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/library/caddy:2.11.4-alpineConfigures the shared host-level Caddy 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 Caddy. Only servers.web may declare it — see servers.<role>.

yaml
servers:
  web:
    proxy:
      host: my-app.example.com
      path: /
      ssl: true
      app_port: 8000
      redirect_hosts:
        - www.my-app.example.com
      healthcheck:
        path: /health
        required_successes: 3
KeyTypeRequired / defaultExampleRules
hostStringOptionalmy-app.example.comPublic hostname registered in Caddy. Omit for an HTTP catch-all route.
sslBoolOptional, default falsetrueRequires host; use only after DNS points at the host.
app_portInt32Optional, default 30008000Positive port matching the app listener inside the container.
healthcheckHealthcheckConfigOptional, default objectSee belowControls readiness before proxy switch.
pathStringOptional/adminExact path and subtree registered in Caddy; the prefix is stripped before proxying.
redirect_hostsArray(String)Optional, default [][www.my-app.example.com]Hostnames redirected permanently to host, including path and query. Values must be unique and differ from host. Uses HTTPS when ssl: true.

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 Caddy Quadlet installed by meridian setup. This block is optional; omit it to use Meridian's built-in Caddy defaults. Role-level servers.web.proxy is what enables proxied deploys and route registration.

yaml
proxy:
  image: docker.io/library/caddy:2.11.4-alpine
  http_port: 80
  https_port: 443
  data_dir: "%h/.local/share/meridian-caddy"
  drain_timeout: 300
KeyTypeRequired / defaultExampleRules
imageStringOptional, runtime default docker.io/library/caddy:2.11.4-alpinedocker.io/library/caddy:2.11.4-alpineMust contain Caddy 2.11.2 or newer.
http_portInt32Optional, default 8080Positive host port; rootless low-port binding must be enabled.
https_portInt32Optional, default 443443Positive host port with the same low-port requirement.

The defaults need net.ipv4.ip_unprivileged_port_start=80, which meridian server bootstrap sets. Without root, use higher ports and keep an existing port forwarder in front. | data_dir | String | Optional, default %h/.local/share/meridian-caddy | "%h/.local/share/meridian-caddy" | Mounted at /data for certificates and Caddy state. %h is systemd's deploy-user home specifier. | | drain_timeout | Int32 | Optional, default 300 | 300 | Positive seconds to wait for in-flight requests on the removed upstream; timeout warns and force-stops it. |

The data_dir default lives under the deploy user's home so that nothing in Meridian needs root on the host. Meridian also stores the root Caddyfile, per-service route fragments, Unix admin socket, and reload lock under ~/.config/containers/meridian-caddy/.

Caddy obtains and renews certificates automatically when servers.web.proxy.ssl: true; ssl: false emits an explicit HTTP site address. For a service without an existing fragment, meridian setup installs a persistent 503 maintenance route until the first successful deploy; existing routes are preserved.

A root-owned path requires you to create it yourself — Meridian will not use sudo.

If port binding fails, see Caddy 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. The secret name is the environment variable name the container sees; there is no rename step.

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

If an existing secret has another name, copy it under the expected name; see Taking Over An Existing Host. Accessory secrets can rename variables with target=VAR.

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 with podman save --format oci-dir, rsyncs it to the host, and imports it there with skopeo inside podman unshare, so a redeploy only sends the layers that changed. Both export through podman, so both work from macOS, where Podman runs inside a VM.

accessories ​

Accessories are declarative host-local resources: databases, caches, and other services your app depends on. They get their own Quadlet and lifecycle commands.

yaml
accessories:
  postgres:
    image: docker.io/library/postgres:18-alpine
    host: prod-01.example.com
    network: postgres                # 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.
networkStringOptionalpostgresPodman network the accessory joins. The app automatically joins it too. The older <name>.network form still works and means the same 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 every accessory it depends on. Accessories are not auto-started; run meridian accessory start NAME before the first app deploy.

