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:
| Work | Across replicas |
|---|---|
| Public sites, admin, portal, APIs | Any 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 jobs | Every 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 tasks | Exactly 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 |
| Migrations | Never, 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
| Requirement | Why |
|---|---|
Set server.replicas to the number you run | The 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: true | An 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 replica | Forms, sessions and media addresses signed on one replica are checked on another |
| The same project files and engine version on every replica, outside a rollout | They must agree on the schema; a replica with different configuration refuses to start on drift |
server.trusted_proxies naming the load balancer | So 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 on | Every replica queries and indexes the same service |
The load balancer
- Route to each replica’s
server.listenport; health-check withGET /health?readiness, which answers 503 while a replica starts, warms or shuts down. - Terminate TLS at the load balancer and forward
X-Forwarded-ForandX-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
| Cache | Behaviour |
|---|---|
| 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 |
| Media | Immutable 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
| Limit | Detail |
|---|---|
| Content MCP call limits | Calls 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 tasks | Run 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 mail | Each replica’s mail queue has one worker, paced by mail.rate_limit_per_minute when set; the rate is per replica |
Related
- Upgrades: rolling upgrades across replicas.
- Monitoring: the per-replica and leader metrics.
- engine.yaml: server, cache, jobs
Monitoring
How to watch a running Meridian install, covering the /health liveness and readiness endpoint, the Prometheus metrics listener and its series, OpenTelemetry traces and metrics, Sentry error reporting, the JSON logs, the job queue and scheduler commands, and the doctor support bundle.
Reference
Where to find every command and flag, every configuration file and key, the template API for your project, environment variables, exit codes and error codes, and how to get the same from the binary.