Templates & variables
A template is a docker-compose.yml plus a manifest of variables and service metadata. When a teammate creates an env from the template, the orchestrator resolves every ${VAR} placeholder, picks ports or subdomains based on the routing mode, and rewrites the compose so the services come up correctly the first time.
Anatomy of a template
my-template/
├── compose.yml # docker-compose with ${VAR} placeholders
├── template.json # variables + services metadata
└── assets/ # optional template files (rendered with the same vars)
└── .env.exampleThe variable manifest
template.json declares variables and services. Every $${KEY} in the compose file (or in any asset under assets/) must resolve to one of these variables, or the materializer rejects the template.
{
"slug": "next-postgres",
"variables": [
{
"key": "WEB_PORT",
"kind": "system-port",
"description": "Host port for the Next.js app"
},
{
"key": "PG_PASSWORD",
"kind": "secret",
"secretName": "WORKSPACE_PG_PASSWORD"
},
{
"key": "APP_NAME",
"kind": "user-input",
"label": "App name",
"required": true,
"defaultValue": "demo"
},
{
"key": "NODE_ENV",
"kind": "default",
"defaultValue": "development"
},
{
"key": "WEB_URL",
"kind": "service-url",
"service": "web",
"portKey": "WEB_PORT",
"description": "Full URL where the web service is reachable"
}
],
"services": [
{
"name": "web",
"role": "frontend",
"userFacing": true,
"description": "Next.js app",
"agentInstructions": "If the user asks to add a route, edit pages/ in the web repo."
},
{
"name": "db",
"role": "database",
"userFacing": false
}
]
}Variable kinds
Five kinds, resolved at materialize time:
system-port— allocated by the orchestrator. In port-mode deployments, you get a free host port. In subdomain mode, this kind is skipped (no host ports are published).user-input— collected from the create-env form. Uselabel,description,required,defaultValueto drive the UI.secret— pulled from a secrets backend bysecretName. The placeholder reads from a workspace-scoped secrets store, with a fallback to the API process environment.default— static value baked into the template. Use this for everything that doesn't need to vary.service-url— the full URL whereservicecan be reached. The orchestrator picks the form based on routing mode:- Subdomain mode:
http://<service>.env-<short>.<baseDomain> - Port mode:
http://$${PUBLIC_HOST}:$${portKey}
serviceblank, the DevOps agent will infer it from the compose file and the variable's description.- Subdomain mode:
PUBLIC_HOST is injected by the orchestrator and cannot be redeclared. The materializer rejects templates that try to.Variable key rules
- Must be
UPPER_SNAKE_CASEand start with a letter:/^[A-Z][A-Z0-9_]*$/. - Must be unique within a template.
- Every key referenced in compose or assets must be declared.
- Every declared key must be referenced somewhere (the materializer warns on dead vars).
Compose-file extensions
Templates use a few x-* extensions. The compose rewriter reads them when it prepares the per-env compose file:
x-expose: true|false— explicitly include or exclude a service from public routing. By default, any service with aports:stanza is exposed.x-subdomain: <name>— override the auto-generated subdomain. Defaults to the service name.x-port: <n>— fallback target port if the rewriter can't infer it fromports:orexpose:. Last resort before defaulting to80.
services:
web:
image: my/web:latest
ports:
- "${WEB_PORT}:3000" # used in port mode; stripped in subdomain mode
environment:
DATABASE_URL: postgres://app:${PG_PASSWORD}@db:5432/app
PUBLIC_URL: ${WEB_URL} # service-url variable
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${PG_PASSWORD}
x-expose: false # never publish, even if it had portsService metadata
Every key in servicesmatches a docker-compose service. Fields are optional — they enrich the UI and the DevOps agent's context:
description— free-text purpose, shown in the UI.role— short tag likefrontend,db,queue.userFacing— true if humans open this service in a browser.agentInstructions— per-service guidance the AI sees when teammates ask about that service. Use it to point the agent at the right repo, package, or directory.
Materialization, step by step
- User clicks New environment → fills user-input fields.
- Materializer collects values: user inputs, secret lookups, allocated ports, defaults.
- If the workspace runs in subdomain mode, the compose rewriter strips host ports, attaches the
proxynetwork, and adds Traefik labels per exposed service. service-urlvariables resolve last, using the routing mode to pick the form.- The rendered compose + assets are written to the env worktree and
docker compose upruns.
Authoring tips
- Start from a working
docker-compose.yml, then incrementally replace literals with$${KEY}placeholders. - Keep secrets in
kind: secret, never indefault. The orchestrator will refuse to log resolved secrets. - Use
service-urlfor any URL that one service uses to reach another. It's the only kind that gives you a working URL in both port and subdomain modes. - Put
agentInstructionson every user-facing service. It's the cheapest way to make the AI useful out of the gate.