# ───────────────────────────────────────────────────────────────────────── # postgresql/postgresql.yaml — Patroni + etcd + HAProxy HA stack (ADR-0001) # BOOTSTRAP TIER — manual deploy only via: # bash /volume1/docker/compose-files/deploy/stack-deploy.sh postgresql # # Env var names for Spilo/Patroni verified against upstream source # (zalando/spilo ENVIRONMENT.rst, configure_spilo.py, launch.sh) and the # Patroni ENVIRONMENT docs — NOT guessed. Spilo has no native Docker-secret # _FILE suffix support, so patroni-0/patroni-1 use a command wrapper to # read secrets from /run/secrets and export them as the plain env vars # Spilo's configure_spilo.py actually expects, before invoking the image's # real entrypoint chain (/launch.sh init). # ───────────────────────────────────────────────────────────────────────── version: "3.6" services: # ── etcd (3-node Raft quorum — NOT data replicas, ~150MB RAM total) ────── etcd-1: image: quay.io/coreos/etcd:v3.5.9 hostname: etcd-1 command: - etcd - --name=etcd-1 - --data-dir=/etcd-data - --initial-advertise-peer-urls=http://etcd-1:2380 - --listen-peer-urls=http://0.0.0.0:2380 - --listen-client-urls=http://0.0.0.0:2379 - --advertise-client-urls=http://etcd-1:2379 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 - --initial-cluster-state=new - --initial-cluster-token=postgresql-ha-etcd volumes: - /volume1/docker/PostgreSQL/etcd-1-data:/etcd-data networks: - postgresql_db-backend deploy: placement: constraints: - node.hostname == docker-1 etcd-2: image: quay.io/coreos/etcd:v3.5.9 hostname: etcd-2 command: - etcd - --name=etcd-2 - --data-dir=/etcd-data - --initial-advertise-peer-urls=http://etcd-2:2380 - --listen-peer-urls=http://0.0.0.0:2380 - --listen-client-urls=http://0.0.0.0:2379 - --advertise-client-urls=http://etcd-2:2379 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 - --initial-cluster-state=new - --initial-cluster-token=postgresql-ha-etcd volumes: - /volume1/docker/PostgreSQL/etcd-2-data:/etcd-data networks: - postgresql_db-backend deploy: placement: constraints: - node.hostname == docker-2 etcd-3: image: quay.io/coreos/etcd:v3.5.9 hostname: etcd-3 command: - etcd - --name=etcd-3 - --data-dir=/etcd-data - --initial-advertise-peer-urls=http://etcd-3:2380 - --listen-peer-urls=http://0.0.0.0:2380 - --listen-client-urls=http://0.0.0.0:2379 - --advertise-client-urls=http://etcd-3:2379 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 - --initial-cluster-state=new - --initial-cluster-token=postgresql-ha-etcd volumes: - /volume1/docker/PostgreSQL/etcd-3-data:/etcd-data networks: - postgresql_db-backend deploy: placement: constraints: - node.hostname == docker-3 # ── Patroni / Spilo data replicas (2 total, per user constraint) ──────── # Image tag pinned to 4.0-p3 (Zalando postgres-operator's own referenced # default at time of writing) — re-verify against current releases # before actual deploy, as patch tags move. patroni-0: image: ghcr.io/zalando/spilo-17:4.0-p3 hostname: patroni-0 command: - /bin/sh - -c - | export PGPASSWORD_SUPERUSER="$(cat /run/secrets/postgresql_password)" export PGPASSWORD_STANDBY="$(cat /run/secrets/postgresql_replication_password)" export PATRONI_RESTAPI_PASSWORD="$(cat /run/secrets/postgresql_patroni_password)" exec /bin/sh /launch.sh init environment: SCOPE: postgres-ha PATRONI_NAME: patroni-0 ETCD_HOSTS: '"etcd-1:2379","etcd-2:2379","etcd-3:2379"' PGUSER_STANDBY: standby PATRONI_RESTAPI_USERNAME: patroni PGROOT: /home/postgres/pgdata/pgroot # CLONE_* vars intentionally omitted here — enabled only at cutover # (Phase 3) to seed from the existing 41GB single instance via # pg_basebackup. See ADR-0001 note before cutover. secrets: - postgresql_password - postgresql_replication_password - postgresql_patroni_password volumes: - /volume1/docker/PostgreSQL/patroni-0-data:/home/postgres/pgdata networks: - postgresql_db-backend deploy: placement: constraints: - node.labels.pg-role == primary patroni-1: image: ghcr.io/zalando/spilo-17:4.0-p3 hostname: patroni-1 command: - /bin/sh - -c - | export PGPASSWORD_SUPERUSER="$(cat /run/secrets/postgresql_password)" export PGPASSWORD_STANDBY="$(cat /run/secrets/postgresql_replication_password)" export PATRONI_RESTAPI_PASSWORD="$(cat /run/secrets/postgresql_patroni_password)" exec /bin/sh /launch.sh init environment: SCOPE: postgres-ha PATRONI_NAME: patroni-1 ETCD_HOSTS: '"etcd-1:2379","etcd-2:2379","etcd-3:2379"' PGUSER_STANDBY: standby PATRONI_RESTAPI_USERNAME: patroni PGROOT: /home/postgres/pgdata/pgroot secrets: - postgresql_password - postgresql_replication_password - postgresql_patroni_password volumes: - /volume1/docker/PostgreSQL/patroni-1-data:/home/postgres/pgdata networks: - postgresql_db-backend deploy: placement: constraints: - node.labels.pg-role == replica # ── HAProxy — TCP routing via Patroni REST /primary health check only ─── # No placement constraint: all 3 nodes are managers (node.role == worker # matches nothing in this cluster). mode: global + no constraint = one # replica per node (docker-1/2/3), matching ADR's "HAProxy global, 3x". haproxy: image: haproxy:2.9-alpine hostname: haproxy volumes: - /volume1/docker/compose-files/postgresql/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro networks: postgresql_db-backend: aliases: - postgresql - db authentik_backend: {} ports: - 5430:5432/tcp deploy: mode: global labels: - traefik.enable=true - traefik.tcp.routers.postgres.entrypoints=postgresql - traefik.tcp.routers.postgres.rule=HostSNI(`*`) - traefik.tcp.services.postgres.loadbalancer.server.port=5432 - traefik.tcp.routers.postgres.service=postgres - traefik.swarm.network=traefik_backend # ── Unchanged from current flat postgresql.yaml ────────────────────────── databasus: hostname: databasus image: databasus/databasus:latest networks: - traefik_backend - postgresql_db-backend volumes: - /volume1/docker/databasus:/databasus-data deploy: labels: - traefik.enable=true - traefik.http.routers.databasus.rule=Host(`${DATABASUS_HOST}`) - traefik.http.routers.databasus.tls=true - traefik.http.routers.databasus.tls.certresolver=letsencrypt - traefik.http.routers.databasus.entrypoints=websecure - traefik.http.services.databasus.loadbalancer.server.port=4005 - traefik.swarm.network=traefik_backend pgadmin: image: dpage/pgadmin4:latest environment: PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/postgresql_pgadmin_password PGADMIN_LISTEN_PORT: 80 secrets: - postgresql_pgadmin_password volumes: - "/volume1/docker/PostgreSQL Admin:/var/lib/pgadmin" ports: - 3030:80 networks: - postgresql_db-backend - traefik_backend deploy: labels: - traefik.enable=true - traefik.http.routers.pgadmin.rule=Host(`${PGADMIN_HOST}`) - traefik.http.routers.pgadmin.tls=true - traefik.http.routers.pgadmin.tls.certresolver=letsencrypt - traefik.http.routers.pgadmin.entrypoints=websecure - traefik.http.services.pgadmin.loadbalancer.server.port=80 - traefik.swarm.network=traefik_backend resources: limits: cpus: '1' memory: 512M networks: postgresql_db-backend: name: postgresql_db-backend attachable: true driver: overlay driver_opts: com.docker.network.driver.mtu: "8950" # preserve — MTU mismatch previously broke Vaultwarden/Immich traefik_backend: external: true authentik_backend: external: true secrets: postgresql_password: external: true postgresql_pgadmin_password: external: true postgresql_replication_password: external: true postgresql_patroni_password: external: true