sprout

docs / adopting-a-repo

Adopting a repo

On this page

Write the .sprout.yaml config-as-code file at the repo root and the app entrypoint that runs inside the preview. Each section below is one task.

Copy-paste app files live in examples/adopting-repo/README.md.

Manifest keys (.sprout.yaml)

Look up any key here. The CLI reads this file locally and sends parsed values to the gateway. Unknown keys are rejected (unknown key: <path>). The table plus the three subsections directly below it (connection env, value grammar, merge order) are the contract. Seeding order lives in Previews, service merge rules in Previews, mail in Previews.

KeyRequiredDefaultPurpose
slugyes—Short name used in database names (sprout_<slug>_pr<id>) and container names. Alphanumeric.
preview.hostnameyes—Per-PR host template. Must contain {pr_id}; no scheme, port, path, or other placeholders. The CLI owns substitution and prints preview_url= — CI never reconstructs it.
preview.envnocanonical PG* (+ PGAPP* in dual) (postgres) or DATABASE_URL (sqlite); rejected on noneRename injected connection env (see Connection env).
preview.app_envno—Extra app env (see Value grammar, Merge order, Connection env reservation).
preview.servicesnoleave companionsCompanion routing entries (see Previews).
preview.services[].nameper entry—Service name (validated, unique).
preview.services[].imageper entry unless --service—Pinned image for the service. Accepts the env placeholder grammar ({hostname}, {pr_id}, {commit_sha}), resolved by the CLI at deploy time; the gateway receives literal refs only.
preview.services[].hostnamenointernal-onlyDistinct Host() for the service.
preview.services[].pathnointernal-onlyPathPrefix() for the service (must start with /).
preview.services[].portnoimage first EXPOSE, else SPROUT_PREVIEW_PORT_DEFAULTRouted port override (integer 1–65535). Only sets the Traefik server.port label when the service is routed; accepted for internal services with no routing effect.
preview.services[].envno—Literal NAME: value string map injected into the service container only (keys must match [A-Za-z_][A-Za-z0-9_]*).
preview.labelsno—Adopter container labels applied to the app container and every service container (see Preview labels).
preview.services[].labelsno—Adopter container labels for that service container only; same key at both levels resolves to the per-service value (see Preview labels).
preview.volumesno—Per-preview named volumes for files the app writes at runtime (see Previews).
preview.authnononePer-preview access gate: none (open), basic (gateway-generated credential), or link (shareable revocable links). link requires the gateway capability or the deploy fails with preview_auth_not_configured. See Previews.
preview.ttlnogateway SPROUT_PREVIEW_TTL (off)Preview lifetime from the last successful deploy (e.g. 7d, 2h, 30m, or off). Overrides the gateway default; off disables. See Previews.
preview.idle_teardownnogateway SPROUT_PREVIEW_IDLE_TEARDOWN (off)Idle lifetime from the last successful deploy (same grammar as ttl). Overrides the gateway default; off disables. See Previews.
db.providernopostgresPreview database provider: postgres (shared instance), sqlite (named volume), or none (no database). See Previews and Previews.
db.rolesnoderived: dual when preview.env remaps PGAPPUSER / PGAPPPASSWORD, else singlePostgres credential axis: single (owner only) or dual (owner + restricted companion). Rejected on sqlite / none. Explicit single plus a companion remap fails fast.
mailnoopportunistic (follows the gateway)enabled (require mail) or none (opt out). See Previews.
mail.fromno<slug>-pr<pr_id>@<from-domain>Send-from override; must be an address template containing {pr_id} (only that placeholder). Rejected with mail: none. See Previews.
db.pathno/dataContainer directory the SQLite volume mounts at (sqlite only).
db.filenopreview.dbSQLite file name inside db.path (sqlite only).
health.pathwhen seeding/healthHTTP path the gateway polls on the Postgres-network container IP.
health.intervalwhen seeding2sPoll interval (duration like 2s, 30m, 2h, 7d; malformed durations fail at manifest parse).
health.timeoutwhen seeding120sHow long the gateway polls before health_timeout. Never starts the seed.
health.expectwhen seeding200Expected status (100–599). Gates the after-healthy seed hook.
build.dockerfilenoDockerfileApp Dockerfile for sprout ci preview. An empty build: {} takes the default.
seed.dockerfilewhen seed: presentDockerfile.seedSeed Dockerfile. An empty seed: {} takes the default and enables seeding.
seed.inputsno— (always rebuild)Repo-relative paths whose contents key seed-image reuse (seed Dockerfile, entrypoint/script, migrations, lockfile, …). Sorted with paths folded into a sha256; the seed tag is <registry-path>:seed-<12-hex>. Without inputs the tag is commit-scoped (<SHA>-seed) and the seed image is rebuilt on every run — set inputs explicitly to opt into reuse, listing every COPY source the seed image depends on.
seed.envno—Seed-only env (same grammar and layering as preview.app_env).
seed.argsno—Seed container args (yaml first, then --seed-arg flags appended).

