holden.yml
The holden.yml file defines your app’s services and infrastructure needs. It lives in your app’s git repo.
Example
Section titled “Example”services: web: image: ghcr.io/you/myapp:latest domain: myapp.example.com port: 3000 memory: 512m cpu: 1 env: DATABASE_URL: ${needs.postgres.url} volumes: - ./uploads:/app/uploads
worker: image: ghcr.io/you/myapp:latest command: ["bun", "worker.ts"] memory: 256m cpu: 0.5 env: DATABASE_URL: ${needs.postgres.url}
needs: postgres: valkey:
data_dir: /mnt/big-drive/myappservices
Section titled “services”Each key under services: defines a container. The key becomes the service name.
image: ghcr.io/you/myapp:latestDocker image to run.
Required.
domain
Section titled “domain”domain: myapp.example.comPublic domain(s) for this service. Holden configures Traefik routing automatically.
# Multiple domainsdomain: - myapp.example.com - www.myapp.example.comOptional. Services without domain: are internal-only (workers, schedulers).
domain_mode
Section titled “domain_mode”domain_mode: redirectHow to handle multiple domains.
| Value | Behavior |
|---|---|
redirect (default) | Redirect all domains to the first one |
all | Serve content on all domains, no redirects |
Optional. Default: redirect
port: 3000Port your container listens on. Holden tells Traefik to route HTTP traffic to this port.
The port is only accessible inside the app’s private network (holden-{app-id}). External access requires domain: to be set, which configures Traefik routing.
Multiple services in the same app can use the same port number—they’re isolated on the network.
Required if domain: is set.
networks
Section titled “networks”networks: ["backend"]Join shared networks for cross-app communication. Holden creates networks as holden-{name}.
Services on the same network can reach each other as {app-id}-{service-name}.
networks: ["backend", "monitoring"] # join multiple networksOptional. No shared networks if not specified.
startup_timeout
Section titled “startup_timeout”startup_timeout: 5mMax time to wait for a new container to become healthy during zero-downtime deployment. If the timeout expires (or Docker marks the container “unhealthy”), Holden removes the new container and keeps the old one running.
Only applies to containers with a Docker HEALTHCHECK in their image.
Optional. Default: 5m
drain_timeout
Section titled “drain_timeout”drain_timeout: 30sTime between SIGTERM and SIGKILL when stopping containers. This gives your app time to finish in-flight requests before being forcefully terminated.
Optional. Default: 10s
command
Section titled “command”command: ["bun", "worker.ts"]Override the container’s default command (CMD). The image’s ENTRYPOINT is preserved—this replaces only the CMD portion.
# If the image has: ENTRYPOINT ["node"] CMD ["server.js"]# This runs: node worker.jscommand: ["worker.js"]Optional.
schedule
Section titled “schedule”Not yet implemented. Will support cron syntax for scheduled tasks that run once and exit, rather than long-running services.
env: NODE_ENV: production DATABASE_URL: ${needs.postgres.url} API_KEY: ${secret.API_KEY}Environment variables for the container. Supports variable syntax.
Optional.
volumes
Section titled “volumes”volumes: - ./uploads:/app/uploads - /mnt/nas/assets:/app/assetsMount volumes into the container.
| Format | Behavior |
|---|---|
./path:/container | Relative: stored at [@base_data_dir]/{app-id}/path |
/absolute:/container | Absolute: passed through as-is |
Optional.
memory
Section titled “memory”memory: 512mMaximum memory for the container. Uses Docker memory format: 128m, 1g, 2.5g.
Optional. No limit if not specified.
cpu: 0.5Maximum CPU cores. Decimal value where 1.0 equals one full core, 0.5 is half a core.
Optional. No limit if not specified.
needs: postgres: valkey: garage:Managed infrastructure services. Holden creates these containers automatically with generated credentials.
See Needs for details.
Optional.
needs.postgres
Section titled “needs.postgres”needs: postgres: version: 17| Field | Default | Description |
|---|---|---|
version | 18 | PostgreSQL major version. Resolves to the Alpine variant (e.g. 17 → postgres:17-alpine). |
image | — | Custom image (e.g. postgis/postgis:18-3.5). Overrides version. |
Both fields are optional. Bare postgres: uses PostgreSQL 18 Alpine.
backup_volumes
Section titled “backup_volumes”services: web: volumes: - ./uploads:/app/uploads - ./cache:/app/cache
backup_volumes: - ./uploadsVolumes to include in backups during the maintenance window. Uses host paths (the left side of volume definitions). Needs containers are backed up automatically—this is for app-specific data.
Validation:
holden validateerrors if a path doesn’t match any volume’s host path- At runtime, missing directories are skipped with a warning
Optional. No app volumes backed up if not specified.
data_dir
Section titled “data_dir”data_dir: /mnt/big-drive/myappOverride where this app’s data is stored.
By default, app data goes to [@base_data_dir]/{app-id}/. For example, if HOLDEN_BASE_DATA_DIR=/appdata and your app ID is myapp, volumes are stored under /appdata/myapp/.
Setting data_dir here replaces the entire path—volumes go directly under the specified directory.
Optional. Default: [@base_data_dir]/{app-id}/
update_policy
Section titled “update_policy”update_policy: alwaysControls when Holden checks for new upstream images (base image updates, security patches).
| Value | Behavior |
|---|---|
always (default) | Poll timer checks continuously; maintenance also checks |
during_maintenance | Only check during the maintenance window—no surprise updates during the day |
manual | Only update when explicitly triggered via webhook or CLI |
Webhooks always trigger an image check regardless of this setting—update_policy controls background checking only.
Use during_maintenance for predictable restarts—your app only updates at 3am, not randomly throughout the day.
Use manual when you have CI/CD webhooks and want full control over when deploys happen.
Optional. Default: always