docs / previews
Previews
On this page
- Preview lifecycle
- Preview TTL and idle teardown
- Preview database roles (db.roles)
- Service images: merge, leave, clear, lifecycle
- Multi-image previews (app + services)
- Per-MR service tags (consumer builds, manifest references)
- Routing (optional)
- Preview labels: adopter-supplied container labels
- Seed run order and resume
- After-healthy hook (seed image)
- SQLite previews
- No-database previews
- Preview app-data volumes
- Email from a preview
- Which preview did this mail come from?
- Shared inbox
- Preview access
- See also
Run one preview per pull request per adopting repo: its preview database and, when configured, its preview app container plus optional companion service containers sharing the same preview database. Each section below is one task.
- Manifest keys: Adopting a repo.
- CI wiring: CI integration.
- Operator side (networks, sweep config): Operator deploy.
Preview lifecycle
Follow one preview from open to close:
create → migrate (app) → seed (optional) → hand over → drop:
- PR opened / synchronize →
sprout deploy:CREATE DATABASEsprout_<slug>_pr<id>, start app container + TraefikHostlabels, optional seed image after the health check. - PR closed →
sprout teardown: stop app / seed / services,DROP DATABASE. - Missed teardown is recovered by the gateway sweep: periodic reconciliation of registered previews vs the Postgres catalog, running containers, and the forge open-PR list.
Synchronize re-deploys keep the same database; only a reset wipes it.
Preview TTL and idle teardown
Bound a preview that nobody closes. Set two optional manifest keys,
durations (30m, 2h, 7d) or off:
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
ttl: 7d
idle_teardown: 2h
preview.ttl— lifetime measured from the last successful deploy; a push (orci reset/ci reseed) refreshes the deadline.preview.idle_teardown— idle lifetime measured from the same signal.
The activity signal is exactly one thing: the last successful deploy,
including reseed and reset. There is no per-preview access-log pipeline in
this release and no edge traffic accounting — a quiet preview that receives
HTTP traffic but no deploy still expires. The gateway's sweep
(SPROUT_SWEEP_CRON, in-process Bun.cron) removes expired previews
through the same path teardown uses (stop container, drop database; the
seed watermark is kept on the tombstone and reset when the next deploy
writes its provisioning intent), holding the per-preview lock so a preview
mid-deploy is never expired. The expiry reason
(sweep:ttl-expired / sweep:idle-expired) is stored on the row and
GET /v1/previews plus the single-preview read return expires_at,
last_activity_at, and the reason; the CI note carries an - Expires: line.
Manifest keys override the gateway defaults (SPROUT_PREVIEW_TTL,
SPROUT_PREVIEW_IDLE_TEARDOWN); off at the effective level disables that
bound. With nothing configured the gateway is unbounded apart from PR-close
teardown and logs a loud boot warning. The legacy creation-age bound
(SPROUT_TTL_HOURS, default 72h) only collects rows that never completed
a deploy; a running preview with both bounds off has no deadline.
Preview database roles (db.roles)
Pick one (single) or two (dual) database LOGINS for a Postgres
preview. single is the owner only; dual adds the per-database
restricted companion (<dbName>_app, injected as PGAPPUSER /
PGAPPPASSWORD, remappable via preview.env). The default is
derived — dual when preview.env remaps a companion key, else
single — and an explicit db.roles wins. Manifest keys, the
contradiction guard, and the RLS recipe live in
Adopting a repo.
Service images: merge, leave, clear, lifecycle
Combine static manifest images with deploy-time --service flags. Static
image in yaml pins the image; --service name=image overlays it —
every service needs an image after merge. Omitting --service leaves
companions in place; --clear-services removes all (cannot combine with
--service). An empty list is rejected (omit the key, or
--clear-services). Reseed bodies carry no service list, so companions
stay as last deployed by construction.
Each service joins the same networks as the app (Traefik + Postgres for
postgres previews, Traefik only for sqlite and none) and receives
the same connection env as the app (including any
preview.env remap; none previews inject no connection env at all).
preview.services[].env adds literal service-only variables on top of
that connection env (gateway connection keys win on collision; nothing
leaks into the app container).
Services are force-removed on teardown (and on
replace) with the app. The health gate covers only the app: after the
app passes health.expect, seed runs (when configured), then companion
services start. There is no per-service health poll in this release.
Multi-image previews (app + services)
Share one preview database across long-lived containers (API + worker, web
- secondary service). Routing examples only — merge, lifecycle, and health-gate rules live above.
With sprout ci preview pass repeatable --service name=image. Low-level
deploys use the same flag:
sprout deploy -i "$APP_IMAGE" \
--service api=ghcr.io/org/api:${SHA} \
--service worker=ghcr.io/org/worker:${SHA}
Per-MR service tags (consumer builds, manifest references)
Pin a per-MR image for a second surface (a separate SPA besides the app) in the manifest and let the CLI resolve it at deploy time. The consumer's pipeline builds and pushes the image; nothing in the component builds service images.
preview:
services:
- name: landing
image: "ghcr.io/org/landing:{commit_sha}"
hostname: "landing-pr-{pr_id}.myapp.preview.example.com"
port: 80
docker build -f apps/landing/Dockerfile -t "$CI_REGISTRY_IMAGE/landing:$CI_COMMIT_SHA" .
docker push "$CI_REGISTRY_IMAGE/landing:$CI_COMMIT_SHA"
image accepts the same placeholders as env values ({hostname},
{pr_id}, {commit_sha}), resolved after manifest/flag merge — so a
--service landing=…:{commit_sha} overlay wins with its own ref resolved.
{hostname} is that service's own resolved hostname when it declares one,
else the app's. Literal refs deploy byte-identical; the deploy body carries
literal refs only.
Routing (optional)
Expose a service by declaring it under preview.services in
.sprout.yaml. Without routing metadata, a service is internal-only
(reachable on the Docker networks, no Traefik router):
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
services:
- name: api
# hostname suffix — distinct Host() rule
hostname: "api-pr-{pr_id}.myapp.preview.example.com"
- name: admin
# path on the app hostname
path: /admin
- name: worker
# no hostname/path → not Traefik-routed
sprout deploy -i "$APP_IMAGE" \
--service api="$API_IMAGE" \
--service admin="$ADMIN_IMAGE" \
--service worker="$WORKER_IMAGE"
hostname—Host(…)(supports{pr_id}like the app hostname).path—PathPrefix(…); combined withHostvia&&. Path-only uses the app hostname.port— Traefikserver.portoverride. Wins over the image's firstEXPOSEd port; without it behaviour is unchanged (firstEXPOSE→SPROUT_PREVIEW_PORT_DEFAULT).env— literal service-only env (NAME: valuestrings, nogenerate:/required:kinds). Lands in the service container only.
Mailpit worked example (the image exposes 1025, 1110, 8025 in that
order, so the UI needs an explicit port):
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
services:
- name: mailpit
hostname: "mailpit-pr-{pr_id}.myapp.preview.example.com"
port: 8025
env:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
sprout deploy -i "$APP_IMAGE" \
--service mailpit=axllent/mailpit:latest
Preview labels: adopter-supplied container labels
Label preview containers for tooling outside the gateway. preview.labels
adds literal key: value string labels to the app container and every
service container; preview.services[].labels adds to that service
container only (same key at both levels resolves to the per-service
value). Labels are orthogonal to routing, so internal (unrouted) services
receive them too. The one-shot seed container never carries adopter
labels. Values are literal only — no {pr_id} interpolation.
Worked example — attach the api service to a Traefik middleware defined
outside the gateway (file provider) and tag every preview container for
the backup tooling that selects containers by label:
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
labels:
com.example.backup: "true"
services:
- name: api
image: ghcr.io/me/api:latest
labels:
traefik.http.routers.api-pr.middlewares: "my-sso@file"
Every other key passes through verbatim, traefik.* included. The
gateway's own keys are reserved: a deploy whose adopter key matches a
gateway-emitted label for that container fails fast with
reserved_preview_label, quoting the manifest path
(preview.labels.traefik.enable,
preview.services[0].labels.traefik.http.routers.sprout-myapp-pr-42.rule).
The reserved set is derived from the gateway's label emission, so it
tracks the TLS and forwardAuth policy of that gateway. Malformed keys,
non-string values, and empty values fail at manifest parse with named
errors in the existing style.
Seed run order and resume
Run "migrate in the app, then seed" with no API-code changes:
- App container starts (entrypoint waits for Postgres, runs migrations, serves).
- Gateway polls
health.pathon the Postgres-network container IP untilhealth.expectorhealth.timeout. - After healthy: if a seed image was provided and this PR has never
seeded successfully (
seeded_atunset), or the incoming seed image differs from the last successful one, the gateway runs the seed image once with the same connection-env remap as the app, plusseed.env/--seed-envandseed.args/--seed-arg.--reseedclearsseeded_atafter a healthy attach (replace) or on seed-phase entry (seed-only), so the same after-healthy gate re-runs even when the seed image is unchanged. - Preview status becomes
runningwithseeded_atset.
On later synchronize deploys, seeding is skipped only when the incoming
seed image matches the last successful one. Same image + hostname:
seed-only (no app container replace). A changed seed image alone re-runs
the seed without replacing the app container. Image or hostname change
still replaces the app, then runs seed after healthy when seeded_at is
unset, the seed image changed, or --reseed was passed. Seed wall-clock
is the gateway env SPROUT_SEED_TIMEOUT (seconds, default 180, applied
internally as seedTimeoutMs); health timeout is separate and never
starts the seed. Seed failure outcomes (exit non-zero, timeout, Docker/ops
error → 500 seed_failed, app stays up and routable, seeded_at unset;
health timeout → health_timeout, app removed, seed never started) and
the resume rule (failed/crash-mid-seed with same image + hostname: redeploy
with -s resumes the seed only; without seed_image, resume returns 422 seed_image_required_to_resume_seeding) are covered under
Troubleshooting.
After-healthy hook (seed image)
Declare the seed once in .sprout.yaml and never pass -s in CI —
sprout ci preview builds + pushes the seed image and deploys with it.
Ordering, resume, timeout, and failure outcomes are contract in the section
above and in Troubleshooting.
Without seed.inputs the tag is commit-scoped (<SHA>-seed) and the
image is rebuilt on every run; with explicit seed.inputs the tag is
seed-<shorthash> content-addressed over those inputs (list every COPY
source the seed image depends on). When the content-addressed tag already
exists in the registry the build + push is skipped
(seed image reused: <ref> in the job log) and the existing image
deploys; a failed or unsupported registry check rebuilds instead of
skipping. The low-level equivalent is sprout deploy -i … -s … with
--seed-env / --seed-arg:
sprout deploy -i "$APP_IMAGE" -s "$SEED_IMAGE" \
--seed-env FIXTURE_SET=demo \
--seed-arg --reset
examples/adopting-repo/Dockerfile.seed
shows a minimal seed image: install deps, copy seed script, entrypoint runs
bun run seed with the same connection env the gateway injects (default
PG*, or remapped names from preview.env).
To force a re-seed against the existing database without tearing down, pass
--reseed with -s (or sprout ci reseed -s …):
sprout deploy -i "$APP_IMAGE" -s "$SEED_IMAGE" --reseed
SQLite previews
Serve a SQLite stack from a preview. Set db.provider — sprout ci preview picks it up from .sprout.yaml (no new flag):
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
env:
DATABASE_URL: APP_DATABASE_URL
db:
provider: sqlite
path: /data
file: preview.db
Env keys: the gateway injects exactly one connection variable,
DATABASE_URL=file:<db.path>/<db.file> (remap replaces the name, no dual
alias). PG* remaps are rejected for SQLite previews, and DATABASE_URL
is rejected for Postgres ones — both at manifest parse and at the gateway
deploy route.
Volume and seed behaviour: bring-up creates one named Docker volume per
preview (sprout-<slug>-pr-<id>-sqlite) mounted at db.path in the
app, companion-service, and seed containers (seed inputs still own the
fixtures; --reseed semantics are unchanged). An app-image replace keeps
the volume, so preview data survives; teardown removes the containers and
the volume on the same paths that drop a Postgres database today. Health,
TTL, sweep, and teardown are otherwise unchanged.
Hand-rolled migration notes (moving an app from a Postgres preview to a
SQLite one): point the app at the injected DATABASE_URL instead of the
PG* set (SQLite opens the file directly — no host, port, user, or
password); run file-level migrations at container startup as before (the
file persists on the volume across replaces); keep companion PGAPP*
assumptions out of the SQLite path (there is no restricted role — the file
is the database; likewise a single Postgres preview injects no
PGAPP*). There is no gateway tooling that copies a Postgres preview into
a SQLite volume in this release.
Operators: a gateway that only serves SQLite previews needs no Postgres env
at all (SPROUT_PREVIEW_POSTGRES_URL, SPROUT_PG_HOST/USER/PASSWORD,
SPROUT_POSTGRES_NETWORK are required only for postgres deploys). A
postgres deploy on such a gateway fails fast with postgres_not_configured,
naming the repo and the missing variables.
No-database previews
Serve an app with no database — static or SSR frontends, apps whose data
lives behind an external API, worker-only services. Set db.provider to
none. sprout ci preview picks it up from .sprout.yaml (no new flag):
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
db:
provider: none
The preview is app container + optional companion services + routing +
health, with no database at all: bring-up provisions nothing and injects
no connection env, teardown removes only containers, and the status and
previews rows carry db_name: null. Companion services keep working
exactly as today minus the database env (Traefik network only, no
PG* / DATABASE_URL keys).
Two combinations are validation errors rather than silent no-ops, both at
manifest parse and at the gateway deploy route: a seed: block (seed requires db.provider postgres or sqlite (db.provider is none)) and any
preview.env database-key remap (preview.env.PGHOST requires db.provider postgres). sprout ci reseed, sprout deploy -s …, and
--reseed fail fast with the seed error on a none repo instead of a 5xx.
Operators: a gateway whose repos are all none boots with no
SPROUT_*PG* / SPROUT_POSTGRES_NETWORK set; with any postgres repo
onboarded it still fails fast when they are missing (same conditional
requirement as SQLite). Switching a repo between none and a database
provider redeploys as a fresh generation: the old backend is dropped
before the row is rewritten, so no resource strands.
Preview app-data volumes
Keep files the app writes at runtime across synchronize re-deploys. A
preview's writable container filesystem is thrown away on every replace
(any push) — but a per-PR Postgres database survives, because it lives on
the shared instance. Anything the app writes at runtime into its container
is lost while its database rows survive, so the app can boot into an
inconsistent state no CI signal shows. preview.volumes opts container
paths into the same lifetime the database already has:
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
volumes:
- /data/documents
Each entry gets one named per-preview Docker volume
(sprout-<slug>-pr-<id>-data-<n>, indexed in manifest order), mounted at
that path in the app, companion-service, and seed containers. The contract:
- Replace keeps. Synchronize re-deploys mount the same volume, so files written by the app are still readable after a push.
- Reset wipes.
sprout ci reset(teardown + redeploy) drops and recreates the volume: the same name comes back empty. - Teardown removes.
sprout teardown, sweep expiry, and the sweep's orphan pass remove the volumes, so nosprout-<slug>-pr-<id>-data-*volume outlives its preview. - Inert by default. Without the key no volume is created and containers are byte-identical to today's.
Validation fails at manifest parse, naming preview.volumes: relative
paths, / itself, .. segments, duplicates, nested entries (one path
equal to or inside another), and any entry equal to or nested inside the
SQLite db.path (or vice versa) are each rejected.
Volume names are index-keyed, so treat the list as append-only: appending an entry is safe, but reordering or removing one silently re-points an existing volume's contents at a different mount path (and strands the tail volume). Shrinking the list does not delete the surplus volumes either — they linger until teardown or the sweep's orphan pass removes them.
Writability rule: a fresh named volume starts empty. Docker copies the image's content and ownership at that path into the volume only when the path exists in the image. An app running as a non-root user must therefore create the path in the image with the right ownership (or chown it in the entrypoint) — otherwise the runtime user cannot write the root-owned directory it gets.
Email from a preview
Send mail from a preview through a gateway-configured Mailpit. The
operator owns the Mailpit instance and the SPROUT_MAIL_* gateway
variables (see
Operator deploy); the adopter
owns only the mail: block and the preview.env remap. Mail works on any
db.provider — postgres, sqlite, and none — and needs no
provisioning, health gate, or lifecycle: the gateway only injects env and
joins a network.
What the app receives (canonical names, injected into app, companion service, and seed containers):
MAILHOST MAILPORT MAILFROM MAILFROMNAME MAILREPLYTO
MAILUSER MAILPASSWORD (only when the operator configures credentials)
MAILSECURE (only when true, as the string "true")
MAILUIURL (only when the operator configures an inbox URL)
preview.env renames these exactly like the PG* set: unmapped keys keep
their canonical name; a remap replaces the name (no dual alias). Gateway
mail keys win over colliding preview.app_env keys — do not put
MAILHOST or a remapped name into SPROUT_APP_ENV. Worked remap for an
app that speaks SMTP_*:
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
env:
MAILHOST: SMTP_HOST
MAILPORT: SMTP_PORT
MAILUSER: SMTP_USER
MAILPASSWORD: SMTP_PASS
The entrypoint must read the adopter names (SMTP_HOST, …).
The mail: block has three postures. Omitted is opportunistic: mail env is
injected when the gateway configures it and silently skipped when it does
not. Explicit mail: enabled requires mail: on a gateway without
SPROUT_MAIL_* the deploy fails fast with mail_not_configured (repo <repo> declares mail enabled but the gateway has no mail configured: missing SPROUT_MAIL_HOST — same wording from the CLI and the gateway). mail: none
opts out: no mail env is injected for that repo, and the preview never shows
the Mailbox: note line.
mail: enabled # require mail; fail fast without a configured gateway
mail: none # opt out even when the gateway configures mail
mail:
mode: enabled
from: "noreply+{pr_id}@preview.invalid" # send-from override (below)
To enable mail: keep the mail: block omitted (or enabled), remap
preview.env onto the app's SMTP names, deploy, and look for the preview's
From address in the inbox linked from the MR note (Mailbox: line).
Which preview did this mail come from?
Tell deployments apart in the shared inbox by the per-preview From address:
MAILFROMdefaults to<slug>-pr<pr_id>@<from-domain>, e.g.myapp-pr42@preview.invalid.MAILREPLYTOcarries the same address.MAILFROMNAMEis the human label<slug> PR <pr_id>(e.g.myapp PR 42).- The from-domain is the operator's
SPROUT_MAIL_FROM_DOMAIN(defaultpreview.invalid, a reserved suffix that can never deliver real mail).
mail.from overrides the address with a {pr_id} template using the same
grammar as preview.hostname — it must contain {pr_id}, support no
other placeholder, contain no whitespace, and read as an address once
{pr_id} is substituted. An app that must send from its own convention
points that convention at the preview-identifying address:
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
env:
MAILFROM: MAIL_FROM # app's own from variable reads the identity
mail:
from: "noreply+{pr_id}@preview.invalid"
mail.from with mail: none is rejected (mail.from requires mail enabled); malformed templates fail at manifest parse (mail.from …
naming the problem, e.g. must contain {pr_id}).
The MR note and sprout list show what to filter for: the note gains
- Mail from: <address> alongside - Mailbox: <inbox-url>, sprout deploy / sprout ci preview print mail_from= (and mailbox_url=), and
sprout list includes mail_from, mail_from_name, and mailbox_url for
previews that received mail env. Inbox recipe: search the Mailpit UI for
the preview's From address, or query the Mailpit API
(GET /api/v1/messages) and keep messages whose From.Address equals
that address.
Shared inbox
All previews on one gateway write into one mailbox — there is no per-preview isolation. Tell mail apart by the recipient / From / subject convention above, not by separate inboxes. If previews must not see each other's mail at all, run a second Mailpit plus a second gateway pointed at it; one gateway holds exactly one mail configuration.
Preview access
Gate one preview without touching the app. Previews are open by design:
anyone who knows the hostname reaches them. Two opt-in postures, declared
with preview.auth (none default; basic or link; see
Adopting a repo):
basic— one username/password per preview, generated by the gateway at deploy. Anonymous requests get401; the documented credential gets200. The credential is printed bysprout access <pr>and in the CI note, never in gateway logs.link— shareable, revocable, host-scoped links.sprout access <pr>mints a link (default expiry7d,--expires 12hto override); opening it on the preview's own host sets a cookie for that host only. The same cookie on another preview's host is rejected.sprout access <pr> --revokerotates the preview's secret and invalidates outstanding links with no redeploy, no container recreation, no gateway restart.
Share-this-preview-with-a-reviewer recipe:
sprout access 42 --repo https://github.com/org/repo
# Shareable link (expires 2026-10-09T00:00:00.000Z):
# https://pr-42.myapp.preview.example.com/__sprout/auth?t=<token>
Send that URL to the reviewer. When they are done, revoke it:
sprout access 42 --revoke --repo https://github.com/org/repo
# Outstanding links revoked with no redeploy.
Wire contract: by default the app receives no identity header at all. The gate is Traefik middleware plus the gateway — anonymous traffic never reaches the app container, and authorised traffic arrives unmodified. (If a future posture propagates identity, it will be recorded alongside the env grammar decision in the maintainer records.)
Companion services keep their own protection and are never double-gated: the gate attaches to the app router only. Reset, reseed, and teardown keep working with a gate on — they act through the gateway and the forge, not through the gated preview host. Expiry is checked on every request, not only when the cookie is set.
Two structural traps, so nobody rebuilds the abandoned shape:
- There is no Cloudflare-free path from the preview edge to an external
auth service — every Cloudflare-mediated path rewrites
X-Forwarded-Host, so an external gate rebuilt its own URL from the header, took its self-request shortcut, and authorised everything. The gate therefore lives in the gateway, which is already on the preview network; the TraefikforwardAuthaddress is the gateway's in-network name (SPROUT_PREVIEW_AUTH_ADDRESS), never a public URL. - The basic-auth password is baked into a Traefik label at deploy, so a
rotated password applies on the next deploy — only link secrets rotate
live.
sprout access --revokeon abasicpreview says so.
link needs the gateway capability: SPROUT_PREVIEW_AUTH_SECRET plus
SPROUT_PREVIEW_AUTH_ADDRESS, set together or both empty (partial sets
fail gateway boot naming the missing key). Asking for link on a gateway
without it fails the deploy with preview_auth_not_configured. basic
needs no gateway env. With the feature off, deploy labels, URLs, and
behaviour are identical to an ungated gateway.
See also
- Adopting a repo — manifest reference
- CI integration — reset, notes
- Operator deploy — networks, mail instance, sweep
- Troubleshooting — seed and mail errors