Connection env: names, roles, reservation, port

Read this before naming a connection variable. The gateway injects these connection variables into preview app, service, and seed containers. Which set arrives follows db.provider:

Postgres (db.provider: postgres, the default):

text
PGHOST  PGPORT  PGUSER  PGPASSWORD  PGDATABASE
PGAPPUSER  PGAPPPASSWORD   (dual only — see db.roles below)

db.roles selects the Postgres credential axis (single | dual, Postgres only; rejected on sqlite / none). The default is derived: dual when preview.env remaps PGAPPUSER or PGAPPPASSWORD, otherwise single — a repo that never mentions the companion gets none, and existing remap users keep working with no config change. An explicit db.roles wins over the derivation, and explicit db.roles: single plus a companion remap fails fast at manifest parse and at the deploy route (preview.env.<KEY> conflicts with db.roles single).

SQLite (db.provider: sqlite):

text
DATABASE_URL=file:<db.path>/<db.file>   (default file:/data/preview.db)

No-database (db.provider: none): no connection variables are injected at all — every preview.env entry is rejected at manifest parse (preview.env.PGHOST requires db.provider postgres) and at the gateway deploy route, and a seed: block is rejected the same way (seed requires db.provider postgres or sqlite (db.provider is none)). See Previews.

No PG* keys are injected for a SQLite preview, and no DATABASE_URL for a Postgres one — preview.env entries for the other backend fail at manifest parse (preview.env.PGHOST requires db.provider postgres). See Previews.

preview.env renames the gateway-injected connection names. Unmapped keys stay canonical; a remap replaces the name (no dual alias). The entrypoint must read the adopter names.

Gateway connection keys replace colliding adopter keys (canonical PG* ∪ remapped names after preview.env) — same policy for app and seed env. Do not put PGHOST or a remapped name into SPROUT_APP_ENV: the gateway strips it in favour of its own value and the app silently gets the gateway's connection, not yours.

Port: the gateway routes to the service port override when set, else the first EXPOSEd port in the app image, else SPROUT_PREVIEW_PORT_DEFAULT. Teardown drops the database and then the companion role when one was provisioned (dual; a dual-era role is still dropped after the repo flips to single).

Mail is cross-provider: on a gateway with mail configured, every preview (app, companions, seed) also receives the canonical MAIL* set, on any db.provider including none. The canonical names, the remap worked example, and the opt-out live in exactly one place — Previews.

Env value grammar

Write preview.app_env / seed.env values in one of three forms: a plain string, { generate: stable_per_pr }, or { required: true }. Strings may interpolate {hostname}, {pr_id}, {commit_sha} ({commit_sha} follows the forge SHA, CI_COMMIT_SHA on GitLab / GITHUB_SHA on GitHub). generate derives a per-MR secret (HMAC of repo, MR, key, keyed by the deploy token — keep the token stable for the MR lifetime). required must be supplied by CI; missing keys fail before the gateway call naming the key.

Env merge order

