High availability and multi-node deployment

High availability and multi-node deployment

This guide is for administrators of a self-hosted installation who want to run the application on two or more nodes behind a load balancer, so that one node can fail or be updated without an outage.

It covers the reference architecture, what the application needs in order to be safe on several nodes, the load balancer settings, the health endpoint, and a checklist. The procedures for updating nodes, taking one out of the pool and recovering from failures are in .

You need a working single-node installation first (), and a place to run a PostgreSQL server, a Valkey (or Redis-compatible) server and an S3-compatible bucket that is not on one of the app nodes.

1. Reference architecture

                 users
                   |
        app.example.com  ->  203.0.113.10
                   |
          +--------+---------+
          |  load balancer   |   TLS, WebSocket upgrade, health checks
          |  (nginx, HAProxy |
          |   or a cloud LB) |
          +----+--------+----+
               |        |
        +------+--+  +--+------+
        | node-a  |  | node-b  |    identical app nodes, port 3000
        | app     |  | app     |    (add node-c, ... the same way)
        +--+---+--+  +--+---+--+
           |   |        |   |
           |   +---+----+   |
           |       |        |
   +-------+--+ +--+-------+ +----+---------------+
   | Postgres | |  Valkey  |     | S3-compatible   |
   | primary  | |  primary |     | object storage  |
   |   + standby | + replica |   | (uploads)       |
   +----------+ +----------+     +-----------------+

Layer

Role

Notes

Load balancer

One public entry point for app.example.com. Distributes HTTP and WebSocket traffic across the app nodes and stops sending traffic to a node that fails its health check.

Run two balancers (or use a managed one) if the balancer itself must not be a single point of failure.

App nodes

The same build, the same configuration, one process per node on port 3000. Every node serves requests, hosts WebSocket connections and runs background jobs.

Nodes are interchangeable. There is no primary app node.

PostgreSQL

One writable primary. A streaming-replication standby (or a managed database with failover) takes over if the primary is lost.

All nodes must connect to the primary, never to a replica.

Valkey

One writable primary, optionally with a replica. Carries live-collaboration messages, the update log of documents being edited, locks and shared caches.

The application talks to one Valkey address. Sentinel and Cluster modes are not supported.

Object storage

S3-compatible bucket for uploaded files.

The local filesystem provider is per node, so it cannot be used with several nodes.

The application does not need a shared filesystem between nodes, and it has no leader election of its own. Coordination goes through PostgreSQL (job claims, advisory locks) and Valkey (collaboration, slot claims, locks).

2. What the application does on several nodes

These behaviours decide how you configure the cluster.

Concern

Behaviour

What you must do

Sign-in sessions

A session is a signed cookie named session, verified with AUTH_SECRET. No server-side session table.

Use the same AUTH_SECRET on every node. A node with a different secret rejects every cookie issued by the others.

Document collaboration

Each node hosts the live copy of the documents its own WebSocket clients have open. Edits are published through Valkey, and every node applies them to its own copy before forwarding them to its clients. A node that opens a document cold rebuilds it from the PostgreSQL snapshot plus the update log in Valkey. Snapshots are written to PostgreSQL every 200 updates or 10 minutes, under a per-document lock, so only one node writes each snapshot.

Two people editing the same document on different nodes see each other live. No sticky routing is needed for this. Valkey must be reachable from every node at all times.

Spreadsheet co-editing

Live fan-out of spreadsheet operations stays inside one node. Only the "someone is editing" marker is shared.

Send all traffic for the same spreadsheet to the same node (section 5).

MCP connections

MCP sessions are kept in the memory of the node that opened them. A request carrying an unknown session id gets a 404.

Pin /api/mcp traffic per client (section 5).

Background jobs

Every node claims jobs from the background_jobs table with row locking, so exactly one node runs each job. A running job holds a 5-minute lease that its node renews every 60 seconds. If a node dies, another node takes the job over once the lease lapses. On a clean stop the node hands its running jobs back at once. Speech-to-text jobs prefer to resume on the node that started them for 5 minutes.

Nothing to configure. There is no setting that turns job processing off on one node, so size every node for job work.

Scheduled (cron-like) work

