Skip to content

deploy.yml

deploy.yml is Meridian's deployment contract. By default Meridian reads .meridian/deploy.yml; pass --config PATH on commands that support alternate config files.

Meridian parses config strictly. Unknown keys at any supported nesting level fail before deploy with an Unknown config key error; this mirrors YAML::Serializable::Strict and prevents silent no-op configuration.

Use meridian plan after editing this file. It loads the same schema and prints the resolved deploy intent without SSH or registry access.

Top-Level Keys

KeyTypeRequired / defaultExampleRules
serviceStringRequiredmy-appMust start with a letter and contain only letters, digits, hyphens, and underscores.
imageStringRequiredghcr.io/acme/my-app:latestUsed by every role unless servers.<role>.image overrides it.
buildBuildConfigOptional, but unsupportedSee buildAny present build: block fails with Config key build is not yet supported.
serversmap of role name to ServerConfigRequired, non-emptyweb: { hosts: [...] }Role names are user-defined; assets: requires servers.web.proxy.
proxyProxyConfigOptionalimage: docker.io/basecamp/kamal-proxy:v0.9.2Configures the shared host-level kamal-proxy service used by proxied roles.
registryRegistryConfigOptionalserver: ghcr.ioUsed before registry pulls when credentials are configured.
envEnvConfigOptionalclear: { MARTEN_ENV: production }Applied to app containers and one-off run containers.
sshSSHConfigOptional, default objectuser: deployControls SSH arguments for remote commands and transfers.
bootBootConfigOptional, default objectlimit: 1Controls host batching and wait time during deploy.
transferTransferConfigOptionalmode: streamOmit for registry pull; if present, mode is required.
accessoriesmap of name to AccessoryConfigOptionalpostgres: { image: ... }Accessory names become host-side Quadlet/container names.
volumesArray(String)Optional, default []["data:/app/data"]App container Volume= entries.
portsArray(String)Optional, default []["127.0.0.1:9000:9000"]App container PublishPort= entries.
hooksHooksConfigOptionalpre_deploy: ./scripts/checkLocal hooks run on the operator machine; remote hooks run on selected hosts.
filesArray(FileSyncConfig)Optional, default []See filesUploads supporting files, optionally template-rendered.
assetsAssetsConfigOptionalSee assetsRequires servers.web.proxy; publishes deploy-managed static assets.

service

Names every generated app unit, service network, runtime-state directory, and proxy registration.

yaml
service: my-app

Validation: service must match ^[a-zA-Z][a-zA-Z0-9_-]*$.

image

The default image for every managed role.

yaml
image: ghcr.io/acme/my-app:latest

Roles can override this with servers.<role>.image. For transfer.mode: stream or incremental, the selected image must exist in local Podman storage; meridian check verifies that before remote mutation.

build

Reserved for future build support. The schema accepts these keys, but any present build: block currently fails validation.

KeyTypeRequired / defaultExampleRules
dockerfileStringOptional, default DockerfileContainerfileReserved; not used while build: is unsupported.
contextStringOptional, default ..Reserved.
argsHash(String, String)Optional, default {}{ RAILS_ENV: production }Reserved.
platformStringOptionallinux/arm64Reserved.
builderStringOptionalpodmanReserved.

Do not add build: yet. Build the image yourself, push it to a registry, or use a registry-free transfer mode.

servers.<role>

Each key under servers: is a role name. web is the conventional proxied role and is required when assets: is configured.

yaml
servers:
  web:
    hosts:
      - prod-01.example.com
    image: ghcr.io/acme/my-app-web:latest
    proxy:
      host: my-app.example.com
      ssl: true
      app_port: 8000
  workers:
    hosts:
      - prod-01.example.com
    cmd: bin/jobs
KeyTypeRequired / defaultExampleRules
hostsArray(String)Optional, default []["prod-01.example.com"]Commands have no targets if a role has no hosts.
proxyServerProxyConfigOptionalSee role proxyOnly supported when managed: true.
cmdStringOptionalbin/jobsAppends a container command for managed roles; forbidden when managed: false.
imageStringOptionalghcr.io/acme/my-worker:latestOverrides top-level image for this role.
managedBoolOptional, default truefalsefalse switches to existing-unit compatibility mode.
unitsArray(String)Optional, default []["legacy-app.service"]Required when managed: false; forbidden when managed: true.

