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.
strategyblue_green | recreateOptional, proxy-driven defaultrecreateOmit to preserve the existing behavior; see strategy.
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: [...] }Must define a web role; other 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_-]*$.

strategy

Controls deployment ordering for the whole service.

yaml
service: my-app
strategy: recreate
image: ghcr.io/acme/my-app:2026-08-17

servers:
  web:
    hosts: [prod-01.example.com]
    proxy:
      host: my-app.example.com
      ssl: true
      app_port: 8000
      healthcheck:
        path: /health
  cron:
    hosts: [prod-01.example.com]
    cmd: /cron.sh

Supported explicit values are blue_green and recreate. When omitted, a proxied web role continues to use Blue/Green and non-proxied roles continue to restart in place. Meridian never infers Recreate from volumes, accessories, or other statefulness heuristics. Explicit blue_green requires servers.web.proxy.

recreate deliberately causes downtime so old and new app processes never share persistent state. It is intended for stateful single-instance services. This release requires servers.web.proxy, managed roles, and exactly one common host for every app role. It rejects assets: and selective deploy --role or --host invocations. Accessories remain running and are only checked for readiness.

If a Recreate deploy fails after entering maintenance, Meridian does not restart the old image or resume the route automatically: migrations may have made the persistent data incompatible. Repair the new release or restore image, database, and volumes from a matching backup before resuming traffic manually.

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 a reserved role name: every config must define servers.web, and it is the only role that may declare proxy:. Meridian supports exactly one proxied role in this release. Other role names are user-defined.

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 on the web role, and only 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: servers.web is required. proxy: on any other role fails with servers.<role>.proxy is not supported: web is the only role that can be proxied. Unmanaged roles cannot define proxy or cmd, and must define at least one units entry.

Managed roles use one of two unit models:

  • With proxy:, Meridian deploys colour-named <service>-blue and <service>-green units and records proxy release state.
  • Without proxy:, Meridian deploys one stable <service>-<role> unit and restarts it in place. This path has brief downtime and does not read or write active-color or release-state.json.

servers.<role>.proxy

Role-local proxy configuration enables blue/green cutover through kamal-proxy. Only servers.web may declare it — see servers.<role>.

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.web.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.

Meridian creates these with podman secret create over SSH stdin and lets Podman store them with whatever secret driver is configured on that host; it does not select a driver itself, so the storage guarantees are the host's, not Meridian's. Each declared name is injected into the container at start through a generated Quadlet Secret= directive, so values stay out of deploy.yml, out of the image, and out of the generated unit file.

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.

Under strategy: recreate, before_transfer and after_transfer retain their normal positions around image preparation, before maintenance. Keep these two hook phases non-destructive for Recreate services. File syncs and after_upload run only after active app units are stopped; each role's before_start and after_start still bracket that role's new unit. Secondary roles start only after the web healthcheck passes, and after_deploy runs after the route is resumed.

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. strategy: recreate rejects assets: in this release rather than publishing a partial asset transaction.

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