Each schedule is tied to shared time slots. All nodes race for the slot in Valkey, the winner takes a PostgreSQL advisory lock, and a per-period claim makes sure the work happens once per period. Two small housekeeping tasks (cross-table statistics and a local traces sweep) run on every node by design.

Nothing to configure. To keep the cluster schedules off a particular node, set SCHEDULERS=none on it, or list the schedule ids it should run. An unknown id stops the node at boot.

Database migrations

Applied once, in order, and recorded in the database. The bundled container entrypoint takes a PostgreSQL advisory lock, so nodes starting together do not interleave.

Run migrations from one place (section 3).

Runtime settings

Values saved under System > Config are stored in the database and also copied into the memory of the node that saved them. Other nodes read them again only when they start. AI provider changes and project portal allowed origins are broadcast to all nodes.

After you change a System > Config value, restart the other nodes one at a time (see the runbook).

Uploaded files

With the Local filesystem provider, files go to a directory on the node that received the upload.

Switch to S3-compatible before adding a second node (section 4.3).

Local files per node

Each node keeps its own diagnostic traces, git cache and import-worker bundles.

Not shared, and not required to be. Do not put them on a shared volume.

Clocks

Schedule slots, job leases and sign-in tokens depend on wall-clock time.

Run time synchronisation (NTP or chrony) on every node, the databases and the balancer.

3. Configuration every app node needs

All nodes get the same values except NODE_ID.

Variable

Value

Why

DATABASE_URL

Address of the PostgreSQL primary, through a name or address that follows the primary after a failover.

A replica is read-only: writes fail.

VALKEY_URL

redis://:<password>@valkey.example.internal:6379

