teploy.yml Reference

8 min read

This is the complete field reference for teploy.yml. The format will feel familiar if you've used a config-file deploy tool, but it is its own schema — a Kamal deploy.yml will not parse (see Coming from Kamal below).

teploy.toml is also supported (auto-detected by extension) with the same fields in TOML syntax.

Identity

app: myapp              # required — lowercase alphanumeric + hyphens, max 63 chars
domain: myapp.com        # required unless ingress: host — comma-separated for multiple hosts
type: container          # "container" (default) or "static"

Server

server: production       # name from ~/.teploy/servers.yml — or pass `teploy deploy <server>`
user: deploy              # SSH user override
servers: [web1, web2]     # multi-server fleet deploy instead of a single `server`

Staged rollouts

rollout:
  canary: "20%"        # or an absolute count, e.g. "1" — min 1, max fleet-1
  max_failures: 1       # tolerance for the main wave after the canary passes (default 0)

Only meaningful with a multi-server servers: fleet. The canary wave deploys first, serially; any canary failure halts the rollout, rolls the canary back, and leaves the rest of the fleet untouched. Once the canary passes, max_failures sets how many of the remaining servers can fail before the whole rollout aborts and converges back — 0 (the default) keeps the original all-or-nothing behavior. A rollout that finishes over budget never leaves a mixed-version fleet silently: the command exits non-zero with a named straggler list and the exact command to converge them.

Container deploys (type: container, the default)

image: myuser/myapp     # image name (built locally or on the server)
port: 3000              # container port the app listens on
platform: linux/amd64   # target platform for cross-arch builds
build_local: true       # build the image locally and push, vs. build on the server (default: server)
stop_timeout: 30        # seconds to wait for graceful shutdown before SIGKILL
parallel: 2             # concurrent deploys across multiple `servers`
replicas: 2              # container replicas behind Caddy
keep_versions: 3          # prune old containers/images after N successful deploys (0 = keep all)

processes:                 # run more than one process from the same image
  web: npm start
  worker: node worker.js

healthcheck:                # per-process HEALTHCHECK overrides, keyed to `processes`
  worker:
    disable: true             # skip the image's HEALTHCHECK for a process with no HTTP listener

env:                            # plain env vars (secrets: use `teploy secret set` instead)
  NODE_ENV: production

env_files:                       # SOPS+age encrypted dotenv/YAML files, merged into env at deploy
  - .env.production.age            # GitOps-friendly: keep encrypted config in Git, not a dashboard

scan: true                       # server-side Trivy scan before the container starts; blocks the
                                  # deploy on a fixable CRITICAL finding (set scan_ignore_unfixed:
                                  # true to stop unpatchable base-image CVEs from wedging releases)

volumes:
  myapp_data: /var/lib/myapp/data

accessories:                         # stateful sidecar containers (db, cache, ...)
  postgres:
    image: postgres:17-alpine
    port: 5432
    env:
      POSTGRES_PASSWORD: <SET_VIA_teploy_secret>
    volumes:
      myapp_pgdata: /var/lib/postgresql/data
  minio:
    image: minio/minio
    command: server /data --console-address :9001    # entrypoint args the image needs to run at all
    publish:                                          # host-reachable ports (before the image)
      - "127.0.0.1:9100:9000"
    env:
      MINIO_ROOT_PASSWORD: <SET_VIA_teploy_secret>

