sprout

docs / operator-deploy

Operator deploy

On this page

Deploy Postgres, the sprout gateway, and Traefik once per environment. Adopting repos then call sprout deploy / sprout teardown from CI — no per-repo server setup. Each section below is one task.

Prerequisites

Quick start (local smoke)

Bring up the reference stack from the repo root:

bash
cp compose.env.example compose.env
# Edit POSTGRES_PASSWORD, SPROUT_PREVIEW_POSTGRES_URL (keep in sync), SPROUT_PG_PASSWORD.
# URL-encode special characters in the DSN password.

docker compose --env-file compose.env up -d --build
docker compose --env-file compose.env ps
curl -sf http://127.0.0.1:7331/healthz
# Traefik HTTP entrypoint (host port TRAEFIK_HTTP_PORT, default 8880)
curl -sf http://127.0.0.1:${TRAEFIK_HTTP_PORT:-8880}/ || true

compose.env is the compose project env. Do not copy it to .env — .env / .env.example are for the gateway process on the host (bun run dev).

The stack in docker-compose.yml is the reference operator compose stack for local development and CI smoke. The E2E acceptance harness (e2e/) runs the same file with --env-file e2e/compose.e2e.env — see e2e/README.md or bun run test:e2e. Default network names (sprout-traefik, sprout-postgres) are project-local so a smoke up does not collide with an existing Coolify Traefik network named traefik. Host ports are SPROUT_GATEWAY_HOST_PORT (default 7331) and TRAEFIK_HTTP_PORT (default 8880); the harness ports come from e2e/compose.e2e.env.

Gateway Docker image

Ship one image, one process (root Dockerfile). The image also embeds the sprout CLI (same Bun lockfile / version pin as the gateway) so operators can docker exec against localhost without a host-side install. Compose builds it via build: .; build and tag it alone for registry push or external orchestrators:

bash
# From the repo root (reproducible with Bun 1.4.0 base + frozen lockfile)
docker build -t ghcr.io/simpros/sprout:0.9.0 \
  --build-arg SPROUT_VERSION=0.9.0 \
  .
# Optional: push after docker login to GHCR (or your registry)
# docker push ghcr.io/simpros/sprout:0.9.0

Image label org.opencontainers.image.version mirrors SPROUT_VERSION (Dockerfile default tracks the monorepo pin; prefer a published release tag such as v0.9.0 / image :0.9.0 in production). Pin operators and CI to a release tag or GHCR digest — not an untagged local build — when publishing previews.

Architecture

Route PR traffic through the three services:

text
                    ┌─────────────┐
   PR traffic ─────►│   Traefik   │  network: $SPROUT_TRAEFIK_NETWORK
                    │  (labels)   │
                    └──────┬──────┘
                           │ preview app containers
                    ┌──────▼──────┐
                    │   gateway   │  networks: traefik + postgres
                    │  (sprout)   │  + Docker socket
                    └──────┬──────┘
                           │ CREATE/DROP DATABASE (admin)
                    ┌──────▼──────┐
                    │  Postgres   │  network: $SPROUT_POSTGRES_NETWORK
                    │  (shared)   │
                    └─────────────┘

Defaults

Durable runtime identity uses product sprout* names:

WhatDefault
Compose project / volumessprout / sprout_*
Control-plane SQLite/data/sprout.db (compose) / sprout.db (host)
Bootstrap admin token fileSPROUT_ADMIN_TOKEN_PATH (/data/admin-token compose/image; admin-token host default); mode 0600
Traefik networksprout-traefik
Postgres networksprout-postgres
Preview Postgres rolessprout_admin / sprout_preview
Preview container namessprout-<slug>-pr-<id>
Preview database namessprout_<slug>_pr<id>

Dual network attach

Point the gateway at two Docker network names:

VariablePurpose
SPROUT_TRAEFIK_NETWORKNetwork shared with Traefik. Preview app containers join this network so Traefik can route traffic via Docker labels.
SPROUT_POSTGRES_NETWORKNetwork shared with Postgres. Gateway, preview app, and seed containers join this network so they can reach the database by hostname.

Compose declares both networks with name: ${SPROUT_…} so the same variable is the single source for the Docker network name and the gateway env (defaults are project-local):

yaml
networks:
  traefik:
    name: ${SPROUT_TRAEFIK_NETWORK:-sprout-traefik}
  postgres:
    name: ${SPROUT_POSTGRES_NETWORK:-sprout-postgres}

When the gateway creates a Postgres preview app container it attaches both networks. Postgres seed containers get Postgres only — they never need Traefik reachability. SQLite preview containers (app, services, seed) mount their named volume instead and join Traefik only; a SQLite-only gateway needs no SPROUT_POSTGRES_NETWORK at all.

One-click Coolify (Docker Compose Empty)

