Skip to content

Latest commit

 

History

History
680 lines (537 loc) · 29.7 KB

File metadata and controls

680 lines (537 loc) · 29.7 KB

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

Key Type Required / default Example Rules
service String Required my-app Must start with a letter and contain only letters, digits, hyphens, and underscores.
strategy blue_green | recreate Optional, proxy-driven default recreate Omit to preserve the existing behavior; see strategy.
image String Required ghcr.io/acme/my-app:latest Used by every role unless servers.<role>.image overrides it.
build BuildConfig Optional, but unsupported See build Any present build: block fails with Config key build is not yet supported.
servers map of role name to ServerConfig Required, non-empty web: { hosts: [...] } Must define a web role; other role names are user-defined. assets: requires servers.web.proxy.
proxy ProxyConfig Optional image: docker.io/library/caddy:2.11.4-alpine Configures the shared host-level Caddy service used by proxied roles.
registry RegistryConfig Optional server: ghcr.io Used before registry pulls when credentials are configured.
env EnvConfig Optional clear: { MARTEN_ENV: production } Applied to app containers and one-off run containers.
ssh SSHConfig Optional, default object user: deploy Controls SSH arguments for remote commands and transfers.
boot BootConfig Optional, default object limit: 1 Controls host batching and wait time during deploy.
transfer TransferConfig Optional mode: stream Omit for registry pull; if present, mode is required.
accessories map of name to AccessoryConfig Optional postgres: { image: ... } Accessory names become host-side Quadlet/container names.
volumes Array(String) Optional, default [] ["data:/app/data"] App container Volume= entries.
ports Array(String) Optional, default [] ["127.0.0.1:9000:9000"] App container PublishPort= entries.
hooks HooksConfig Optional pre_deploy: ./scripts/check Local hooks run on the operator machine; remote hooks run on selected hosts.
files Array(FileSyncConfig) Optional, default [] See files Uploads supporting files, optionally template-rendered.
assets AssetsConfig Optional See assets Requires servers.web.proxy; publishes deploy-managed static assets.

service

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

service: my-app

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

strategy

Controls deployment ordering for the whole service.

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.

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.