Validation: unmanaged roles cannot define proxy or cmd, and must define at least one units entry.

servers.<role>.proxy

Role-local proxy configuration enables blue/green cutover through kamal-proxy.

yaml
servers:
  web:
    proxy:
      host: my-app.example.com
      path: /
      ssl: true
      app_port: 8000
      healthcheck:
        path: /health
        required_successes: 3
KeyTypeRequired / defaultExampleRules
hostStringOptionalmy-app.example.comPublic hostname registered in kamal-proxy.
sslBoolOptional, default falsetrueUse only after DNS points at the host.
app_portInt32Optional, default 30008000Must match the port the app listens on inside the container.
healthcheckHealthcheckConfigOptional, default objectSee belowControls readiness before proxy switch.
pathStringOptional/adminRoute path registered in kamal-proxy.

servers.<role>.proxy.healthcheck

The healthcheck runs from a temporary probe container on the meridian-proxy network, not from inside your app image. Configure one app healthcheck path per proxied role.

KeyTypeRequired / defaultExampleRules
pathStringOptional, default /health/upMust return success from the new app container.
intervalInt32Optional, default 22Seconds between attempts.
timeoutInt32Optional, default 55Per-attempt timeout in seconds.
retriesInt32Optional, default 1020Maximum attempts before failing rollout.
probe_imageStringOptional, default docker.io/library/alpine:3.21registry.local/probe:3.21Should ship wget/nc (the probe fails at deploy time otherwise — Meridian does not validate this); useful for mirrors or air-gapped hosts.
required_successesInt32Optional, default 33Consecutive successful probes needed before traffic switches.

For failures, see Healthcheck timeout.

proxy

Top-level proxy settings configure the shared kamal-proxy Quadlet installed by meridian setup. This block is optional; omit it to use Meridian's built-in kamal-proxy defaults. Role-level servers.<role>.proxy is what enables proxied deploys and route registration.

yaml
proxy:
  image: docker.io/basecamp/kamal-proxy:v0.9.2
  http_port: 80
  https_port: 443
  data_dir: /var/lib/kamal-proxy
KeyTypeRequired / defaultExampleRules
imageStringOptional, runtime default docker.io/basecamp/kamal-proxy:v0.9.2docker.io/basecamp/kamal-proxy:v0.9.2Leave unset to use Meridian's pinned default.
http_portInt32Optional, default 8080Rootless low-port binding must be enabled on the host.
https_portInt32Optional, default 443443Same low-port requirement as http_port.
data_dirStringOptional, default /var/lib/kamal-proxy/var/lib/kamal-proxyMounted into the proxy container for certificate/state data.

If port binding fails, see kamal-proxy bind permission denied.

registry

Registry credentials are used before podman pull when registry transfer is selected or when images need remote pulling.

yaml
registry:
  server: ghcr.io
  username: deploy
  password:
    - REGISTRY_PASSWORD
KeyTypeRequired / defaultExampleRules
serverStringOptionalghcr.ioRegistry host passed to login.
usernameStringOptionaldeployRegistry username.
passwordArray(String)Optional, default [][REGISTRY_PASSWORD]Names environment variables that provide the password value.

Missing password environment variables fail before SSH begins.

env

Environment variables for app containers and one-off run containers.

yaml
env:
  clear:
    MARTEN_ENV: production
  secret:
    - SECRET_KEY_BASE
    - DATABASE_URL
KeyTypeRequired / defaultExampleRules
clearHash(String, String)Optional, default {}{ MARTEN_ENV: production }Written directly into generated Quadlets.
secretArray(String)Optional, default [][DATABASE_URL]Names Podman secrets already present on target hosts.

Use service-prefixed secret names when multiple apps share one host.

ssh

SSH settings used by remote commands and transfer helpers.

yaml
ssh:
  user: deploy
  port: 22
  keys:
    - /Users/me/.ssh/id_ed25519
  connect_timeout: 10
  keepalive: true
  keepalive_interval: 30