Required. The process refuses to start without it, so a node cannot silently run against the wrong Valkey. It must be in the process environment at start (container environment, systemd unit, or the process manager's environment file option).

PUBLIC_APP_URL

https://app.example.com

Used to build links and to tell the application's own host from customer hosts.

AUTH_SECRET

At least 32 characters, identical on all nodes

Signs sessions and sign-in requests. Changing it signs everyone out.

CONNECTOR_ENCRYPTION_KEY

32 random bytes, base64, identical on all nodes

Encrypts stored connector credentials. If it is not set, the application looks in Valkey and then in a file in the working directory, and generates a key if neither has one. Setting it explicitly on every node removes any dependency on Valkey contents.

NODE_ID

A unique short name per node, for example node-a

Appears in the health response, in job ownership and in the System > Health page. Two nodes with the same value (and the same process id, which is common in containers) can renew each other's job leases.

NEXT_MANUAL_SIG_HANDLE

true

Lets the application finish its own shutdown sequence (section 7). Without it the application logs a warning and the framework exits early.

HEALTH_TOKEN

Optional shared secret

If set, /api/health answers 401 without it (section 6).

SCHEDULERS

Optional: all (default), none, or a comma list

See section 2.

Generate the secrets once and copy them to every node:

openssl rand -base64 48 | tr -d '\n'
openssl rand -base64 32 | tr -d '\n'

The first command gives an AUTH_SECRET, the second a CONNECTOR_ENCRYPTION_KEY.

Careful: do not generate secrets separately on each node, and do not rotate AUTH_SECRET or CONNECTOR_ENCRYPTION_KEY on one node only. Rotating the encryption key without re-entering the credentials makes stored connector credentials unreadable.

Example for a container-based node (Docker Compose). It uses only the application service of the bundled stack: remove the bundled db and valkey services and point the variables at your shared services.

services:
  app:
    image: ${CODEDOCS_IMAGE}
    restart: unless-stopped
    ports:
      - "10.0.0.11:3000:3000"        # private interface only; the load balancer reaches it here
    stop_grace_period: 20s            # the application needs up to 12 s to drain
    environment:
      DATABASE_URL: postgres://app:<password>@db.example.internal:5432/app
      VALKEY_URL: redis://:<password>@valkey.example.internal:6379
      PUBLIC_APP_URL: https://app.example.com
      AUTH_SECRET: ${AUTH_SECRET}
      CONNECTOR_ENCRYPTION_KEY: ${CONNECTOR_ENCRYPTION_KEY}
      NODE_ID: node-a
      NEXT_MANUAL_SIG_HANDLE: "true"
      SKIP_MIGRATIONS: "1"            # migrations are run separately, see below
    volumes:
      - traces:/app/traces
volumes:
  traces:

Migrations. The bundled container entrypoint applies pending migrations on every start unless SKIP_MIGRATIONS=1 is set. Choose one approach and keep it:

•

Let every node migrate on start (leave SKIP_MIGRATIONS unset). Concurrent starts are serialised by an advisory lock, and the second node finds nothing left to do.

•

Or set SKIP_MIGRATIONS=1 on all nodes and run the migrations as an explicit step before starting the new version (see ).

Careful: while the first updated node migrates, the nodes still running the old version keep serving against the new schema. Read the release notes before a rolling update. If a release is marked as incompatible with the previous version, stop all nodes and update them together.

Check: on each node, curl -s http://127.0.0.1:3000/api/health returns "status":"ok" and the node field shows that node's own NODE_ID.

4. Shared services

4.1 PostgreSQL

Use a managed PostgreSQL service with automatic failover if you can. The extensions vector and pg_trgm must be available on it, because the schema needs them.

If you run it yourself, set up one primary and one streaming standby. On the primary, in postgresql.conf:

listen_addresses = '*'
wal_level = replica
max_wal_senders = 10
max_replication_slots = 10
hot_standby = on
max_slot_wal_keep_size = 20GB

Create the replication role and allow the standby to connect (adjust the address and the authentication method to your policy):

CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '<strong password>';
host  replication  replicator  198.51.100.21/32  scram-sha-256

On the standby, with an empty data directory and the same PostgreSQL major version and extension packages as the primary:

pg_basebackup -h db-primary.example.internal -U replicator -D /var/lib/postgresql/data -R -X stream -C -S standby_1

Check on the primary:

SELECT application_name, state, sync_state,
       pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS lag
FROM pg_stat_replication;

Expect state = streaming. Check on the standby:

SELECT pg_is_in_recovery();

Expect t.

Connection budget: each node opens a main pool of 20 connections (PG_POOL_MAX) and a pool of up to 10 for advisory locks (PG_LOCK_POOL_MAX), plus a few small auxiliary connections. If you use a separate Log DB (set under System > Config > Storage & Logs > Log DB configuration), its address must be reachable from every node: never use 127.0.0.1 there. Size max_connections for all nodes together with headroom.

Careful: replication is asynchronous by default. If the primary is lost, the last few committed transactions may not be on the standby. Synchronous replication removes that gap at the cost of waiting for the standby on every commit. Decide on purpose.

Careful: a replication slot makes the primary keep WAL until the standby has read it. A standby that is offline for long fills the primary's disk. max_slot_wal_keep_size caps this; a slot that exceeds the cap is invalidated and the standby must be rebuilt. Monitor the slot and the free disk space ().

Replication protects against losing a server, not against deleted or corrupted data. Backups are a separate duty: .

4.2 Valkey

Run Valkey with persistence enabled (append-only file), as the bundled stack does, and without a key-eviction policy (maxmemory-policy noeviction) so that locks and logs are never evicted. Protect it with a password and keep it off public networks.

A replica is optional and is for fast recovery. In its configuration:

replicaof valkey-primary.example.internal 6379
masterauth <password>
requirepass <password>

Check on the primary:

valkey-cli -h valkey-primary.example.internal -a "$VK_PASS" --no-auth-warning INFO replication

Expect role:master and connected_slaves:1. Check on the replica:

valkey-cli -h valkey-replica.example.internal -a "$VK_PASS" --no-auth-warning INFO replication

Expect role:slave and master_link_status:up.

Careful: replication copies stored data but not published messages. A subscriber receives live collaboration messages only from the Valkey it is connected to. Applications must always connect to the current primary, never to a replica.

4.3 Object storage

1.

Create a bucket and an access key for the application.

2.

Sign in as a platform administrator and open System > Config > tab Storage & Logs > Storage.

3.

Choose S3-compatible, fill in the endpoint (blank means AWS), region, bucket, access key ID and secret access key. Tick Force path-style addressing for MinIO and similar services.

4.

Click Test Connection, then Save.

5.

Restart the other nodes (the setting is cached in memory per node, see section 2).

Check: upload an image in a project and open the same image URL through each node's own address (port 3000). Both return the image.

Switching the provider affects new uploads only. To move existing files, use the Migrate storage panel on the same tab: copy, verify, then switch over.

Careful: do not add a second node while Local filesystem is active. A file uploaded through one node would be missing on the other.

5. Load balancer

The balancer must:

•

terminate TLS for app.example.com (and for any wildcard or customer host you serve);

•

pass the original Host header unchanged: the application routes custom domains and subdomains by it;

•

pass WebSocket upgrades, and keep idle WebSocket and event-stream connections open for a long time;

•

stop sending traffic to a node that fails its health check, and retry the request on another node.

Which routes need which kind of routing:

Route

Routing

Reason

Everything else

Any node. Affinity by the session cookie is recommended, not required.

Keeps one user's page and asset requests on one node, which helps during a rolling update when two versions run side by side.

/api/projects/<id>/documents/<id>/sheet-collab

The same node for the same URL.

Spreadsheet operations are broadcast inside one node only.

/api/mcp

The same node for the same client (or primary with backup).

MCP sessions live in one node's memory.

Example nginx configuration. Replace the addresses with your nodes' private addresses. Keep the server lists identical on every balancer: the hash-based methods give different answers if the lists differ.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      "";
}

