Deployment
deploy.yml Reference
This is the complete deploy.yml schema. wheels deploy loads config/deploy.yml on every invocation, validates it against this schema, and hands it to every subcommand. Unknown top-level keys and missing required keys fail fast with an error prefixed by the config file’s absolute path (messages are not line-scoped).
Required keys
Section titled “Required keys”Three keys are required. Omitting any of them stops the loader with <path to deploy.yml>: missing required key: '<name>'.
| Key | Type | Purpose |
|---|---|---|
service | string | Short, stable name. Drives container naming (<service>-<role>-<version>) and the Docker label that scopes every wheels deploy query. |
image | string | Registry path excluding the tag (e.g. myorg/myapp). wheels deploy appends a version. |
servers | array or map | One or more hosts. See servers below. |
All allowed top-level keys
Section titled “All allowed top-level keys”The loader rejects any top-level key not on this list. Case-insensitive. The allowlist contains only keys the runtime actually reads — Kamal keys that wheels deploy has not implemented yet are rejected rather than accepted-and-ignored (see Rejected Kamal keys below).
service, image, servers, registry, builder, env, ssh, proxy, accessoriesservice
Section titled “service”The name threaded through every container, label, and lock file on the server.
service: myappContainers are named <service>-<role>-<version> (myapp-web-abc1234). Accessories are named <service>-<accessory> (myapp-db). The lock file is /tmp/kamal_deploy_lock_<service>. Keep it short and stable — renaming later means manually cleaning up every host.
The registry path for the app image, excluding the tag.
image: myorg/myapp # Docker Hubimage: ghcr.io/myorg/myapp # GHCR — include the registry hostwheels deploy tags every build with a version (short git sha by default) and produces a pull target like myorg/myapp:abc1234. Pair with registry.server when the registry isn’t Docker Hub.
servers
Section titled “servers”The hosts Wheels deploys to. Three shapes, increasing in expressiveness.
Bare list (implicit web role)
Section titled “Bare list (implicit web role)”servers: - 192.0.2.10 - 192.0.2.11Every host joins an implicit role named web. Simplest shape for single-role apps.
Named roles
Section titled “Named roles”servers: web: - 192.0.2.10 job: - 192.0.2.11Any number of roles. The web role is the one meant to receive public traffic through the proxy; other roles (job, worker, sidekiq) are started and stopped like web. Known defect: the current CLI issues the kamal-proxy deploy cutover call for every role, not just web, so non-web roles also get registered with the proxy (#2957).
Roles with options
Section titled “Roles with options”servers: web: hosts: - 192.0.2.10 env: clear: WHEELS_MAX_THREADS: 8 options: memory: 2gb cpus: 2 labels: my-label: value cmd: /app/bin/webhosts:— required when using the map shape.cmd:— overrides the container entrypoint for this role. Useful when one image runs aswebunder one command and asjobunder another.env:— parsed but currently ignored: only the top-levelenv:is applied to containers; per-role overrides never reachdocker run(#3088).options:— parsed but currently ignored: no extradocker runflags (memory,cpus, etc.) are emitted (#3088).labels:— parsed but currently ignored: containers only get the four labels Wheels adds (service,role,destination,version) (#3088).
Host strings accept user@host, host:port, and user@host:port. IPv6 literals must be bracketed ([::1]:22). Multiple colons without brackets are rejected — including adjacent colons, so an unbracketed '::1:22' fails validation.
registry
Section titled “registry”Where images are pushed and pulled.
registry: server: ghcr.io # optional — defaults to Docker Hub username: myorg password: - KAMAL_REGISTRY_PASSWORDserver:— hostname. Omit for Docker Hub.username:— identity used fordocker login. Literal string.password:— array of secret keys.wheels deployresolves the first key against.kamal/secretsand passes it todocker login --password-stdin.
For ECR, set username: AWS and point password: at an env var that you populate via a $(aws ecr get-login-password) in .kamal/secrets — ECR tokens rotate every 12 hours.
builder
Section titled “builder”How and where Docker images are built.
builder: context: . dockerfile: Dockerfile arch: - amd64 - arm64 args: WHEELS_VERSION: "4.0.0" remote: ssh://deploy@builder.example.comcontext:— build context. Default..dockerfile:— path relative to context. DefaultDockerfile.arch:— parsed but currently ignored: no--platformflag is emitted, so builds target whatever the host running the build is (#3088).wheels deploy build createdoes set up adocker buildxbuilder.args:— parsed but currently ignored: no--build-argflags are emitted (#3088).remote:— parsed but currently ignored: the build always runs locally; no remote builder endpoint is used (#3088).
Environment variables for app containers. clear values are baked into deploy.yml (safe under git) and passed to docker run -e at deploy time.
env: clear: WHEELS_ENV: production WHEELS_LOG_TO_STDOUT: "1"Changes to env take effect on next deploy only; running containers keep their original values.
How wheels deploy connects to hosts.
ssh: user: deploy port: 22 proxy: bastion.example.com keys: - ~/.ssh/id_ed25519 keys_only: falseuser:— SSH user. Defaults toroot. If not root,wheels deploywraps root-requiring commands withsudo -n.port:— TCP port. Default 22.proxy:— parsed but currently ignored:ProxyJump-style bastion hops are not implemented; every connection goes direct (#3088).keys:— array of private key file paths. The first entry is passed to the SSH pool. Tilde (~/) is expanded against the JVMuser.homebecause sshj reads key files viajava.io.Fileand does not expand the shell shortcut. Omit to fall back tossh-agent.keys_only:— parsed but currently ignored (#3088).
Host key verification reads your ~/.ssh/known_hosts. Your ~/.ssh/config is not read — the bundled sshj client doesn’t parse OpenSSH client config, so per-host users, identities, and ProxyJump directives there have no effect. Declare connection settings in this ssh: block instead.
Configuration for kamal-proxy, the singleton reverse proxy on every host.
proxy: host: app.example.com app_port: 8080 ssl: true healthcheck: path: /up timeout: 30 forward_headers: true buffering: requests: true responses: truehost:— the public hostname. Used for TLS SNI and Let’s Encrypt issuance.app_port:— port the app listens on inside the container;wheels deployandwheels deploy rollbackroute traffic to it via the kamal-proxy target (--target <container>:<app_port>). The config default is80(matching Kamal), andwheels deploy initscaffolds8080to match its generated Dockerfile.ssl:— if true,kamal-proxyrequests a Let’s Encrypt cert forhoston first boot. Set false if you terminate TLS in front of the proxy.healthcheck:— new container must return 2xx athealthcheck.pathwithinhealthcheck.timeoutseconds (defaults:/up, 30) or the proxy refuses to cut traffic over.forward_headers:— parsed but currently ignored; not forwarded to the proxy (#3088).buffering:— parsed but currently ignored (#3088).
accessories
Section titled “accessories”Sidecar containers — databases, caches, queues — that run alongside the app but aren’t part of the rolling deploy. Booted once and left alone.
accessories: redis: image: redis:7 host: 192.0.2.20 port: 6379 db: image: postgres:16 host: 192.0.2.20 port: 5432 env: clear: POSTGRES_USER: app secret: - POSTGRES_PASSWORD volumes: - /data/pg:/var/lib/postgresql/data files: - config/init.sql:/docker-entrypoint-initdb.d/init.sqlTwo caveats on the example shapes: files: is accepted but never uploaded — no copy step or docker flag is emitted (#3088) — and accessory env.secret entries follow the same delivery rules as the top-level env key: delivered via a host-side env file on 4.0.4+ builds, silently dropped on the released 4.0.3 (#2957).
See Accessories for the full walk-through.
Rejected Kamal keys
Section titled “Rejected Kamal keys”Kamal accepts a number of additional top-level keys that wheels deploy has not implemented. They used to pass validation while doing nothing; the validator now rejects them with unknown top-level key: '<name>' so a config that looks like it works but doesn’t fails loudly (#3088).
| Key | What it does in Kamal | Status in wheels deploy |
|---|---|---|
boot | Rolling boot sequence (limit: parallelism, wait: pauses) | Rejected — hosts always deploy one at a time, sequentially (orchestration work tracked under #2957) |
healthcheck | Legacy pre-kamal-proxy top-level health check | Rejected — configure proxy.healthcheck: instead |
hooks | Path override for the hooks directory | Rejected — hook scripts are always loaded from .kamal/hooks/ (see Hooks; the feature works, only the path override key does not exist) |
volumes, labels | App-container volume mounts / extra labels | Rejected — containers only get the four labels Wheels adds (service, role, destination, version); only accessory volumes:/directories: are mounted |
logging | docker run --log-driver / --log-opt passthrough | Rejected — no logging flags are ever emitted |
retain_containers | Old-container keep count for rollback | Rejected — wheels deploy prune all always keeps the 5 most recent unless you pass --keep=<n> on the command line |
minimum_version | Fail-fast CLI version gate | Rejected — no version gate runs |
asset_path | Zero-downtime JS/CSS asset rotation path | Rejected |
require_destination | Forces every command to pass --destination=<name> | Rejected |
allow_empty_roles | Lets a role with an empty hosts: list pass validation | Rejected |
run_directory | Working directory inside the container | Rejected — the image’s configured WORKDIR is always used |
readiness_delay | Seconds to wait after docker run before proxy cutover | Rejected |
Variable interpolation
Section titled “Variable interpolation”deploy.yml supports a single, deliberately small interpolation form: ${UPPER_SNAKE} env-var tokens. This is the same syntax Ruby Kamal supports natively. Lookup order:
.kamal/secrets(and.kamal/secrets.<destination>when--destinationis set).System.getenv(VAR)on the machine runningwheels deploy.- Empty string for unset vars.
service: ${APP_NAME}image: ${REGISTRY}/${APP_NAME}Only uppercase-and-underscore tokens are expanded — ${APP_NAME} matches; lowercase ${service} is left alone, which keeps shell-style placeholders elsewhere in the config from being captured by accident.
No loops, conditionals, or method calls. If you need logic (e.g. different values per destination), resolve it in .kamal/secrets or a .kamal/secrets.<destination> overlay first and reference the result with ${VAR}.
Destination overlays
Section titled “Destination overlays”--destination production loads config/deploy.yml and then deep-merges config/deploy.production.yml on top. Arrays are replaced; maps merge recursively. Use overlays to split shared config from per-environment overrides:
service: myappimage: myorg/myapp
servers: web: - 192.0.2.10servers: web: - staging.example.com
proxy: host: staging.example.comOne caveat: the wheels deploy config inspection verb currently ignores --destination and always prints the base file (#3085). To see an overlay applied, use a verb that does resolve it, e.g. wheels deploy details --destination=staging --dry-run.
Validation errors
Section titled “Validation errors”Errors are emitted as <absolute path to deploy.yml>: <message> — the prefix is the full file path, and messages are never line-scoped:
missing required key: '<name>'— one ofservice,image,serversis absent.unknown top-level key: '<name>' (allowed keys: …)— typo, unsupported Kamal key, or a key from the rejected list. The message includes the full allowlist.invalid host: '<string>'— host string has more than one colon without IPv6 brackets (adjacent colons count, so unbracketed'::1:22'is rejected).invalid <kind> name: '<name>' (must match [a-zA-Z0-9][a-zA-Z0-9_.-]*)— a service, role, or accessory name contains shell-unsafe characters. Develop builds only (added after 4.0.3 by #3008 for #2956).