Key Type Required / default Example Rules
dockerfile String Optional, default Dockerfile Containerfile Reserved; not used while build: is unsupported.
context String Optional, default . . Reserved.
args Hash(String, String) Optional, default {} { RAILS_ENV: production } Reserved.
platform String Optional linux/arm64 Reserved.
builder String Optional podman Reserved.

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.

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
Key Type Required / default Example Rules
hosts Array(String) Optional, default [] ["prod-01.example.com"] Commands have no targets if a role has no hosts.
proxy ServerProxyConfig Optional See role proxy Only supported on the web role, and only when managed: true.
cmd String Optional bin/jobs Appends a container command for managed roles; forbidden when managed: false.
image String Optional ghcr.io/acme/my-worker:latest Overrides top-level image for this role.
managed Bool Optional, default true false false switches to existing-unit compatibility mode.
units Array(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>.

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
Key Type Required / default Example Rules
host String Optional my-app.example.com Public hostname registered in Caddy. Omit for an HTTP catch-all route.
ssl Bool Optional, default false true Requires host; use only after DNS points at the host.
app_port Int32 Optional, default 3000 8000 Positive port matching the app listener inside the container.
healthcheck HealthcheckConfig Optional, default object See below Controls readiness before proxy switch.
path String Optional /admin Exact path and subtree registered in Caddy; the prefix is stripped before proxying.
redirect_hosts Array(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 {#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.

Key Type Required / default Example Rules
path String Optional, default /health /up Must return success from the new app container.
interval Int32 Optional, default 2 2 Seconds between attempts.
timeout Int32 Optional, default 5 5 Per-attempt timeout in seconds.
retries Int32 Optional, default 10 20 Maximum attempts before failing rollout.
probe_image String Optional, default docker.io/library/alpine:3.21 registry.local/probe:3.21 Should ship wget/nc (the probe fails at deploy time otherwise — Meridian does not validate this); useful for mirrors or air-gapped hosts.
required_successes Int32 Optional, default 3 3 Consecutive 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.

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
Key Type Required / default Example Rules
image String Optional, runtime default docker.io/library/caddy:2.11.4-alpine docker.io/library/caddy:2.11.4-alpine Must contain Caddy 2.11.2 or newer.
http_port Int32 Optional, default 80 80 Positive host port; rootless low-port binding must be enabled.
https_port Int32 Optional, default 443 443 Positive 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.

registry:
  server: ghcr.io
  username: deploy
  password:
    - REGISTRY_PASSWORD
Key Type Required / default Example Rules
server String Optional ghcr.io Registry host passed to login.
username String Optional deploy Registry username.
password Array(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.

env:
  clear:
    MARTEN_ENV: production
  secret:
    - SECRET_KEY_BASE
    - DATABASE_URL
Key Type Required / default Example Rules
clear Hash(String, String) Optional, default {} { MARTEN_ENV: production } Written directly into generated Quadlets.
secret Array(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.

ssh:
  user: deploy
  port: 22
  keys:
    - /Users/me/.ssh/id_ed25519
  connect_timeout: 10
  keepalive: true
  keepalive_interval: 30
Key Type Required / default Example Rules
user String Optional, default deploy deploy Remote user for SSH.
port Int32 Optional, default 22 22 SSH port.
keys Array(String) Optional, default [] ["/Users/me/.ssh/id_ed25519"] First key is used as identity file; paths are expanded with home support.
proxy_jump String Optional bastion.example.com Passed through to SSH as a jump host.
connect_timeout Int32 Optional, default 10 10 SSH connection timeout in seconds.
keepalive Bool Optional, default true true Enables SSH server-alive options.
keepalive_interval Int32 Optional, default 30 30 Server-alive interval in seconds.

boot

Controls deploy batching.

boot:
  limit: 1
  wait: 10
Key Type Required / default Example Rules
limit Int32 Optional, default 1 1 Number of hosts released in a batch.
wait Int32 Optional, default 0 10 Seconds to wait between batches.

transfer

Controls how the selected image reaches each host.

transfer:
  mode: stream
Key Type Required / default Example Rules
mode registry, stream, or incremental Required when transfer: is present stream Unknown 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.

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.

Key Type Required / default Example Rules
image String Optional in YAML, required at runtime docker.io/library/postgres:18-alpine Missing image fails when readiness inference or generation needs it.
host String Optional in schema prod-01.example.com Accessory commands need a target host; declare it explicitly.
port String Optional "5432:5432" Used as published port and for default readiness inference.
volumes Array(String) Optional, default [] ["pgdata:/var/lib/postgresql"] Accessory Volume= entries.
env EnvConfig Optional clear: { POSTGRES_USER: app } Same shape as top-level env; official images can consume file-mounted secrets through variables such as POSTGRES_PASSWORD_FILE.
cmd String Optional postgres -c max_connections=200 Container command for the accessory.
network String Optional postgres Podman network the accessory joins. The app automatically joins it too. The older <name>.network form still works and means the same network.
secrets Array(String) Optional, default [] [MY_APP_POSTGRES_PASSWORD] Extra Podman secrets for the accessory.
depends_on String Optional postgres Adds systemd ordering between accessories.
ready AccessoryReadinessConfig Optional See below Explicit readiness probe; otherwise Meridian tries to infer one.

accessories.<name>.ready {#accessory-readiness}

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

Key Type Required / default Example Rules
tcp Int32 or Array(Int32) One of tcp/cmd/http 6379 or [5432, 5433] Normalized to a list; each value must be an integer.
cmd Array(String) One of tcp/cmd/http ["pg_isready", "-U", "app"] Runs inside the accessory with podman exec.
http AccessoryReadinessHTTPConfig One of tcp/cmd/http { path: /health, port: 8080 } Sidecar HTTP GET.
timeout Int32 Optional, default 5 5 Per-probe timeout in seconds.
interval Int32 Optional, default 1 1 Seconds between attempts.
retries Int32 Optional, default 30 30 Maximum attempts before the gate fails.

ready.http fields:

Key Type Required / default Example Rules
path String Optional, default / /health Request path.
port Int32 Required 8080 Port inside the accessory container.

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

Image base name Inferred readiness
postgres cmd: ["pg_isready", "-q"]
redis, valkey, dragonfly, keydb tcp: 6379
mysql, mariadb cmd: ["mysqladmin", "ping", "--silent"]
anything else tcp 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 {#shared-accessories}

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

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:

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:

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:

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:

meridian setup
meridian accessory start postgres
meridian check
meridian deploy

A second application declaring the same PostgreSQL normally needs only:

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.

volumes:
  - my-app-data:/app/data
  - /srv/my-app/config:/app/config:ro
Key Type Required / default Example Rules
volumes Array(String) Optional, default [] my-app-data:/app/data Each string is passed through as a Quadlet Volume= entry.

ports

App container host port publications.

ports:
  - "127.0.0.1:9000:9000"
Key Type Required / default Example Rules
ports Array(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.

hooks:
  pre_deploy: ./scripts/preflight
  post_deploy: ./scripts/notify
  remote:
    before_start:
      - command: bin/manage migrate
        roles: [web]
Key Type Required / default Example Rules
pre_deploy String Optional ./scripts/preflight Runs locally before deploy starts.
post_deploy String Optional ./scripts/notify Runs locally after deploy finishes.
remote RemoteHooksConfig Optional See below Runs on remote hosts during deploy phases.

Remote phases under hooks.remote:

Phase Type Default When it runs
before_transfer Array(RemoteHookConfig) [] Before image transfer.
after_transfer Array(RemoteHookConfig) [] After image transfer.
after_upload Array(RemoteHookConfig) [] After Quadlets/files/assets upload.
before_start Array(RemoteHookConfig) [] After co-network accessories are ready and before asset build/app start.
after_start Array(RemoteHookConfig) [] After starting the new unit.
before_switch Array(RemoteHookConfig) [] Before Caddy atomically switches traffic.
after_switch Array(RemoteHookConfig) [] After the successful Caddy reload (the commit point).
after_deploy Array(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:

Key Type Required / default Example Rules
command String Required bin/manage migrate Command executed remotely.
roles Array(String) Optional [web] Limits the hook to selected roles.

files

Uploads host-side files during deploy.

files:
  - source: deploy/Caddyfile.ecr
    destination: /home/deploy/Caddyfile
    template: true
    roles: [web]
Key Type Required / default Example Rules
source String Required deploy/Caddyfile.ecr Local file path.
destination String Required /home/deploy/Caddyfile Remote path.
template Bool Optional, default false true Render with ECR before upload.
roles Array(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.

assets:
  host: assets.my-app.example.com
  command: bin/manage collectassets --fingerprint --no-input
  output_dir: /app/assets
  retain_releases: 2
  compression: true
Key Type Required / default Example Rules
host String Required assets.my-app.example.com Must resolve to the server before HTTPS issuance.
command String Required bin/manage collectassets --fingerprint --no-input Runs in an app-image one-shot unit without a container network. Disable eager cache or database connections for this command.
output_dir String Required /app/assets Directory copied into the asset release directory.
retain_releases Int32 Optional, default 2 2 Release directories kept on disk. The route serves the newest two; older ones are retained but not reachable.
compression Bool Optional, default true true Emits 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.