upstream app_pool {
    hash $cookie_session consistent;
    server 10.0.0.11:3000 max_fails=1 fail_timeout=10s;   # node-a
    server 10.0.0.12:3000 max_fails=1 fail_timeout=10s;   # node-b
    keepalive 32;
}

upstream app_sheet {
    hash $uri consistent;
    server 10.0.0.11:3000 max_fails=1 fail_timeout=10s;
    server 10.0.0.12:3000 max_fails=1 fail_timeout=10s;
    keepalive 32;
}

upstream app_mcp {
    ip_hash;
    server 10.0.0.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:3000 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;
    # ssl_certificate / ssl_certificate_key ...

    client_max_body_size 50m;
    proxy_connect_timeout 2s;
    proxy_read_timeout 86400s;
    proxy_send_timeout 86400s;
    proxy_next_upstream error timeout http_502 http_503 http_504;

    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_buffering off;

    location ~ ^/api/projects/[^/]+/documents/[^/]+/sheet-collab$ {
        proxy_pass http://app_sheet;
    }

    location /api/mcp {
        proxy_pass http://app_mcp;
    }

    location / {
        proxy_pass http://app_pool;
    }
}

Notes on these settings:

•

proxy_connect_timeout 2s lets the balancer notice a node whose network has stalled. With a long default, clients give up first, the balancer sees an aborted client instead of a failed upstream, and the dead node stays in the pool.

•

proxy_next_upstream retries on another node when a node refuses connections, times out, or answers 502, 503 or 504. A node that is shutting down closes its listener, so new connections to it are refused and go to the other node. By default nginx retries only requests that are safe to repeat (GET, HEAD) once they have been sent upstream; leave it that way unless you know a POST is safe to repeat.

•

Connection is sent empty for ordinary requests, which is what lets keepalive reuse upstream connections.

•

Open-source nginx has only passive health checks (max_fails). If you need active checks, use a balancer that supports them (for example HAProxy with option httpchk GET /api/health) or a managed balancer, and see section 6 for the response codes.

•

When you take a node out of the pool for maintenance, mark its server lines with down in every upstream block, then run nginx -t and reload. With the hash methods only that node's users move.

Careful: if you run two balancers, give them the same configuration with the same node list. A balancer that lists a node as itself plus a public address, instead of the other node, quietly sends everything to one node.

Check: add $upstream_addr to the balancer's log_format, send a few requests with different session cookie values, and confirm in the access log that they are spread over both nodes while one cookie value always goes to the same node. Requests to /api/health through the public name come back with the node field of whichever node served them (when HEALTH_TOKEN is set, send the token).

6. Health endpoint

GET /api/health is the single health check for balancers and monitoring. It runs a database SELECT 1, a Valkey PING and a connection-pool check, each with a 3-second limit, so it answers even when a dependency hangs.

Status

Body status

Meaning

200

ok

Database and Valkey answered and the connection pool is healthy.

503

degraded

A check failed. The checks object names which one (db, valkey, pool).

503

draining

The node is shutting down. It is finishing work and should receive no new traffic.

401

none

HEALTH_TOKEN is set and the request did not carry it.

Example healthy body (shortened):