Layer env so later wins per key. App: yaml first, then SPROUT_APP_ENV / each --app-env-file in order, then --app-env flags. Seed: yaml first, then SPROUT_SEED_ENV / each --seed-env-file in order, then --seed-env flags. Seed args: yaml seed.args first, then --seed-arg flags appended.

Write a minimal app-only manifest (defaults apply):

yaml
slug: myapp
preview:
  hostname: "pr-{pr_id}.myapp.preview.example.com"

Write a seeded manifest: add health: + seed: beside the app-only manifest above. health: is required when seeding; seed: {} alone enables seeding with the conventional Dockerfile.seed.

yaml
slug: myapp
preview:
  hostname: "pr-{pr_id}.myapp.preview.example.com"
health:
  path: /health
  interval: 2s
  timeout: 120s
  expect: 200
seed:
  dockerfile: Dockerfile.seed

App image: migrate at startup

Make the app entrypoint do these three things in order. The connection names, owner/companion roles, and the port rule live in Connection env — snippets below assume the default PG* map (dual adds PGAPP*; single omits them):

  1. Wait until Postgres accepts connections.
  2. Run migrations as the owner against the injected database name (default PGDATABASE); GRANT to the companion role for RLS instead of creating roles.
  3. Start the web server, connecting runtime queries as PGAPPUSER when RLS scoping is needed.

There is no mandatory wrapper image from sprout. Copy an entrypoint that fits the stack. Migrations must be idempotent — synchronize re-deploys keep the same database and re-run migrate on every container start.

Dual-role (RLS) previews

Scope product databases that use a privileged owner + restricted RLS role. Set db.roles: dual so previews work without cluster CREATEROLE on the preview login (without it there is no companion role and no PGAPP* names to read):

  1. Migrate with PGUSER / PGPASSWORD (owner).
  2. GRANT the needed table/sequence privileges to the role in PGAPPUSER (and enable RLS / policies as in production).
  3. Open the app pool with PGAPPUSER / PGAPPPASSWORD (remap via preview.env when the product env names differ).

Extra app env (non-connection)

Pass runtime env beyond the connection fields (BETTER_AUTH_SECRET, app URLs, trusted origins) as preview.app_env / seed.env in .sprout.yaml, a masked file-type SPROUT_APP_ENV / SPROUT_SEED_ENV dotenv blob, repeatable --app-env-file / --seed-env-file, or repeatable --app-env KEY=VALUE / --seed-env KEY=VALUE. Grammar, merge order, and the gateway reservation rule live above. Forge File-var wiring lives in templates/README.md; placeholders expand in every layer. File-type CI variables do NOT survive component inputs: expansion — map file-type blobs at job runtime via variables: (SPROUT_APP_ENV: $MY_ENV_FILE), never as app_env_file: inputs.

Example:

yaml
slug: myapp
preview:
  hostname: "pr-{pr_id}.myapp.preview.example.com"
  app_env:
    LOG_LEVEL: info
    BETTER_AUTH_URL: "https://{hostname}"
    BETTER_AUTH_SECRET:
      generate: stable_per_pr
    STRIPE_API_KEY:
      required: true

Shell entrypoint (any runtime)

Copy examples/adopting-repo/docker-entrypoint.sh (default PG* names):

bash
#!/bin/sh
set -eu
until pg_isready -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE"; do
  sleep 1
done
./migrate.sh   # your toolchain: drizzle-kit, prisma, flyway, etc.
exec "$@"

Bun / Node one-liner variant

bash
until bun -e "await Bun.sql\`select 1\`"; do sleep 1; done
bun run db:migrate
exec bun run start

Deploy token setup

Mint one deploy token per adopting repo (operator or lead dev with the admin token), then store the returned token as the repo's SPROUT_TOKEN secret:

bash
export SPROUT_URL=https://sprout.example.com
export SPROUT_TOKEN=<admin-token>
sprout admin token create \
  --scope deploy \
  --repo "https://github.com/${GITHUB_REPOSITORY}"

Reviewers may see brief 502 responses while the app migrates and starts — Traefik routes exist before the app is healthy.

See also