Stand up a gateway with its own Postgres inside Coolify. Paste deploy/coolify/gateway.compose.yml and follow deploy/coolify/README.md (wildcard DNS, preview hostname template, optional mail/forwardAuth). Both SPROUT_TRAEFIK_NETWORK and SPROUT_POSTGRES_NETWORK point at the predefined external coolify network — the only name known at template time that the Coolify proxy (coolify-proxy) is attached to.

Production-shaped deploy (external / Coolify Traefik)

Coexist with an externally managed Traefik (including Coolify's) without forking the reference file. sprout does not manage Traefik or call the Coolify API. It registers routes by setting standard Traefik Docker labels on preview app containers. Use the overlay:

  1. Set SPROUT_TRAEFIK_NETWORK / SPROUT_POSTGRES_NETWORK in compose.env to the existing network names (Coolify's predefined shared network is coolify).
  2. For HTTPS routers, set Traefik TLS knobs to match that proxy — e.g. SPROUT_TRAEFIK_ENTRYPOINTS=https and optionally SPROUT_TRAEFIK_CERTRESOLVER=letsencrypt on Coolify (HTTP-01 is fine for small fleets). If a single wildcard / DNS-01 is needed for rate limits or fleet growth, follow Wildcard preview certificate (DNS-01) — often the name letsencrypt is kept after converting that resolver. Leave both unset for HTTP-only Traefik (bundled compose / local). Empty entrypoints keeps TLS off even if certresolver is set.
  3. For an SSO gate on preview hosts (Traefik forwardAuth), set both SPROUT_TRAEFIK_MIDDLEWARES (a single name, e.g. voidauth) and SPROUT_FORWARDAUTH_ADDRESS (a URL Traefik can reach). The gateway emits the middleware definition and the router attachment as Docker labels — no Traefik static/file config. Leave both empty for open previews.
  4. Ensure those networks exist (docker network create … if needed).
  5. Bring up only the gateway against external networks:
bash
docker compose -f docker-compose.yml -f docker-compose.external.yml \
  --env-file compose.env up -d --build gateway

The overlay marks both networks external: true and disables the bundled traefik / postgres services (via profiles). Point SPROUT_PREVIEW_POSTGRES_URL at the external Postgres admin DSN.

Also set SPROUT_PG_HOST (and SPROUT_PG_PORT if not 5432) to the hostname preview app and seed containers use to reach Postgres on SPROUT_POSTGRES_NETWORK. That is often different from the host in the admin DSN (dual-homed setups). The bundled default postgres only works when a service with that DNS name exists on the network.

The gateway creates or syncs the preview login (SPROUT_PG_USER / SPROUT_PG_PASSWORD) on boot from the admin DSN — no manual CREATE ROLE and no compose one-shot. The admin role needs CREATEROLE (or superuser); otherwise boot fails with a clear error.

Also ensure the external Traefik has --providers.docker=true and --providers.docker.exposedbydefault=false (or equivalent) so only labelled containers are published.

Label conventions the gateway applies:

When TLS is enabled (SPROUT_TRAEFIK_ENTRYPOINTS non-empty), also:

TLS is opt-in: unset/blank entrypoints → HTTP labels only (works with the bundled Traefik web entrypoint). Entrypoints without certresolver uses Traefik's default/builtin cert. Entrypoint and certresolver names are not Coolify-specific — set them to match the Traefik in use (e.g. Coolify often uses https + letsencrypt; stock Traefik quickstarts often use websecure + a custom resolver name).

When forwardAuth is enabled (both SPROUT_TRAEFIK_MIDDLEWARES and SPROUT_FORWARDAUTH_ADDRESS non-empty), also:

(<mw> is the single middleware name from SPROUT_TRAEFIK_MIDDLEWARES.)

ForwardAuth is opt-in and Coolify-safe: Coolify's Traefik uses the Docker provider only, so sprout must emit both the middleware definition and the router attachment on preview containers (no Traefik file/static config). Both env vars must be set together (or both empty); setting only one fails at gateway boot. SPROUT_TRAEFIK_MIDDLEWARES is one name (commas rejected).

Per-repo alternative: adopting repos can attach their own previews to Traefik routers or middlewares defined outside the gateway with preview.labels / preview.services[].labels in .sprout.yaml (see Preview labels). The gateway-wide knobs above change every preview on the gateway; the manifest keys change one repo's previews. Gateway-emitted keys stay reserved — a manifest key colliding with one fails the deploy fast instead of silently overriding routing.

VoidAuth / SSO setup: add the preview domain (e.g. *.internal.example.com) in the VoidAuth UI, then point SPROUT_FORWARDAUTH_ADDRESS at the forward-auth endpoint (e.g. https://auth.example.com/api/authz/forward-auth). That URL must be reachable from Traefik (not only from browsers). Unauthenticated visits redirect to login; authenticated visits reach the preview app.

Coolify-managed Traefik already watches the Docker socket; sprout preview containers appear alongside Coolify apps as long as they share the Traefik network.

Per-preview access gate (preview.auth)

Offer adopters the per-repo opt-in (preview.auth: basic | link, see Previews). basic needs nothing from the operator. link needs both SPROUT_PREVIEW_AUTH_SECRET (HMAC root for shareable tokens) and SPROUT_PREVIEW_AUTH_ADDRESS (the gateway's in-network forwardAuth URL, e.g. http://gateway:7331/v1/internal/preview-auth) — set together or both empty; a partial set fails gateway boot naming the missing key. The address must be Traefik-reachable without leaving the Docker network: that is the whole point (an external auth service has no Cloudflare-free path to the preview edge, and Cloudflare rewrites X-Forwarded-Host, so an external gate authorised everything). Never point it at a public URL.

Wildcard preview certificate (DNS-01)

Stop per-preview Let's Encrypt issuance under fleet growth. Per-preview certs hit the 50 certificates / registered domain / week rate limit under fleet growth or ACME-store wipes. One wildcard on the preview domain (*.previews.example.com) removes per-deploy issuance: Traefik serves the stored cert for every preview host without ordering a new one.

sprout still emits optional tls.certresolver labels (#102). Traefik resolves via that named resolver and must find a covering cert in that resolver's store. With *.PREVIEW_DOMAIN already there, Traefik should log a store hit / No ACME certificate generation required for the preview FQDN — not a new LE order. Wrong resolver name → per-host orders resume (the rate-limit failure mode).

Opt-in wildcard mode (gateway-declared domains)

Off by default. Set SPROUT_TRAEFIK_WILDCARD_TLS=true and the gateway's preview routers additionally declare tls.domains[0].main=*.<suffix> + tls.domains[0].sans=<suffix>, where <suffix> is the workload's hostname with its first label removed (pr-42.a.previews.example.com → a.previews.example.com). The requested set equals the bootstrap compose's shape (wildcard main + apex SAN), so a hand-bootstrapped wildcard is reused instead of duplicated, and companion services under the same suffix emit the same set (Traefik deduplicates by domain set — one order serves all of them).

Prerequisites:

With the flag off, emitted labels are byte-identical to today.

Ready-to-apply fragments (placeholders only): deploy/traefik/README.md.

Prerequisites (secrets / access — not in this repo)

Collect these before the runbook; the repo ships fragments only:

NeedWhy
DNS provider API credentials for the preview zoneTraefik dnsChallenge must create/delete _acme-challenge TXT records
Write access to Traefik static config (or managed-proxy UI equivalent)Convert or add a dnsChallenge certificates resolver + durable ACME storage
Ability to inject provider env vars into the Traefik processProvider plugins read tokens from the environment (names are provider-specific)
Durable volume (or equivalent) for ACME storageCert + account must survive Traefik restarts/upgrades

Do not invent provider field names here — look up Traefik's docs for the DNS provider in use. Fill placeholders in deploy/traefik/certificates-resolver.dns.yml for the resolver body used in step 2.

Choose a path

PathWhenGateway certresolver
Default (simple)Traefik's job for this zone is preview TLS, or DNS API creds already cover every host Traefik terminatesKeep existing name (e.g. letsencrypt)
Coexistence (optional)Coexistence with HTTP-01 for other apps must remainPoint sprout at the new DNS-01 name (e.g. letsencrypt-dns)

Runbook

  1. Identify the DNS provider for the preview domain (the zone that serves *.previews.example.com). Create API credentials with permission to manage TXT records for ACME. Record the Traefik provider name and required env vars from Traefik's DNS Providers list.

  2. Resolver setup — pick one branch from the table above:

    • Default — convert in place. In Traefik static config (or Coolify proxy settings), replace the existing resolver's httpChallenge block with the dnsChallenge shape from deploy/traefik/certificates-resolver.dns.yml (same key, often letsencrypt; keep the same durable storage path). Converting replaces the challenge type for every consumer of that resolver name — if any other app must stay on HTTP-01, stop and use coexistence instead. Inject <DNS_PROVIDER_ENV_*> into the Traefik process. No second resolver, no second acme.json, no rename dance.

    • Coexistence — add a second resolver. Traefik requires one storage file per certificates resolver — reusing the HTTP-01 path (e.g. /data/acme.json) causes init conflicts or a corrupted store. Copy the fragment body under a new key (e.g. letsencrypt-dns:), set storage to a distinct path (e.g. /data/acme-dns.json), and leave the HTTP-01 resolver untouched. Paste under the existing certificatesResolvers: map (do not overwrite the whole static ACME document). Merged shape:

      yaml
      certificatesResolvers:
        letsencrypt:                    # existing HTTP-01 — leave as-is
          acme:
            email: "<ACME_EMAIL>"
            storage: "/data/acme.json"  # HTTP-01 store
            httpChallenge:
              entryPoint: http
        letsencrypt-dns:                # fragment body under a new key
          acme:
            email: "<ACME_EMAIL>"
            storage: "/data/acme-dns.json"  # MUST differ from HTTP-01
            dnsChallenge:
              provider: "<DNS_PROVIDER>"

      Inject <DNS_PROVIDER_ENV_*> into the Traefik container/process.

  3. Issue the wildcard once with a temporary router, then remove it. The cert stays in the ACME store. Set CERTRESOLVER_NAME to the DNS-01 resolver from step 2 (letsencrypt on default; letsencrypt-dns on coexistence). Compose fails closed if required env is unset:

    bash
    export PREVIEW_DOMAIN=previews.example.com          # your preview base domain
    export TRAEFIK_NETWORK=traefik                      # shared Docker network
    export CERTRESOLVER_NAME=letsencrypt                # match step 2 resolver
    export HTTPS_ENTRYPOINT=https                       # match your Traefik
    
    docker compose -f deploy/traefik/wildcard-bootstrap.compose.yml up -d
    # Wait until Traefik logs show successful ACME obtain for
    # *.${PREVIEW_DOMAIN} (and the apex SAN). Then:
    docker compose -f deploy/traefik/wildcard-bootstrap.compose.yml down

    The bootstrap compose attaches tls.domains[0].main=*.${PREVIEW_DOMAIN} so Traefik requests a wildcard (DNS-01), not a single-host cert for bootstrap.*.

  4. Point sprout at that resolver (names must match Traefik). Default keeps Coolify quickstart unchanged; coexistence points at the new name:

    bash
    # compose.env / gateway env
    SPROUT_TRAEFIK_ENTRYPOINTS=https
    SPROUT_TRAEFIK_CERTRESOLVER=letsencrypt       # or letsencrypt-dns on coexistence

    Deploy a preview. Traefik should terminate TLS with the stored wildcard; no new LE order for that hostname (store hit / No ACME certificate generation required in Traefik ACME logs). Other Coolify apps can keep using letsencrypt (HTTP-01) on the coexistence path; preview hosts must use the resolver that holds *.PREVIEW_DOMAIN.

  5. Monitor renewal. Let's Encrypt wildcards renew before expiry (~30 days out). Confirm Traefik still has DNS credentials and that ACME storage is writable (the DNS-01 resolver's path — coexistence: the distinct second file). Optionally dry-run against the staging CA (caServer) on a non-prod Traefik first.

Managed Traefik notes (Coolify and similar)

sprout does not configure Coolify's proxy. On a managed Traefik:

Verification (zero LE orders per deploy)

After the wildcard is in the store:

CheckHow
Wildcard present in ACME storeInspect the DNS-01 resolver's storage path (or Traefik API / dashboard certificates) for *.previews.example.com (and apex SAN if requested).
ACME survives restartRestart Traefik; confirm the same store file/volume still lists the wildcard; HTTPS to an existing preview host still presents that cert.
New preview → zero new LE ordersNote LE account order count or Traefik ACME log cursor. sprout deploy a fresh PR host. Confirm logs show a store hit / No ACME certificate generation required for that FQDN against the resolver that holds the wildcard — no Obtain / new order; openssl s_client (or browser) shows the wildcard cert (CN/SAN includes *.previews.example.com).
Rate-limit safetyOptional: temporarily revoke network to the DNS API and deploy again — TLS should still work from the store (renewal would fail later; issuance on deploy must not be required).

Acceptance from #105: new preview deploys order zero LE certificates (store hit); ACME storage survives Traefik restarts/upgrades; renewal observed or staging dry-run verified.

Preview mail (Mailpit)

Give previews a shared mailbox. The reference compose stack ships a Mailpit service for preview mail, and the gateway joins its mail network (SPROUT_MAIL_NETWORK, default sprout-mail). Preview app, companion-service, and seed containers receive the canonical MAIL* env (see Previews) and join that network, so they reach Mailpit by hostname. Mail is never provisioned per preview: one shared Mailpit, one shared inbox for the whole gateway.

Point at an external Mailpit (a Mailpit running outside the preview networks, or a long-lived instance shared across environments):

  1. Set SPROUT_MAIL_HOST to the hostname preview containers use to reach it on SPROUT_MAIL_NETWORK — a container-network DNS name, mirroring how SPROUT_PG_HOST names Postgres. That is often different from the host an operator's browser uses.
  2. Set SPROUT_MAIL_NETWORK to the Docker network Mailpit is attached to (create it first if needed). Preview app containers join it alongside Traefik (+ Postgres for postgres previews); seed containers join it whenever a seed can run.
  3. Set SPROUT_MAIL_UI_URL to the inbox URL humans open (surfaced in the MR note, sprout list, and deploy output).
  4. Leave SPROUT_MAIL_PORT at 1025 unless the instance listens elsewhere; leave SPROUT_MAIL_USER / SPROUT_MAIL_PASSWORD empty when Mailpit accepts any credentials (the bundled service does — MP_SMTP_AUTH_ACCEPT_ANY=1), otherwise set both.

Unset group = no mail: with no SPROUT_MAIL_* set, previews deploy without mail env. Setting any of them without SPROUT_MAIL_HOST fails gateway boot (Incomplete mail configuration: missing SPROUT_MAIL_HOST).

How the mailbox is protected

Treat the mailbox as internal. The bundled compose publishes Mailpit's UI (8025) and SMTP (1025) on host ports for local smoke; in production:

Dashboard (read-only, opt-in)

Serve a human-readable page of live previews at GET /dashboard — one row per preview with repository slug, PR id linked to the forge PR, status, clickable preview URL, database name and provider, created and last-deploy timestamps, mail identity when mail is configured, and the last deploy outcome or error code. It renders from the same state the GET /v1/previews read surface reads, costs one state query per request regardless of preview count (no per-preview container inspection), and ships as server-rendered HTML with inline styles only: no frontend toolchain, no JavaScript, no external asset fetch.

Operator-only: the page lists every preview hostname on the gateway, so it is off by default and protected by its own HTTP basic credentials — never shared links, never unauthenticated. Enabling it without credentials fails gateway boot instead of serving a public page.

bash
SPROUT_DASHBOARD_ENABLED=true
SPROUT_DASHBOARD_AUTH=basic
SPROUT_DASHBOARD_USER=operator
SPROUT_DASHBOARD_PASSWORD=change-me-dashboard
# Optional: serve only behind your own proxy hostname + certificate.
# Requests to any other Host get 404, as if the flag were off.
# SPROUT_DASHBOARD_HOST=previews-status.example.com

With the flag unset or false, /dashboard is 404 and the response surface is unchanged. The page is read-only by construction: only GET is reachable, and it carries no write controls and no token, password, or credential material.

Dashboard at phone width

Env var reference

Set gateway env from this table. Canonical compose keys live in compose.env.example. Host bun run dev uses .env.example (same gateway names; different defaults for host networking). Keep POSTGRES_* and SPROUT_PREVIEW_POSTGRES_URL in sync in compose.env; do not synthesize the DSN from the raw password in YAML.

Compose project (compose.env)

VariableRequiredDescription
POSTGRES_USERyes (bundled)Postgres image bootstrap user (default sprout_admin)
POSTGRES_PASSWORDyes (bundled)Must match the password embedded in SPROUT_PREVIEW_POSTGRES_URL
POSTGRES_DBnoBootstrap DB (default postgres)
SPROUT_PREVIEW_POSTGRES_URLpostgres previewsAdmin DSN for role ensure, CREATE DATABASE, DROP DATABASE (needs CREATEROLE or superuser). URL-encode special chars in the password. A gateway that only serves sqlite previews boots without it.
SPROUT_PG_HOSTpostgres previewsHostname preview app+seed containers use for PGHOST on SPROUT_POSTGRES_NETWORK (bundled: postgres)
SPROUT_PG_PORTnoPGPORT for preview containers (default 5432)
SPROUT_PG_USERpostgres previewsStatic preview login; gateway ensures it exists
SPROUT_PG_PASSWORDpostgres previewsPreview PGPASSWORD; synced onto the role on every gateway boot
SPROUT_TRAEFIK_NETWORKyesDocker network name for Traefik-facing containers
SPROUT_POSTGRES_NETWORKpostgres previewsDocker network name for database reachability
SPROUT_MAIL_HOSTmail previewsMailpit hostname preview containers use on SPROUT_MAIL_NETWORK (bundled: mailpit). Any other SPROUT_MAIL_* set without it fails boot (Incomplete mail configuration: missing SPROUT_MAIL_HOST); unset group = no mail env injected
SPROUT_MAIL_PORTnoMAILPORT for preview containers (default 1025)
SPROUT_MAIL_USER / SPROUT_MAIL_PASSWORDnoOptional credentials (MAILUSER / MAILPASSWORD); omitted keys are not injected at all. Leave empty when Mailpit accepts any credentials
SPROUT_MAIL_SECUREnotrue/false (1/0, yes/no accepted; default false). MAILSECURE=true is injected only when true; anything else fails boot
SPROUT_MAIL_NETWORKmail previewsExtra Docker network preview app (+ seed, when seedable) containers join to reach Mailpit (mirrors SPROUT_POSTGRES_NETWORK)
SPROUT_MAIL_UI_URLnoInbox URL for humans — MR note Mailbox: line, sprout list, deploy output — and the injected MAILUIURL (omitted when empty)
SPROUT_MAIL_FROM_DOMAINnoFrom-domain for the per-preview identity (default preview.invalid, never deliverable)
SPROUT_GATEWAY_HOST_PORTnoHost port published for the gateway (default 7331)
TRAEFIK_HTTP_PORTnoHost port published for Traefik HTTP (default 8880)
SPROUT_ADMIN_TOKENnoPin bootstrap admin bearer; omit/blank to auto-generate
SPROUT_STATE_DB_PATHnoSQLite path (compose default /data/sprout.db)
SPROUT_ADMIN_TOKEN_PATHnoRaw admin bearer file (compose default /data/admin-token)
SPROUT_REGISTRY_AUTHS_JSONnoPer-host pull map {"ghcr.io":{"username":"u","password":"p"},…}
SPROUT_REGISTRY_USER / SPROUT_REGISTRY_PASSWORDnoLegacy single-registry fallback when image host is not in the map
SPROUT_TRAEFIK_ENTRYPOINTSnoNon-empty → HTTPS router labels; empty → HTTP-only
SPROUT_TRAEFIK_CERTRESOLVERnoOptional certresolver name when entrypoints set (required with SPROUT_TRAEFIK_WILDCARD_TLS=true)
SPROUT_TRAEFIK_WILDCARD_TLSnotrue → preview routers declare tls.domains wildcard+apex for their suffix (DNS-01 resolver required); default false (labels unchanged)
SPROUT_TRAEFIK_MIDDLEWARESnoSingle forwardAuth middleware name (no commas)
SPROUT_FORWARDAUTH_ADDRESSnoTraefik-reachable forwardAuth URL (required with middleware name)
SPROUT_PREVIEW_AUTH_SECRETnoHMAC root for preview.auth: link tokens (required with address)
SPROUT_PREVIEW_AUTH_ADDRESSnoGateway in-network forwardAuth URL, e.g. http://gateway:7331/v1/internal/preview-auth (required with secret)
SPROUT_DASHBOARD_ENABLEDnotrue serves read-only GET /dashboard; default false (route absent, requests 404)
SPROUT_DASHBOARD_HOSTnoOptional hostname that alone may serve the page (own proxy host + certificate); other hosts 404
SPROUT_DASHBOARD_AUTHnoProtection model; empty or basic (anything else fails boot)
SPROUT_DASHBOARD_USER / SPROUT_DASHBOARD_PASSWORDdashboardHTTP basic credentials; enabling without both fails boot
SPROUT_GITHUB_TOKEN / SPROUT_GITLAB_TOKENnoSweep forge PATs (may be blank at boot)
SPROUT_FORGE_HOSTSnoOptional host=gitlab pairs for self-managed GitLab
SPROUT_TELEMETRY_ENDPOINTnoAnonymous install-telemetry destination (URL). Empty = nothing is sent; must be set together with SPROUT_TELEMETRY_AUTH (see Install telemetry)
SPROUT_TELEMETRY_AUTHnoCredential for the install-telemetry destination. Empty = nothing is sent
SPROUT_OTLP_ENDPOINTnoOperator-owned OTLP/HTTP traces URL, used verbatim. Empty = no trace export; non-http(s) fails boot (see Your own trace backend)
SPROUT_OTLP_HEADERSnoComma-separated name=value export headers (first = splits, base64 padding kept). Bad entries fail boot

Forge kind is chosen per repo from the canonical URL (github.com / gitlab.com) or SPROUT_FORGE_HOSTS — not a gateway-wide forge switch.

Registry: the deploy request carries a fully-qualified app_image; there is no separate registry-host env. Host docker login does not help gateway-initiated Engine API pulls — set registry auth when images are private. Malformed SPROUT_REGISTRY_AUTHS_JSON fails at boot.

Optional gateway tuning (host .env / non-compose)

Compose pins SPROUT_PORT=7331 inside the container. These tuning knobs are in .env.example for host runs (and may be added to compose environment: for non-defaults):

VariableDefault
SPROUT_PORT7331
SPROUT_TTL_HOURS72 (legacy creation-age sweep; new governance below is off by default)
SPROUT_SWEEP_CRON*/30 * * * *
SPROUT_TELEMETRYon
SPROUT_PREVIEW_PORT_DEFAULT8080
SPROUT_SEED_TIMEOUT180
SPROUT_PREVIEW_TTLoff (7d in the self-serve compose stack)
SPROUT_PREVIEW_IDLE_TEARDOWNoff
SPROUT_MAX_PREVIEWS_PER_REPOoff
SPROUT_MAX_PREVIEWSoff
SPROUT_PREVIEW_MAX_DB_CONNECTIONSoff
SPROUT_POSTGRES_MAX_CONNECTIONSoff (100 in the compose example)

SPROUT_SWEEP_CRON is a cron expression (local gateway time); an invalid value fails gateway boot. The first sweep pass lands on the next boundary — never at boot.

Unbounded by default: with TTL/idle/caps all off the gateway keeps every running preview until PR close and logs a loud boot warning. Rows that never complete a deploy are still collected by the legacy creation-age bound (SPROUT_TTL_HOURS). The self-serve compose stack sets SPROUT_PREVIEW_TTL=7d so a new operator is safe without configuring anything; an existing deployment upgrades with no new required env and identical behaviour apart from that warning.

Preview caps and connection budget

Enforce caps instead of discovering overload afterwards. Exceeding SPROUT_MAX_PREVIEWS or SPROUT_MAX_PREVIEWS_PER_REPO fails the deploy with preview_limit_reached naming the cap, its value, and the current count — no container and no database is created. The sweep logs over-cap state but never deletes to fix it; close a PR or raise the cap.

Connection arithmetic the gateway enforces:

projected = (active previews + 1) × SPROUT_PREVIEW_MAX_DB_CONNECTIONS against SPROUT_POSTGRES_MAX_CONNECTIONS. A deploy beyond the projection fails with preview_connection_budget_exceeded. The two vars are a pair — set both or neither; setting exactly one fails gateway boot instead of silently enforcing nothing.

Measured case: each preview app holds two long-lived pools at default size, so ~12 idle connections per preview; ~8 previews fill a 100-slot Postgres instance with zero traffic (remaining connection slots are reserved for SUPERUSER, SQLSTATE 53300). Set SPROUT_PREVIEW_MAX_DB_CONNECTIONS=12 and SPROUT_POSTGRES_MAX_CONNECTIONS=100 to enforce that shape. The figure is only accurate for apps that honour the injected cap — a repo that raises its pool sizes bypasses the budget, and the gateway must not assume otherwise. Raising max_connections is headroom, not a bound.

For HTTPS behind an external Traefik, set entrypoints (and optionally certresolver) to that proxy's names. Example Coolify-shaped values (operator choice, not sprout defaults): SPROUT_TRAEFIK_ENTRYPOINTS=https and SPROUT_TRAEFIK_CERTRESOLVER=letsencrypt (HTTP-01, or the same name after a DNS-01 convert — see Wildcard preview certificate (DNS-01); use letsencrypt-dns only on the coexistence path).

For SSO via Traefik forwardAuth (e.g. VoidAuth), set both SPROUT_TRAEFIK_MIDDLEWARES=voidauth and SPROUT_FORWARDAUTH_ADDRESS=https://auth.example.com/api/authz/forward-auth.

Install telemetry

Leave the defaults unless the fleet opts out. The published image reports anonymous installation facts and deploy outcomes to a maintainer-run endpoint — on by default, one variable to stop (SPROUT_TELEMETRY=off, or DO_NOT_TRACK=1). Schema, payloads, and off switches: Telemetry.

bash
docker build -t sprout:local \
  --build-arg SPROUT_TELEMETRY_ENDPOINT="${SPROUT_TELEMETRY_ENDPOINT}" \
  --build-arg SPROUT_TELEMETRY_AUTH="${SPROUT_TELEMETRY_AUTH}" \
  .

The Coolify paste-file needs nothing: it pins the published image, so it inherits the image's destination env.

Two channels, two purposes: SPROUT_TELEMETRY is the anonymous upstream channel to the maintainer. Pointing traces at your own backend is a separate opt-in feature (SPROUT_OTLP_*, #262) — never both in one knob.

Postgres preview role

Fresh Postgres, zero SQL. Point SPROUT_PREVIEW_POSTGRES_URL at a new instance whose admin has CREATEROLE (or is superuser), set SPROUT_PG_USER / SPROUT_PG_PASSWORD, start the gateway — no CREATE ROLE, no init scripts, no compose one-shot. Compose no longer ships an ensure-preview-role service; the gateway owns role ensure on boot (and again before each CREATE DATABASE).

If SPROUT_PG_USER is missing the gateway runs CREATE ROLE … LOGIN PASSWORD …; if present it ALTER ROLE … LOGIN PASSWORD … so password rotation is change SPROUT_PG_PASSWORD + restart. Role names must match the lowercase SAFE_ROLE guard. Without CREATEROLE (or superuser), boot fails with a clear error instead of a later CREATE DATABASE … OWNER failure.

An optional manual helper remains at deploy/postgres/ensure-preview-role.sh for pre-provisioning without starting the gateway.

The gateway preview-db module grants that role ownership when it creates each sprout_<slug>_pr<id> database, and — for dual previews (db.roles, see Adopting a repo) — also creates a per-DB restricted companion LOGIN (<dbName>_app) with CONNECT + schema USAGE. Containers receive owner credentials as PGUSER/PGPASSWORD and, in dual only, companion credentials as PGAPPUSER/PGAPPPASSWORD (remappable via preview.env — see Adopting a repo). Teardown drops the database then the companion role when one was provisioned.

Bootstrap admin token

Persist the admin bearer across boots. On every boot where the raw bearer is known (pinned SPROUT_ADMIN_TOKEN, or first-time auto-generate), the gateway writes it to SPROUT_ADMIN_TOKEN_PATH (mode 0600). Compose and the gateway image publish SPROUT_ADMIN_TOKEN_PATH=/data/admin-token so the embedded CLI does not guess from the SQLite path. Later boots with a hashed-only admin require that file to still be readable (missing/empty → boot fails). When SPROUT_ADMIN_TOKEN is unset or blank on first generate, the raw token is also printed once in gateway logs:

bash
docker compose --env-file compose.env logs gateway | grep -i admin

Create a deploy token for each adopting repo by exec'ing the CLI already in the gateway image (SPROUT_URL defaults to http://127.0.0.1:7331). Against that loopback URL the CLI resolves a bearer as: SPROUT_TOKEN → SPROUT_ADMIN_TOKEN → SPROUT_ADMIN_TOKEN_PATH (default admin-token; /data/admin-token in-container), so bare docker exec works for pinned and auto-generated admin tokens:

bash
# Primary path: CLI embedded in the gateway image (no SPROUT_TOKEN needed)
docker compose --env-file compose.env exec gateway \
  sprout admin token create --scope deploy --repo https://github.com/org/repo --slug org-repo

# Smoke the embedded CLI (no token / no SPROUT_URL needed)
docker compose --env-file compose.env exec gateway sprout health

Host-side CLI against a remote gateway still needs an explicit SPROUT_TOKEN — admin env/file fallback is loopback-only.

Store the deploy token in the adopting repo's CI secrets as SPROUT_TOKEN.

Worktree DB (local provisioner)

Isolate per-worktree Postgres credentials for parallel agents / herdr worktrees on one machine. The CLI provisions an isolated DB + LOGIN role per worktree without talking to the gateway:

bash
sprout worktree-db provision --slug <name> --env-file <path> --admin-url "$ADMIN_DSN"
sprout worktree-db drop --slug <name> --admin-url "$ADMIN_DSN"

Flag contract (slug normalization, object naming, env-file behavior, --rename): CLI reference.

Upgrade / redeploy

Pull latest operator files, then rebuild + recreate:

bash
# Pull latest operator files, then rebuild + recreate
git pull
docker compose --env-file compose.env up -d --build
curl -sf http://127.0.0.1:7331/healthz

Teardown

Remove the stack:

bash
docker compose --env-file compose.env down
# docker compose --env-file compose.env down -v   # also drops SQLite + Postgres volumes

External-overlay gateway only:

bash
docker compose -f docker-compose.yml -f docker-compose.external.yml \
  --env-file compose.env down

Smoke checklist

POSTGRES_PASSWORD and the password embedded in SPROUT_PREVIEW_POSTGRES_URL are two spellings of one secret — keep them identical in compose.env. Drift is a known risk of this dual-write; catch it with the login vs redacted-URL check below.

CheckCommand
Postgres healthydocker compose --env-file compose.env ps postgres
Preview role syncedGateway started cleanly (boot runs ensurePreviewRole); or check psql can \du the SPROUT_PG_USER login
Gateway healthycurl -sf http://127.0.0.1:7331/healthz
Admin password not driftedpsql login with POSTGRES_* succeeds and gateway startup log configSummary redacted previewPostgresUrl shows the same user/host/db as SPROUT_PREVIEW_POSTGRES_URL (password masked as ***). If only one of POSTGRES_PASSWORD / DSN password was changed, admin SQL fails while the other still works.
Networks existdocker network inspect sprout-traefik sprout-postgres
Traefik sees Dockerdocker compose --env-file compose.env logs traefik | tail
No Postgres secrets in gatewaydocker compose --env-file compose.env exec gateway printenv POSTGRES_PASSWORD — empty / unset

Troubleshooting

Operator symptoms below. The full catalogue (adopter + operator) lives in Troubleshooting.

SymptomLikely causeWhat to try
curl …/healthz fails / connection refusedGateway not up, or host port conflict on SPROUT_GATEWAY_HOST_PORTdocker compose --env-file compose.env ps; ss -ltnp | grep 7331 (or your host port); check docker compose … logs gateway
Traefik curl on :8880 failsBundled Traefik not published, or TRAEFIK_HTTP_PORT overriddenConfirm TRAEFIK_HTTP_PORT in compose.env; docker compose … ps traefik
network … not found on external overlaySPROUT_*_NETWORK names do not exist on the hostdocker network ls; docker network create "$SPROUT_TRAEFIK_NETWORK" (and postgres) before up
Gateway boot: missing env / CREATEROLERequired vars blank, or admin DSN lacks role privilegesDiff compose.env against compose.env.example; confirm admin can CREATE ROLE
Preview apps unreachable behind Coolify TraefikWrong Traefik network, TLS entrypoints, or labelsConfirm gateway + apps join Coolify's Traefik network; set SPROUT_TRAEFIK_ENTRYPOINTS / CERTRESOLVER to that proxy's names
Admin SQL works but gateway cannotPOSTGRES_PASSWORD vs DSN password driftRe-sync both spellings in compose.env and recreate gateway

See also