internal/templates/data/*.json, embedded
into the binary at build time (//go:embed data/*.json) — not database
rows, not admin-editable at runtime. Adding one is “add a file, rebuild,”
with no migration or seed script.
Installing a template (POST /api/projects/{id}/templates/{key}/install)
creates one or more real deployment/service/volume/env rows and deploys
them through the exact same Orchestrator.Deploy path (and so the same
admission control and port registry) as a hand-configured deployment —
see architecture.md.
Schema
A template file is one JSON object:deployments describes one deployment to create:
Env vars: literals, generated secrets, prompted values, and cross-deployment references
Each entry is{ "key": ..., "value" | "generate" | "prompt", "generate_key", "secret", "label", "required" } — exactly one of value, generate, or prompt is set:
"value": a literal string, which may contain placeholders resolved at install time and can appear inline within a larger string (e.g. a combined connection URL):{{alias:<slug_suffix>}}— a sibling deployment’s stable network/container alias (stable across redeploys, unlike the per-deploy container name), so one deployment can address another by name over the Docker network.{{generated:<generate_key>}}— a value produced by an earlier deployment’sgenerate(see below).{{slug}}— this deployment’s own final slug.{{base_slug}}— the user’s chosen base slug, i.e. the primary deployment’s slug, regardless of which deployment the placeholder appears on. A deployment with a suffix (e.g.-studio) uses this to reference the primary deployment’s public URL.{{base_domain}}—Config.BaseDomain.
"generate": "password","generate": "hex32", or"generate": "hex64": produces a random value at install time, stored under"generate_key"so a later deployment in the same template can reference it via{{generated:<generate_key>}}:"password"— 24 alnum characters, safe to embed directly in a connection-string URL or shell command without escaping."hex32"— 32 lowercase hex characters (16 random bytes), for things that want exactly 32 characters (e.g. Postgres-meta’sCRYPTO_KEY)."hex64"— 64 lowercase hex characters (32 random bytes), for HS256 JWT secrets / Elixir secret-key-bases that want at least 32 bytes."jwt:<role>:{{generated:<secret_key>}}"— an HS256-signed Supabase API key (roleanonorservice_role), signed with the value stored under<secret_key>by an earlier generate (the shared JWT secret). PostgREST/GoTrue verify the signature and use theroleclaim, so the key must be minted with the exact secret every service shares. It’s stored (secretly) like any other generated value and shown once at install.
"prompt": true: asks the installing user for this value in the install form, instead of deriving it from the template — for things a template can’t sensibly default (API tokens, webhook secrets)."label"is optional UI copy;"required": trueblocks install until a non-empty value is supplied (checked upfront, before any rows are created, and again in the orchestrator). The value never becomes part of the template itself — the caller passes it intoInstallTemplateasenv_overrides, keyed byslug_suffixthen env key (the same shapememory_overrides_mbuses), and it’s substituted in exactly like a literalvaluewould be."secret": true: stored encrypted, same as a manually-added secret env var (see the multi-user doc for who can view/set these) — independent of whether the value came fromvalue,generate, orprompt.
Files: seeding a container before first boot
Some images expect SQL/config present before the entrypoint’s init machinery runs (Postgres’s/docker-entrypoint-initdb.d, the nginx config dir). A template can’t express that as an env var, so deployments can carry files:
"path"— absolute path inside the container, mounted read-only."content"— inline text, resolved for the same placeholders an env var’svaluesupports ({{slug}},{{base_slug}},{{base_domain}},{{alias:...}},{{generated:...}}) at install time.
executor.FileMount and the supabase.json template for a real example (role-password, JWT-setting, and _realtime-schema init scripts mounted into the Postgres container, mirroring the official compose’s init scripts and migrations).
Post-deploy commands: seeding state a startup script can’t
Some images only read env vars for a single instance of a thing — e.g. themuchobien/pocketbase image’s entrypoint auto-creates exactly one superuser from PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD and has no way to seed a second or third from env vars alone. post_deploy_commands covers this: a list of exec-form commands (like command, but run via docker exec against the running container instead of becoming the container’s CMD), executed in order immediately after this deployment’s own Deploy() succeeds.
Each token is resolved for the same placeholders a files entry’s content supports ({{slug}}, {{base_slug}}, {{base_domain}}, {{alias:...}}, {{generated:...}}), plus one more: {{env:<key>}} — this deployment’s own resolved value for that env key (literal, generated, or prompted). That last one is what makes optional prompted admins possible: a template can declare PB_ADMIN_EMAIL_2/PB_ADMIN_PASSWORD_2 as ordinary optional (non-required) prompt env vars, then reference them in a command via {{env:PB_ADMIN_EMAIL_2}}/{{env:PB_ADMIN_PASSWORD_2}} — installing as empty strings when the installer leaves them blank.
Because each command is exec-form (an argv array, not a shell string), docker exec never re-interprets a resolved value — an email or password containing shell metacharacters can’t break out of the command. If the command itself needs a shell (e.g. to guard against an empty optional value), invoke one explicitly and pass the resolved values as trailing argv after -- rather than interpolating them into the script text, so they reach the shell as positional parameters instead of being re-parsed. See pocketbase.json for the real thing: it seeds up to two extra optional superusers this way, on top of the one the image’s own entrypoint creates — useful for standing up a single shared PocketBase instance with a few trusted admins rather than a separate deployment per person.
A command is not retried, and a nonzero exit fails the whole install the same way a failed Deploy() does — write commands defensively (as above) if a blank optional value should be a no-op rather than a failure. Like files, these are not persisted anywhere; they only run once, during InstallTemplate itself — a later plain redeploy of the resulting deployment does not re-run them.
Ordering
Validation (internal/templates/templates.go’s validate, run once at init() — an invalid template file panics the binary on startup, not at install time) enforces:
- Exactly one deployment has
slug_suffix == ""(the primary), and it’s the last entry in the array. - Every
{{generated:key}}placeholder references agenerate_keyset by an earlier deployment in the same array — including references inside ajwt:generate kind, inside afilesentry’s content, and inside apost_deploy_commandstoken. - Every deployment has a positive
memory_limit_mb, and a build source:image_refforbuild_strategy: "image"(the default),git_urlforbuild_strategy: "dockerfile". - Every env var sets at most one of
value,generate,prompt. - Every
filesentry has a non-emptypathandcontent. - Every
post_deploy_commandsentry is non-empty, and every{{env:key}}placeholder inside one references an env key that actually exists on the same deployment.
WORDPRESS_DB_HOST referencing {{alias:-mysql}}) needs its dependency to already exist and have a resolvable network alias. Putting the primary last means “the thing the user actually opens” is the last one created, after all its dependencies.
Example: a linked two-deployment template
wordpress.json — MySQL first (dependency), WordPress second (primary, slug_suffix: ""), referencing MySQL’s generated password and stable alias:
Adding a new template
- Drop a new
internal/templates/data/<key>.jsonfile following the schema above. The filename’s stem should match"key". go build ./...— an invalid template panics on startup (fails fast at build/boot time, not silently at install time), so this alone catches most schema mistakes.- If it’s meant to be user-facing (not a bare backing database), add it to
SIMPLE_TEMPLATESinweb/src/lib/plainCopy.tswith a plain-language label and one-sentence blurb — otherwise it only shows up in the technical dashboard’s gallery. See modes.md for why databases are deliberately left out of simple mode. go test ./...—internal/orchestrator/templates_test.goexercises the install path against a fake executor; add a case there for anything with new install-time behavior (e.g. a newgeneratekind).