Skip to content

High Availability Cluster with Docker Compose

This page runs one primary and two standbys from the published image ghcr.io/heliosdatabase/heliosdb-nano with Docker Compose on a Linux host. For how replication behaves, the sync modes and the current limits, read the High Availability Hands-On Tutorial first; this page covers only what is specific to containers.

Prerequisites

  • Docker Engine with the Compose plugin (docker compose).
  • psql on the host.

Step 1: Create the replication token

Terminal window
mkdir -p ~/nano-ha-docker && cd ~/nano-ha-docker
head -c 32 /dev/urandom > replication.token
chmod 600 replication.token
# The image runs as uid 999; the token must be owned by that user.
sudo chown 999:999 replication.token

The token authenticates standbys to the primary. It must be a regular file with exactly 32 bytes, mode 0600 or stricter, owned by the user that runs the server.

Step 2: Write the Compose file

Create docker-compose.yml:

x-image: &image ghcr.io/heliosdatabase/heliosdb-nano:latest # pin a release tag in production
x-health: &health
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 5s
timeout: 3s
retries: 12
services:
primary:
image: *image
command:
- start
- --replication-role
- primary
- --replication-port
- "5433"
- --sync-mode
- semi-sync
- --replication-token-file
- /run/secrets/replication.token
- --allow-insecure-replication-remote
environment:
HELIOSDB_PASSWORD: change-me
volumes:
- primary-data:/data
- ./replication.token:/run/secrets/replication.token:ro
ports:
- "127.0.0.1:15432:5432"
healthcheck: *health
standby1:
image: *image
entrypoint: ["heliosdb-nano"]
command:
- start
- --data-dir
- /data/standby
- --listen
- 0.0.0.0
- --auth
- scram-sha-256
- --replication-role
- standby
- --primary-host
- primary:5433
- --sync-mode
- semi-sync
- --replication-token-file
- /run/secrets/replication.token
environment:
HELIOSDB_PASSWORD: change-me
volumes:
- standby1-data:/data
- ./replication.token:/run/secrets/replication.token:ro
ports:
- "127.0.0.1:15442:5432"
healthcheck: *health
depends_on:
primary:
condition: service_healthy
standby2:
image: *image
entrypoint: ["heliosdb-nano"]
command:
- start
- --data-dir
- /data/standby
- --listen
- 0.0.0.0
- --auth
- scram-sha-256
- --replication-role
- standby
- --primary-host
- primary:5433
- --sync-mode
- semi-sync
- --replication-token-file
- /run/secrets/replication.token
environment:
HELIOSDB_PASSWORD: change-me
volumes:
- standby2-data:/data
- ./replication.token:/run/secrets/replication.token:ro
ports:
- "127.0.0.1:15452:5432"
healthcheck: *health
depends_on:
primary:
condition: service_healthy
volumes:
primary-data:
standby1-data:
standby2-data:

Notes on this file:

  • Primary. The image entrypoint adds --data-dir /data, --listen 0.0.0.0, --auth scram-sha-256 (from HELIOSDB_PASSWORD), a loopback-only HTTP listener and a self-signed TLS certificate in /data/tls. The replication listener binds 0.0.0.0:5433 inside the container network.
  • Plaintext replication. Because the replication listener is not on loopback, the primary refuses a token unless --allow-insecure-replication-remote is set: the token and the WAL cross the Docker network unencrypted. Keep port 5433 unpublished (there is no ports: entry for it), use this only on a private network or a protected tunnel, and store the password in a Docker secret with HELIOSDB_PASSWORD_FILE outside of tests. Encryption of the replication channel is planned.
  • Standbys. A standby refuses a data directory that already exists, and the image entrypoint creates the data directory before it starts the server. The standbys therefore run the binary directly (entrypoint: ["heliosdb-nano"]) and point --data-dir at /data/standby, which the standby creates on its first start. Without the entrypoint there is no generated TLS certificate; add --tls-cert and --tls-key to the standby command to offer TLS.
  • Health checks. A standby starts its HTTP listener only after it has caught up with the primary, so service_healthy on a standby means it is serving reads. The shorter interval here replaces the image default of 30 seconds.

Step 3: Start the cluster

Terminal window
docker compose up -d
docker compose ps

The standbys start once the primary is healthy. Start all three together, before writing data: a standby added later to a primary that already holds data is refused with full resync required (re-seeding is planned).

Step 4: Verify replication

Terminal window
export PGPASSWORD=change-me
psql -h 127.0.0.1 -p 15432 -U postgres -d postgres <<'SQL'
CREATE TABLE orders (id INT PRIMARY KEY, item TEXT, qty INT);
INSERT INTO orders VALUES (1, 'widget', 3), (2, 'gadget', 5);
SELECT node_id, address, state, flush_lsn, apply_lsn, lag_bytes FROM pg_replication_standbys;
SQL
psql -h 127.0.0.1 -p 15442 -U postgres -d postgres -c "SELECT * FROM orders ORDER BY id;"
psql -h 127.0.0.1 -p 15452 -U postgres -d postgres -c "SELECT * FROM orders ORDER BY id;"
# A write on a standby is refused (SQLSTATE 25006)
psql -h 127.0.0.1 -p 15442 -U postgres -d postgres -c "INSERT INTO orders VALUES (3, 'gizmo', 1);"

Step 5: Logs and restarts

Terminal window
docker compose logs -f primary
docker compose logs standby1 | grep -i -E "history|caught up|resync|reconnect"
# Restart one standby; it resumes from its stored position
docker compose restart standby2

A restarted standby resumes only while the primary still holds the WAL it is missing; see Restart a standby. When the primary stops, the standbys keep serving reads and keep retrying the connection; writes wait for the primary to come back. Promotion and automatic failover are planned.

Step 6: Clean up

Terminal window
docker compose down # keep the volumes
docker compose down -v # remove the volumes as well

After down -v the next up -d creates a new primary history and new standby directories.