KeyTypeRequired / defaultExampleRules
userStringOptional, default deploydeployRemote user for SSH.
portInt32Optional, default 2222SSH port.
keysArray(String)Optional, default []["/Users/me/.ssh/id_ed25519"]First key is used as identity file; paths are expanded with home support.
proxy_jumpStringOptionalbastion.example.comPassed through to SSH as a jump host.
connect_timeoutInt32Optional, default 1010SSH connection timeout in seconds.
keepaliveBoolOptional, default truetrueEnables SSH server-alive options.
keepalive_intervalInt32Optional, default 3030Server-alive interval in seconds.

boot

Controls deploy batching.

yaml
boot:
  limit: 1
  wait: 10
KeyTypeRequired / defaultExampleRules
limitInt32Optional, default 11Number of hosts released in a batch.
waitInt32Optional, default 010Seconds to wait between batches.

transfer

Controls how the selected image reaches each host.

yaml
transfer:
  mode: stream
KeyTypeRequired / defaultExampleRules
moderegistry, stream, or incrementalRequired when transfer: is presentstreamUnknown modes fail parse; empty mode fails validation.

Omit transfer: for registry pull. stream uses podman save | zstd | ssh | podman load; incremental exports an OCI layout and syncs changed layers.

accessories

Accessories are independent services such as databases and caches. They get their own Quadlet and lifecycle commands.

yaml
accessories:
  postgres:
    image: docker.io/library/postgres:18-alpine
    host: prod-01.example.com
    network: my-app.network          # reachable by container name on this network; no host port
    volumes:
      - my-app-pgdata:/var/lib/postgresql
    env:
      clear:
        POSTGRES_DB: app
        POSTGRES_USER: app
        POSTGRES_PASSWORD_FILE: /run/secrets/MY_APP_POSTGRES_PASSWORD
    secrets:
      - MY_APP_POSTGRES_PASSWORD
    # readiness inferred from the postgres image (pg_isready); override with `ready:` if needed

The official Postgres image supports the _FILE convention used above, so a Podman secret can remain mounted at /run/secrets/... instead of being exposed as a Postgres environment variable. See the Postgres image documentation.

KeyTypeRequired / defaultExampleRules
imageStringOptional in YAML, required at runtimedocker.io/library/postgres:18-alpineMissing image fails when readiness inference or generation needs it.
hostStringOptional in schemaprod-01.example.comAccessory commands need a target host; declare it explicitly.
portStringOptional"5432:5432"Used as published port and for default readiness inference.
volumesArray(String)Optional, default []["pgdata:/var/lib/postgresql"]Accessory Volume= entries.
envEnvConfigOptionalclear: { POSTGRES_USER: app }Same shape as top-level env; official images can consume file-mounted secrets through variables such as POSTGRES_PASSWORD_FILE.
cmdStringOptionalpostgres -c max_connections=200Container command for the accessory.
networkStringOptionalmy-app.networkPut co-dependent accessories on the app service network.
secretsArray(String)Optional, default [][MY_APP_POSTGRES_PASSWORD]Extra Podman secrets for the accessory.
depends_onStringOptionalpostgresAdds systemd ordering between accessories.
readyAccessoryReadinessConfigOptionalSee belowExplicit readiness probe; otherwise Meridian tries to infer one.

accessories.<name>.ready

Readiness must declare exactly one probe shape: tcp, cmd, or http.

KeyTypeRequired / defaultExampleRules
tcpInt32 or Array(Int32)One of tcp/cmd/http6379 or [5432, 5433]Normalized to a list; each value must be an integer.
cmdArray(String)One of tcp/cmd/http["pg_isready", "-U", "app"]Runs inside the accessory with podman exec.
httpAccessoryReadinessHTTPConfigOne of tcp/cmd/http{ path: /health, port: 8080 }Sidecar HTTP GET.
timeoutInt32Optional, default 55Per-probe timeout in seconds.
intervalInt32Optional, default 11Seconds between attempts.
retriesInt32Optional, default 3030Maximum attempts before the gate fails.

ready.http fields:

KeyTypeRequired / defaultExampleRules
pathStringOptional, default //healthRequest path.
portInt32Required8080Port inside the accessory container.

If ready: is omitted, Meridian infers defaults for common images:

