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.
| 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. |
Names every generated app unit, service network, runtime-state directory, and proxy registration.
service: my-appValidation: service must match ^[a-zA-Z][a-zA-Z0-9_-]*$.
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.shSupported 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.
The default image for every managed role.
image: ghcr.io/acme/my-app:latestRoles 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.
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.
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>-blueand<service>-greenunits 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 writeactive-colororrelease-state.json.
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. |
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.
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 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.
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 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. |
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. |
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 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 neededThe 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. |
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.
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/dataNextcloud, independently:
service: nextcloud
accessories:
postgres:
image: docker.io/library/postgres:18-alpine
host: server.example.com
network: postgres
volumes:
- postgres-data:/var/lib/postgresql/dataBoth 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=postgresYou 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 postgresstart 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 deployA second application declaring the same PostgreSQL normally needs only:
meridian setup
meridian check
meridian deployStopping 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.
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. |
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 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. |
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. |
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.