{
  "status": "ok",
  "node": "node-a",
  "pid": 7,
  "uptime_s": 30532,
  "checks": { "db": "ok", "valkey": "ok", "pool": "ok" },
  "version": { "sha": "a1b2c3d", "buildTime": "2026-01-01T00:00:00Z", "buildId": "..." }
}

version.buildId differs on every build. Compare it across nodes after an update to prove that every node runs the same build. version.sha and version.buildTime are filled in only if you pass BUILD_SHA and BUILD_TIME when you build the image; otherwise they read unknown.

The token can be sent as an X-Health-Token header, as Authorization: Bearer <token>, or as a ?token= query parameter. Prefer the header: a query parameter is written to the balancer's access log on every probe.

Careful: the container image of the bundled stack has its own health check that calls /api/health without a token. If you set HEALTH_TOKEN, that check gets 401 and the container is reported unhealthy. Override the container health check to send the header, and give your balancer's health checks the token too. If you cannot send a header, leave HEALTH_TOKEN unset and restrict access to port 3000 by firewall instead.

Point the balancer at each node's own address and port 3000, not at the public name. Alerts and dashboards for the same endpoint are described in .

Besides the endpoint, System > Health > Jobs shows each background schedule, whether it runs once per cluster or on every node, and which node ran it last. After you add a node, it should appear there within a few minutes.

7. How a node stops

When the process receives SIGTERM or SIGINT (a container stop, a service stop, a process-manager restart) it drains instead of dropping its work. The whole sequence has a 12-second limit:

1.

Stop intake: /api/health answers 503 draining, the HTTP listener closes, schedules stop, and the node stops claiming jobs.

2.

Save in-memory state: every hosted document is flushed to Valkey and saved to PostgreSQL, its WebSockets are closed with code 1001 so editors reconnect to another node, pending recomputations run, and running jobs get 3 seconds to finish before they are returned to the queue.

3.

Close the remaining connections.

4.

Close the Valkey and PostgreSQL clients and exit.

A second signal, or running out of time, exits immediately. For this to work your process manager must wait longer than 12 seconds before it kills the process (stop_grace_period: 20s in Compose, TimeoutStopSec=20 in systemd, kill_timeout: 15000 in PM2) and NEXT_MANUAL_SIG_HANDLE=true must be set. A process killed without the drain loses unsaved edits made since the last flush and leaves its jobs waiting for the 5-minute lease to lapse.

The application also stops itself if its event loop is blocked for more than 5 seconds (a built-in watchdog; the first 60 seconds after start are excused). Your process manager must restart it automatically (restart: unless-stopped, Restart=always).

8. Checklist

Before going live with more than one node:

•

PostgreSQL has a standby or managed failover; all nodes use the primary's address; vector and pg_trgm are available.

•

Valkey is reachable from every node, has a password and persistence, and no eviction policy.

•

Storage provider is S3-compatible; Test Connection passed; new uploads open through every node.

•

AUTH_SECRET and CONNECTOR_ENCRYPTION_KEY are identical on every node and stored in your secret store.

•

NODE_ID is unique on every node; NEXT_MANUAL_SIG_HANDLE=true is set; the process manager waits at least 20 seconds on stop and restarts the process on exit.

•

Migrations run from one place; the release notes were read.

•

The balancer passes Host, WebSocket upgrades and long timeouts; routes sheet-collab and /api/mcp as in section 5; has the same node list on every balancer.

•

Health checks hit each node on port 3000; if HEALTH_TOKEN is set, both the balancer and the container health check send it.

•

Port 3000 of the nodes is reachable only from the balancer (and from monitoring); PostgreSQL and Valkey ports only from the nodes and replicas.

•

Time synchronisation is running everywhere.

•

Backups of PostgreSQL and of the bucket are scheduled and a restore was tested ().

•

Monitoring covers each node's /api/health, replication lag, replication-slot disk use and certificate expiry ().

•

You rehearsed stopping one node under load and a database failover on a copy of the setup ().

Check: sign in as two different users in two browsers and open the same document. Use the balancer's access log (with $upstream_addr) to see which node each browser's WebSocket went to, and repeat with other users until the two are on different nodes. Type in one browser: the edit appears in the other within a second. Then stop one node as described in the runbook: the page keeps working and the editors reconnect.