Sharing an accessory between services ​

Declaring an accessory means "this application depends on this resource". Two services may declare the same one. FreshRSS:

yaml
service: freshrss

accessories:
  postgres:
    image: docker.io/library/postgres:18-alpine
    host: server.example.com
    network: postgres
    volumes:
      - postgres-data:/var/lib/postgresql/data

Nextcloud, independently:

yaml
service: nextcloud

accessories:
  postgres:
    image: docker.io/library/postgres:18-alpine
    host: server.example.com
    network: postgres
    volumes:
      - postgres-data:/var/lib/postgresql/data

Both declarations refer to the same postgres container on server.example.com. There is no owner and no consumer syntax — repeating the declaration is the whole mechanism.

The definitions must match. Identity is the accessory name, its host, and its effective definition (image, port, network, volumes, environment, secrets, command, depends_on, and readiness). Meridian normalizes that into a canonical form and fingerprints it, so YAML key order and formatting are irrelevant while any field that changes the container is not. Matching definitions on the same host are one shared resource; differing ones are a conflict:

Accessory 'postgres' conflicts with the definition already registered by service 'freshrss'.

Different fields:
  image:
    current:  docker.io/library/postgres:17-alpine
    existing: docker.io/library/postgres:18-alpine

Meridian never silently picks one definition or overwrites another service's.

Both apps automatically join the postgres network. Nextcloud's generated Quadlet contains both its private service network and the accessory's:

ini
Network=nextcloud.network
Network=postgres

You do not declare that network anywhere else — the accessory declaration implies it. Accessories using several networks add each one once.

Either project may start it, and starting is idempotent:

bash
meridian accessory start postgres

start verifies no other service declares postgres differently, creates the postgres network if it is missing, reuses an existing compatible unit, and refuses to overwrite an incompatible one.

The usual first-time sequence:

bash
meridian setup
meridian accessory start postgres
meridian check
meridian deploy

A second application declaring the same PostgreSQL normally needs only:

bash
meridian setup
meridian check
meridian deploy

Stopping or removing a shared accessory affects every service that declares it, so Meridian warns and defaults to No:

Accessory 'postgres' is shared by 3 services:
  freshrss
  nextcloud
  vaultwarden

Stopping it will affect all of them.
Continue? [y/N]

--force acknowledges that warning. It does not overwrite conflicting definitions, bypass missing requirements, or delete anything extra. meridian accessory remove deletes the Quadlet unit only — named volumes, images, and the shared network are left in place, because other services may still depend on them.

Two caveats:

  • Podman networks and accessory containers are host-local. Two services naming the same accessory on different hosts are two separate resources and do not conflict.
  • Sharing a PostgreSQL instance does not create separate databases and users for each application. Provisioning those remains PostgreSQL and application configuration.

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 Caddy atomically switches traffic.
after_switchArray(RemoteHookConfig)[]After the successful Caddy reload (the commit point).
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 release directory under ~/.local/state/meridian/assets/<service>/, and the shared Caddy proxy serves it directly on a separate host.

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 without a container network. Disable eager cache or database connections for this command.
output_dirStringRequired/app/assetsDirectory copied into the asset release directory.
retain_releasesInt32Optional, default 22Release directories kept on disk. The route serves the newest two; older ones are retained but not reachable.
compressionBoolOptional, default truetrueEmits encode zstd gzip in the asset route fragment. Set false to disable compression.

Validation: assets: requires servers.web.proxy because the assets are served by the same shared Caddy instance that fronts the proxied web host. strategy: recreate rejects assets: in this release rather than publishing a partial asset transaction.

The asset route 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.

The route tries current first, then previous. This keeps pages loaded before a deploy from losing their fingerprinted assets. Older retained releases are not served.

Assets are served by the shared meridian-caddy proxy, which bind-mounts ~/.local/state/meridian/assets read-only at /srv/assets. That mount is installed by meridian setup; meridian check verifies it as caddy-assets.

MIT License