postgres cluster with nix + pgactive | home

WARN: I’m not a database expert, and I’ve never seriously run a database in production.

Running a PostgreSQL cluster is surprisingly confusing given how popular postgres is. Searching for “postgres replication”, “postgres cluster”, and so on turns up a huge number of solutions, and most of them are either overly complex or possibly unmaintained.

For my personal apps, I needed a postgres cluster setup that met this requirements:

In the end I built it on top of AWS’s pgactive, and I’m quite happy with the result.

postgres setup

First, set up postgres. All of my servers run nixos, so I run postgres on top of it.

Nixos provides the services.postgresql module. But once you depend on the system’s nixpkgs, the cache doesn’t behave correctly when updates happen, and pinning versions becomes a headache especially if you use pg extensions. Running multiple postgres instances isn’t possible either, so I concluded that using containers is the better approach.

The image is built from a nix flake with the required extensions (pgvector, pg_rational, timescaledb, and pgactive) and the necessary configuration added.

In the end, all you have to do is set up the following oci-containers on each node.

virtualisation.oci-containers = {
  containers.pg = {
    image = "ghcr.io/aca/containers/postgres-18:latest-arm64";
    extraOptions = [ "--network=host" ];
    volumes = [ "/var/lib/pg:/var/lib/postgresql/data" ];
  };
};

pgactive

With a postgres container running on each of the oci-xnzm-a1 and oci-impx-a1 nodes,

set up the node that will act as the seed (oci-xnzm-a1).

ssh root@oci-xnzm-a1 'podman exec -i pg psql -v ON_ERROR_STOP=1 -U postgres -d postgres' <<'SQL'
CREATE EXTENSION IF NOT EXISTS pgactive;

CREATE SERVER "oci-xnzm-a1" FOREIGN DATA WRAPPER pgactive_fdw
  OPTIONS (host 'oci-xnzm-a1', port '5432', dbname 'postgres');
CREATE USER MAPPING FOR postgres SERVER "oci-xnzm-a1" OPTIONS (user 'postgres');

CREATE SERVER "oci-impx-a1" FOREIGN DATA WRAPPER pgactive_fdw
  OPTIONS (host 'oci-impx-a1', port '5432', dbname 'postgres');
CREATE USER MAPPING FOR postgres SERVER "oci-impx-a1" OPTIONS (user 'postgres');

SELECT pgactive.pgactive_create_group(
  node_name := 'pg-oci-xnzm-a1',
  node_dsn  := 'user_mapping=postgres pgactive_foreign_server=oci-xnzm-a1'
);
SELECT pgactive.pgactive_wait_for_node_ready();
SELECT node_name, node_status FROM pgactive.pgactive_nodes ORDER BY node_name;
SQL

On oci-impx-a1, connect to the seed (oci-xnzm-a1) with join_using_dsn to pull the entire DB (existing data at join time gets replicated).

ssh root@oci-impx-a1 'podman exec -i pg psql -v ON_ERROR_STOP=1 -U postgres -d postgres' <<'SQL'
CREATE EXTENSION IF NOT EXISTS pgactive;

CREATE SERVER "oci-xnzm-a1" FOREIGN DATA WRAPPER pgactive_fdw
  OPTIONS (host 'oci-xnzm-a1', port '5432', dbname 'postgres');
CREATE USER MAPPING FOR postgres SERVER "oci-xnzm-a1" OPTIONS (user 'postgres');

CREATE SERVER "oci-impx-a1" FOREIGN DATA WRAPPER pgactive_fdw
  OPTIONS (host 'oci-impx-a1', port '5432', dbname 'postgres');
CREATE USER MAPPING FOR postgres SERVER "oci-impx-a1" OPTIONS (user 'postgres');

SELECT pgactive.pgactive_join_group(
  node_name      := 'pg-oci-impx-a1',
  node_dsn       := 'user_mapping=postgres pgactive_foreign_server=oci-impx-a1',
  join_using_dsn := 'user_mapping=postgres pgactive_foreign_server=oci-xnzm-a1'
);
SELECT pgactive.pgactive_wait_for_node_ready();
SELECT node_name, node_status FROM pgactive.pgactive_nodes ORDER BY node_name;
SQL

checking replication

psql "postgres://postgres@oci-impx-a1:5432/postgres" -c "CREATE TABLE xxx (id SERIAL PRIMARY KEY, name TEXT); INSERT INTO xxx (name) VALUES ('alice'); SELECT * FROM xxx;"
CREATE TABLE
INSERT 0 1
 id | name
----+-------
  1 | alice
(1 row)

$ psql "postgres://postgres@oci-xnzm-a1:5432/postgres" -c 'select * from xxx'
 id | name
----+-------
  1 | alice
(1 row)

$ psql "postgres://postgres@oci-xnzm-a1:5432/postgres" -c "INSERT INTO xxx (name) VALUES ('john');"
INSERT 0 1

$ psql "postgres://postgres@oci-impx-a1:5432/postgres" -c 'select * from xxx'
 id | name
----+-------
  1 | alice
  2 | john

While I haven’t exhausted every possible configuration, it has proven reliable for my purposes. If your needs align with mine, I’d consider this setup is worth a try.