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 gzipIngress
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 fordomain, and does blue/green deploys.external: you front the container yourself (Cloudflare Tunnel, nginx, an ALB, ...). Teploy still publishes the port to127.0.0.1and joins theteployDocker network, but never touches Caddy. Nodomainrequired.host: publishes the container directly on a fixed host port (no Caddy, no proxy) — for private/tailnet boxes reached atIP:port. Deploys by recreate, not blue/green, since a fixed host port can't double-bind. Not supported fortype: 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: 10MBRequires 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.keyHealth checks
health:
path: /api/health # teploy-level deploy health check (gates traffic switch). Default: /healthThis 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/warmupNotifications
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-observeDeploy 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 credsOnly 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-boxteploy deploy -d stagingComing 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:.