diff --git a/postgresql/postgresql.yaml b/postgresql/postgresql.yaml new file mode 100644 index 0000000..3355f2a --- /dev/null +++ b/postgresql/postgresql.yaml @@ -0,0 +1,252 @@ +# ───────────────────────────────────────────────────────────────────────── +# 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