env_files: decrypts *.age via the age identity (TEPLOY_AGE_IDENTITY, SOPS_AGE_KEY_FILE, or ~/.config/teploy/age.txt), *.sops.*/*.enc.* via sops -d, and parses anything else as plain dotenv. Explicit env: keys win over file values.

command:/publish: on an accessory are the primitives that make a self-hosted MinIO or ntfy accessory work — command: supplies the entrypoint args the image needs (MinIO and ntfy both require one), publish: exposes a port to the host so e.g. the backup commands' aws CLI can reach a loopback-bound MinIO bucket.

Static deploys (type: static)

Native rsync-based static hosting — no Docker, no Dockerfile. Teploy rsyncs a pre-built local directory to the server, symlinks it live, and writes the Caddy route. This is the default recommended path for static sites; see Static Sites for the full guide.

app: mysite
type: static
domain: mysite.com
server: production

source: dist                # required — local directory of already-built files to ship
build:                        # optional — shell commands run before rsync (locally by default)
  - npm run build
build_remote: false            # run `build` commands on the server instead of locally
spa: true                       # enable SPA fallback (try_files to index.html)
spa_fallback: /index.html        # override the SPA fallback path
keep_releases: 5                  # release retention count (static-deploy equivalent of keep_versions)
cache:                              # path glob -> Cache-Control header
  "/assets/*": "public, max-age=31536000, immutable"
headers:                             # arbitrary extra response headers
  X-Frame-Options: DENY
caddy_extra: |                        # raw Caddy directives appended into the site block
  encode gzip

Ingress

ingress: caddy       # "caddy" (default, Teploy manages the Caddyfile), "external", or "host"
bind: 0.0.0.0          # host IP for `ingress: host` (default 0.0.0.0)
  • caddy (default): Teploy owns the Caddyfile, requests an ACME cert for domain, and does blue/green deploys.
  • external: you front the container yourself (Cloudflare Tunnel, nginx, an ALB, ...). Teploy still publishes the port to 127.0.0.1 and joins the teploy Docker network, but never touches Caddy. No domain required.
  • host: publishes the container directly on a fixed host port (no Caddy, no proxy) — for private/tailnet boxes reached at IP:port. Deploys by recreate, not blue/green, since a fixed host port can't double-bind. Not supported for type: static.

Firewall (Caddy edge)

firewall:
  allow_ips: ["203.0.113.0/24"]    # if set, only these IPs/CIDRs may connect
  deny_ips: ["198.51.100.5"]
  block_user_agents: ["BadBot"]      # case-insensitive substring match
  max_body_size: 10MB

Requires Caddy ingress (ingress: caddy, the default) and type: container — rejected at config validation otherwise. Rules are matched before the reverse proxy, so a blocked request never reaches your app. allow_ips/deny_ips match Caddy's remote_ip — the direct connection. If you're behind another proxy (Cloudflare, an ALB), that's the proxy's IP, not the original client's; scope rules to the fronting proxy's IPs or use trusted_proxies instead. This isn't rate limiting (needs a custom Caddy build) or a full WAF — it's the lightweight always-on slice.

Access (inbound auth)

access:
  basic_auth:
    alice: <BCRYPT_HASH>          # Caddy requires bcrypt, not a plaintext password
  # or:
  forward_auth:
    uri: https://auth.internal/verify
    copy_headers: [Remote-User, Remote-Groups]

Puts a reverse-proxied app behind a login before any request reaches it. basic_auth is a quick self-hosted gate; forward_auth delegates to an external identity proxy (Authelia, oauth2-proxy, an OIDC gateway). Same requirements as firewall: — Caddy ingress, type: container.

TLS (custom certificate)

Use this instead of Caddy's automatic ACME when the public hostname is proxied (Cloudflare orange-cloud, Cloudflare Tunnel) and can't complete an HTTP-01 challenge.

tls:
  cert: ./.teploy-certs/origin.crt
  key: ./.teploy-certs/origin.key

Health checks

health:
  path: /api/health     # teploy-level deploy health check (gates traffic switch). Default: /health

This is distinct from the per-process container HEALTHCHECK directive (see healthcheck: above).

Hooks

hooks:
  pre_deploy: npm run migrate     # runs inside the new container BEFORE the health check + traffic switch
  post_deploy: curl -f https://myapp.com/warmup

Notifications

notifications:
  webhook: https://hooks.example.com/deploy
  channels:
    - type: slack
      url: https://hooks.slack.com/services/...
      events: [deploy_success, deploy_failure]
    - type: ntfy
      url: https://ntfy.sh/my-topic     # or your self-hosted ntfy accessory's URL
      events: [deploy_failure]

type: ntfy posts native ntfy messages (plain-text body, Title/Priority/Tags headers) instead of the generic webhook JSON blob — works against ntfy.sh or a self-hosted ntfy accessory (see the command:/publish: example above).

Audit

audit:
  endpoint: https://observe.internal
  token: <EDITOR_OR_ADMIN_TOKEN>      # a teploy-observe API token, editor role or above
  site: myapp                          # site_id in teploy-observe

Deploy and rollback events forward to a teploy-observe instance's audit trail as deploy.run/deploy.rollback events — fire-and-forget, a failed emit only warns and never fails the deploy. Pairs with OpenBao's own audit streaming (see Secrets) for a combined record of deploys and secret access.

Network (cross-server VPN mesh)

network:
  provider: tailscale        # tailscale, headscale, or netbird
  auth_key: <SET_VIA_ENV>

Secrets (OpenBao provider)

secret:
  provider: openbao        # local (default) or openbao
  accessory: secrets        # accessory name running OpenBao (default: openbao setup's own name)
  agent: true                 # run an OpenBao Agent sidecar that auto-renews dynamic DB creds

Only needed once you move past the local age-store default. See Secrets for setup, seals, dynamic database credentials, and audit streaming.

Multiple environments

Create teploy.<destination>.yml to overlay onto the base teploy.yml (non-zero fields in the overlay replace the base):

# teploy.staging.yml
domain: staging.myapp.com
server: staging-box
teploy deploy -d staging

Coming from Kamal

teploy.yml is its own schema — copy-pasting a Kamal deploy.yml will fail to parse (or worse, silently drop fields it doesn't recognize). The most common mismatches:

Kamal deploy.yml teploy.yml
service: myapp app: myapp
proxy: { host: myapp.com } domain: myapp.com
builder: { arch: amd64 } platform: linux/amd64, build_local
registry: { ... } not used — Teploy ships images over SSH directly, no registry required
servers: { web: [...] } (nested by role) server: <name> (single) or servers: [...] (flat list)
env: { clear: {...}, secret: [...] } env: (plain vars) + teploy secret set (secrets) — not nested
boot: { limit, wait } not supported
accessories.<name>.host not used — accessories run on the same server as the app

There's no build: { command, output } shape in Teploy — for type: static, build: is a flat list of shell commands and the output directory is source:.