Preview deployments
Reviewing a pull request is easier when you can click it. An app with a git source can get a preview per pull request: the request's head built and served on its own URL while the request is open, and removed when it closes or merges, as Dokploy's preview deployments do. A preview starts with an empty environment and its own volumes, so it never touches production's data or secrets unless you say so.
# isb app update web -f previews.yaml
previews:
enabled: true
branches: [main] # base branches watched (default: the app's ref)
max: 3 # previews at once
env: |
MODE=preview
DATABASE_URL=postgres://db.shop-preview/app
API_KEY=${{secret.preview_api_key}}
domain: auto # or *.preview.example.com
status: # optional: a commit status with the URL
token_secret: forge-token
$ isb app previews ls web
APP PR STATUS HEAD COMMIT URL
web #1 done feature 0ba71049 https://web-shop-production-pr-1-acme.203-0-113-10.sslip.io/
$ isb app previews show web 1
$ isb app previews logs web 1 -f
$ isb app previews redeploy web 1
$ isb app previews rm web 1
isb app previews ls [NAME] [--json] | show NAME PR
isb app previews logs NAME PR [ID] [-f] | redeploy NAME PR [-d] | rm NAME PR
The web UI's Previews tab on an app built from git shows the same and edits the settings (The web UI).
What triggers what#
Previews ride on the app's webhook (POST /api/v1/webhooks/<org>/<app>,
Deploy apps) with its signature checks. Subscribe
the webhook to pull request events too: GitHub pull_request, Gitea/Forgejo
"Pull Request" events, GitLab "Merge request events". A ?token= call carries
no pull requests.
| Event | GitHub | Gitea, Forgejo | GitLab | Does |
|---|---|---|---|---|
| opened, reopened | opened, reopened |
opened, reopened |
open, reopen |
create the preview, build, deploy |
| new commits | synchronize |
synchronized |
update with oldrev |
build and roll the preview |
| closed | closed |
closed |
close, merge |
remove the preview |
| anything else | answered 200, ignored |
- Only requests whose base branch is in
branches(default: the app's git ref) get a preview; closing removes one whatever its base. - A new pull request over
maxgets none (ignored, and anerrorcommit status whenstatusis set) until another preview goes. - A synchronize naming the commit the preview already has does nothing (Gitea sends one right after opening).
- Deploys queue per preview like an app's: one at a time, a newer one replacing a waiting one.
The head is fetched with the app's git source, URL and credentials, and the
same hardened git (Deploy apps), from the ref
the forge keeps for the request in the base repository: refs/pull/<n>/head
(GitHub, Gitea, Forgejo) or refs/merge-requests/<n>/head (GitLab). A fork's
commits are fetched from the base repository, never from the fork.
Isolated from production#
| Production | Preview of pull request n |
|
|---|---|---|
| stack | <project>-<env> |
<project>-<env>-pr-<n> (<project>-pr-<n> when that is too long), same org |
| service | <app> |
<app>, labelled isb.preview=<n>; apps of one environment previewing the same number share the stack and reach each other as <app>.<project>-<env>-pr-<n> |
| image | registry:<app>:<sha> |
registry:<app>:pr-<n>-<sha> |
| volumes (incus names) | <project>-<env>_<app>_<name> |
<project>-<env>-pr-<n>_<app>_<name>: fresh, its own |
| environment | the app's env |
previews.env only; the app's too (under it) with inherit_env: true |
| files | the app's files |
none; the app's with inherit_env: true (a fork's: only those whose secret is in fork_secrets) |
| published host ports | the app's ports |
none |
| replicas, resources | the app's | previews.replicas (default 1), previews.resources (default the app's) |
| build cache | build-cache-<app> |
build-cache-<app>-preview; a fork's build-cache-<app>-pr-<n> |
| domain | the app's domains |
previews.domain on previews.port (default the app's port) |
So a preview starts with an empty environment: it never points at
production's database or receives production's secrets unless env (or
inherit_env) says so. Environment names ending in pr-<number> are refused,
so no environment's stack can be mistaken for a preview's.
Domains#
auto(default): the ingress' generated name (Domains and ingress) for the preview's stack,<app>-<project>-<env>-pr-<n>-<org>.<a-b-c-d>.sslip.io.*.<suffix>:<app>-pr-<n>.<suffix>, a concrete name that must be within the org's allowed domains (<suffix>or a parent of it). Point a wildcard DNS record at the server (or the org's tunnel).
The preview's URL (from the ingress, once it serves it) is on the preview
record (url), in the deploy.succeeded event and in the commit status.
Pull requests from forks#
A fork's pull request runs code nobody with push access wrote, so by default
it gets no preview (ignored ... previews for forks are off). With
forks: true it does, and:
- it builds in a VM, whatever the app's
build.untrusted, with a build cache of its own that goes with the preview; - it receives none of the app's secrets: with
inherit_envthe app's plain values come along, its secret references do not; - of the secrets in
previews.env, only those named infork_secretsreach it; the others are left out (the deployment log says which).
A request counts as from a fork when its head repository is not the base repository (GitHub, Gitea: the repositories' full names; GitLab: the project ids), or is unknown (a deleted fork). Once a fork, a preview stays one.
Commit statuses#
With status: {token_secret: NAME} (an org secret holding an API token that
may write commit statuses), each preview deploy posts the commit status
isb/preview on the head commit: pending while it builds, then success
with the preview's URL as the link, or failure with the error. A status with
the same context replaces the last one.
kind |
API | Token header |
|---|---|---|
github |
POST /repos/{owner}/{repo}/statuses/{sha} on https://api.github.com (https://<host>/api/v3 for GitHub Enterprise) |
Authorization: Bearer |
gitea (also Forgejo) |
the same path on https://<host>/api/v1 |
Authorization: token |
kind defaults to the webhook's sender. The API base comes from the app's
git URL (owner and repository from its path); set api_url for anything
else. The token is only sent over HTTPS unless api_url is set to an
http:// URL explicitly. GitLab merge requests get no status. A status that
cannot be posted is a note in the deployment log, never a failed deploy.
Removal and cleanup#
Closing or merging the pull request, preview_delete (isb app previews rm), a ttl, or deleting the app removes a preview:
- a deploy waiting to start is superseded; one running finishes first;
- its service leaves the preview stack (the stack goes with its last
service,
isb stack rm --volumesstyle); - its volumes are deleted (retried while instances wind down), and a fork's build cache;
- its images: every
pr-<n>-*manifest in<org>/<app>that no deployed service runs (the registry deletes manifests, not tags, so a digest a production tag shares stays); the blobs go at the nextisb registry gc; - its records and checkout.
A removal that fails is retried every five minutes by the daemon, which also
removes previews not updated within ttl (7d, 36h; at least 10m;
default none: kept until the pull request closes). Each preview deploy also
deletes that preview's older images. Registry retention
(Builds) never counts pr-* tags among an app's
newest: a preview's image is kept while it is deployed and no longer.
Turning enabled off stops new previews and redeploys; existing ones stay
until their pull requests close or they are deleted.
Events#
On the events feed, under the preview's stack (<org>/<project>-<env>-pr-<n>)
and the app's service: preview.created, deploy.succeeded / deploy.failed
(app web preview #3: deployment 2 done: https://...), preview.removed,
and each deployment log line at level log.
Settings#
| Field | Default | |
|---|---|---|
enabled |
false |
|
branches |
the app's ref | base branches watched |
max |
3 | previews at once (1 to 50) |
env |
empty | .env text or {KEY: value | {secret: NAME}} |
inherit_env |
false |
start from the app's environment |
domain |
auto |
auto or *.<suffix> |
port |
the app's | |
replicas |
1 | 1 to 10 |
resources |
the app's | {cpus, memory} |
ttl |
none | remove when not updated for this long |
forks |
false |
previews for forks' pull requests |
fork_secrets |
none | secrets in env a fork's preview may get |
status |
none | {token_secret, kind?, api_url?} |
Tools#
| Tool | Does |
|---|---|
preview_list |
Previews of an app (name) or of every app in the org, with status and URL. |
preview_get |
One (name, number), with its deployments. |
preview_log |
A preview deployment's log from offset. |
preview_redeploy |
Fetch the head again, build, roll (wait). |
preview_delete |
Remove one now (wait). |
Settings change with app_update ({"name": "web", "previews": {...}}, a
merge patch like every app setting).
On disk#
<state>/apps/<app>/previews/<n>/preview.json
<state>/apps/<app>/previews/<n>/deployments/<id>.json, <id>.log (last 10)
<state>/sources/<app>/previews/<n>/repo
Reaching the webhook#
The forge must reach the daemon's TCP listener, which binds loopback only:
in production that is a Cloudflare Tunnel (with an Access bypass for
/api/v1/webhooks/*, Deploy apps) or a reverse
proxy (Reach isb serve remotely). A forge on the same
host can reach it through a narrow forwarder: when previews were verified
with a forge running in another org, a second Caddy on that org's bridge
address passed only /api/v1/webhooks/* to the daemon's loopback listener,
behind a one-port ufw rule on that bridge.