Meridian handbook
Deploying

Scaling

Running more than one engine replica behind a load balancer, covering what each replica does, the scheduler lease, shared storage, caches across replicas, background job workers, database connections and the page cache.

One engine process serves a typical destination site comfortably. Run more when you need to survive a machine failing, deploy without a gap, or serve more traffic than one process can. Replicas are identical engine processes against the same database, the same media store and the same search backend, behind a load balancer.

server:
  replicas: 3
  trusted_proxies: ["10.0.0.0/16"]   # the load balancer's addresses
  security:
    signing_key: ${SIGNING_KEY}      # the same on every replica

storage:
  driver: s3
  s3:
    bucket: ${S3_BUCKET}
    region: ${S3_REGION}
    access_key_id: ${S3_ACCESS_KEY_ID}
    secret_access_key: ${S3_SECRET_ACCESS_KEY}

What every replica does

Every engine-dmo serve process does everything:

WorkAcross replicas
Public sites, admin, portal, APIsAny replica answers any request. Sessions and rate-limit counters live in the database and CSRF tokens are signed with the shared key, so no sticky sessions are needed
Background jobsEvery replica takes jobs from the shared queue in the database. jobs.workers (default 4) caps how many run at once on each replica, across all queues; jobs.queues.<queue>.workers caps one queue
Scheduled tasksExactly one replica, the one holding the scheduler lease in the database, runs them. If it stops, another takes the lease within one heartbeat. meridian_scheduler_leader is 1 on the holder
MigrationsNever, unless migrate.on_boot: apply. Run migrate up once as a deploy step

engine-dmo serve --role accepts web, worker or all, but in this release every value runs everything: there is no way yet to run a process that only serves or only works jobs. Size every replica for both.

Requirements for more than one replica

RequirementWhy
Set server.replicas to the number you runThe configuration check uses it: filesystem storage on local disk is refused with more than one replica
Shared media storage: storage.driver: s3, or a filesystem every replica mounts with storage.filesystem.shared: trueAn upload landing on one replica’s disk would be missing on the others
The same server.security.signing_key (and media.signing_key, if set) on every replicaForms, sessions and media addresses signed on one replica are checked on another
The same project files and engine version on every replica, outside a rolloutThey must agree on the schema; a replica with different configuration refuses to start on drift
server.trusted_proxies naming the load balancerSo client addresses (X-Forwarded-For) and the scheme (X-Forwarded-Proto) are believed, for rate limits, analytics and absolute URLs
A shared search backend, if search is onEvery replica queries and indexes the same service

The load balancer

  • Route to each replica’s server.listen port; health-check with GET /health?readiness, which answers 503 while a replica starts, warms or shuts down.
  • Terminate TLS at the load balancer and forward X-Forwarded-For and X-Forwarded-Proto.
  • Give in-flight requests time to finish: a stopping replica reports not ready at once and finishes what it has for up to server.shutdown_grace (30 seconds). Set the load balancer’s deregistration delay to at least that.
  • Allow request bodies up to media.max_upload_bytes (50MB by default) for uploads.

Caches across replicas

CacheBehaviour
The in-process cache (resolved lists, blocks, navigation and other derived data)Held separately by each replica, at most cache.max_entries entries for cache.page_ttl. A publish or other change writes an invalidation to the database, and every replica polls for them every cache.invalidation_poll (2 seconds by default), so a change shows on every replica within a few seconds
The page cache (cache.pages.enabled: true, off by default)Whole public pages for anonymous visitors, held by each replica in memory up to cache.pages.max_memory, served stale while one render replaces them. Each replica renders cache.pages.warm (the paths listed, plus each site’s most visited pages) before it reports ready, for at most cache.pages.warm.deadline
MediaImmutable addresses; put a CDN in front (see Storage and media)

A page cache warmed on every replica at boot slows each replica’s start by up to the warm deadline; the readiness check holds traffic back meanwhile. After a deploy, engine-dmo warm --origin http://<replica>:8080 requests the warm list against one replica directly with each page’s own host.

Database connections

Each replica opens up to database.max_connections (default 20). Keep the total, plus a few for one-off commands, under the server’s limit:

replicas × database.max_connections + headroom ≤ PostgreSQL max_connections
3 × 20 + 10 = 70 ≤ 100

Watch meridian_db_pool_acquired_connections against meridian_db_pool_max_connections and meridian_db_pool_empty_acquire_wait_seconds_total; raise max_connections (and the server’s limit) when requests wait for connections. Use session pooling if a pooler sits between the engine and PostgreSQL.

What does not scale out

LimitDetail
Content MCP call limitsCalls a minute are counted per replica, so a client spread across replicas can make up to replicas × the configured rate. Write limits are counted in the database and hold across replicas
Scheduled tasksRun on one replica at a time, one task at a time. A slow task delays the next; meridian_scheduler_task_success_age_seconds shows it
Transactional mailEach replica’s mail queue has one worker, paced by mail.rate_limit_per_minute when set; the rate is per replica