sprout

docs / previews

Previews

On this page

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.

Preview lifecycle

Follow one preview from open to close:

create → migrate (app) → seed (optional) → hand over → drop:

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:

yaml
preview:
  hostname: "pr-{pr_id}.myapp.preview.example.com"
  ttl: 7d
  idle_teardown: 2h

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

With sprout ci preview pass repeatable --service name=image. Low-level deploys use the same flag:

bash
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.

yaml
preview:
  services:
    - name: landing
      image: "ghcr.io/org/landing:{commit_sha}"
      hostname: "landing-pr-{pr_id}.myapp.preview.example.com"
      port: 80
bash
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):

yaml
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
bash
sprout deploy -i "$APP_IMAGE" \
  --service api="$API_IMAGE" \
  --service admin="$ADMIN_IMAGE" \
  --service worker="$WORKER_IMAGE"

Mailpit worked example (the image exposes 1025, 1110, 8025 in that order, so the UI needs an explicit port):

yaml
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"
bash
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:

yaml
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:

  1. App container starts (entrypoint waits for Postgres, runs migrations, serves).
  2. Gateway polls health.path on the Postgres-network container IP until health.expect or health.timeout.
  3. After healthy: if a seed image was provided and this PR has never seeded successfully (seeded_at unset), 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, plus seed.env / --seed-env and seed.args / --seed-arg. --reseed clears seeded_at after 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.
  4. Preview status becomes running with seeded_at set.

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:

bash
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 …):

bash
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):

yaml
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):

yaml
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:

yaml
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:

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):

text
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_*:

yaml
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.

yaml
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:

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:

yaml
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):

Share-this-preview-with-a-reviewer recipe:

bash
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:

bash
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:

  1. 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 Traefik forwardAuth address is the gateway's in-network name (SPROUT_PREVIEW_AUTH_ADDRESS), never a public URL.
  2. 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 --revoke on a basic preview 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