withvibe
Working in withvibe

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

The 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. Use label, description, required, defaultValue to drive the UI.
  • secret — pulled from a secrets backend by secretName. 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 where service can be reached. The orchestrator picks the form based on routing mode:
    • Subdomain mode: http://<service>.env-<short>.<baseDomain>
    • Port mode: http://$${PUBLIC_HOST}:$${portKey}
    If you leave serviceblank, the DevOps agent will infer it from the compose file and the variable's description.
Reserved keys
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_CASE and 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 a ports: 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 from ports: or expose:. Last resort before defaulting to 80.
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 ports

Service 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 like frontend, 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

  1. User clicks New environment → fills user-input fields.
  2. Materializer collects values: user inputs, secret lookups, allocated ports, defaults.
  3. If the workspace runs in subdomain mode, the compose rewriter strips host ports, attaches the proxy network, and adds Traefik labels per exposed service.
  4. service-url variables resolve last, using the routing mode to pick the form.
  5. The rendered compose + assets are written to the env worktree and docker compose up runs.

Authoring tips

  • Start from a working docker-compose.yml, then incrementally replace literals with $${KEY} placeholders.
  • Keep secrets in kind: secret, never in default. 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.