Image base nameInferred readiness
postgrescmd: ["pg_isready", "-q"]
redis, valkey, dragonfly, keydbtcp: 6379
mysql, mariadbcmd: ["mysqladmin", "ping", "--silent"]
anything elsetcp on the first declared port; if no port exists, validation asks for explicit ready:.

The generated app Quadlet gains Wants= and After= for co-network accessories. Accessories are not auto-started; run meridian accessory start NAME before the first app deploy.

volumes

App container volume mounts.

yaml
volumes:
  - my-app-data:/app/data
  - /srv/my-app/config:/app/config:ro
KeyTypeRequired / defaultExampleRules
volumesArray(String)Optional, default []my-app-data:/app/dataEach string is passed through as a Quadlet Volume= entry.

ports

App container host port publications.

yaml
ports:
  - "127.0.0.1:9000:9000"
KeyTypeRequired / defaultExampleRules
portsArray(String)Optional, default []"127.0.0.1:9000:9000"Each string is passed through as a Quadlet PublishPort= entry.

hooks

Hooks run extra commands at deploy boundaries.

yaml
hooks:
  pre_deploy: ./scripts/preflight
  post_deploy: ./scripts/notify
  remote:
    before_start:
      - command: bin/manage migrate
        roles: [web]
KeyTypeRequired / defaultExampleRules
pre_deployStringOptional./scripts/preflightRuns locally before deploy starts.
post_deployStringOptional./scripts/notifyRuns locally after deploy finishes.
remoteRemoteHooksConfigOptionalSee belowRuns on remote hosts during deploy phases.

Remote phases under hooks.remote:

PhaseTypeDefaultWhen it runs
before_transferArray(RemoteHookConfig)[]Before image transfer.
after_transferArray(RemoteHookConfig)[]After image transfer.
after_uploadArray(RemoteHookConfig)[]After Quadlets/files/assets upload.
before_startArray(RemoteHookConfig)[]After co-network accessories are ready and before asset build/app start.
after_startArray(RemoteHookConfig)[]After starting the new unit.
before_switchArray(RemoteHookConfig)[]Before kamal-proxy switches traffic.
after_switchArray(RemoteHookConfig)[]After kamal-proxy switches traffic.
after_deployArray(RemoteHookConfig)[]After deploy state is recorded.

Each remote hook entry has:

KeyTypeRequired / defaultExampleRules
commandStringRequiredbin/manage migrateCommand executed remotely.
rolesArray(String)Optional[web]Limits the hook to selected roles.

files

Uploads host-side files during deploy.

yaml
files:
  - source: deploy/Caddyfile.ecr
    destination: /home/deploy/Caddyfile
    template: true
    roles: [web]
KeyTypeRequired / defaultExampleRules
sourceStringRequireddeploy/Caddyfile.ecrLocal file path.
destinationStringRequired/home/deploy/CaddyfileRemote path.
templateBoolOptional, default falsetrueRender with ECR before upload.
rolesArray(String)Optional[web]Limit upload to selected roles.

assets

Meridian's built-in path for publishing fingerprinted static assets as part of the deploy - distinct from your app's dynamic responses and from user media uploads. The command builds the front-end bundle inside the app image, its output_dir output is copied into a deploy-managed volume, and a generated Caddy server publishes the result on a separate host that kamal-proxy routes to.

yaml
assets:
  host: assets.my-app.example.com
  command: bin/manage collectassets --fingerprint --no-input
  output_dir: /app/assets
  retain_releases: 2
  compression: true
KeyTypeRequired / defaultExampleRules
hostStringRequiredassets.my-app.example.comMust resolve to the server before HTTPS issuance.
commandStringRequiredbin/manage collectassets --fingerprint --no-inputRuns in an app-image one-shot unit.
output_dirStringRequired/app/assetsDirectory copied into the asset release volume.
retain_releasesInt32Optional, default 22Number of old asset releases kept.
compressionBoolOptional, default truetrueEmits encode zstd gzip in the asset Caddyfile. Set false to disable compression.

Validation: assets: requires servers.web.proxy because the asset server is published through the proxied web host.

The asset server always sends fingerprinted files with a long-lived Cache-Control: public, max-age=31536000, immutable header, and — unless compression: false — negotiates zstd/gzip compression per request.

MIT License