networks: nginx-bridge: external: true services: postgresql: # pgvector image = stock postgres:17 plus the `vector` extension compiled in. Same # PostgreSQL 17.10 and same data directory format, so this is a drop-in swap with no # dump/restore needed. # # ⚠️ THE TAG MUST STAY -trixie. This is not cosmetic. # # The plain `pgvector/pgvector:pg17` tag is built on Debian 12 (bookworm, glibc 2.36). # The stock `postgres:17` this cluster was created under is Debian 13 (trixie, # glibc 2.41). glibc supplies the collation used to order every text index, and it is # NOT guaranteed stable across versions. Starting on bookworm made all 14 databases # report: # WARNING: database "npm" has a collation version mismatch # DETAIL: created using collation version 2.41, but the OS provides version 2.36 # An index built under one collation and read under another can silently return wrong # results — missed rows in range scans, duplicate values slipping past unique # constraints. It does not error; it just quietly answers incorrectly. # # Using the -trixie build keeps glibc at 2.41, matching how the data was written, so # no REINDEX is required. If you ever must move to a different base, the correct # procedure is: REINDEX DATABASE , then ALTER DATABASE REFRESH COLLATION # VERSION — not simply silencing the warning. # # The extension is AVAILABLE but not enabled anywhere by default. To use it in a # database, enable it per-database (it is not cluster-wide): # podman exec postgresql psql -U npm -d -c 'CREATE EXTENSION vector;' # # ⚠️ Do NOT let watchtower update this to a different pg major. It is already in # WATCHTOWER_DISABLE_CONTAINERS, which is what keeps that from happening. image: pgvector/pgvector:pg17-trixie container_name: postgresql # Rootless: maps host uid 1000 (gib) -> container uid 999 (postgres), so ./pg_data # stays gib-owned on the host. Postgres' entrypoint supports running as non-root # provided the data dir ownership matches, which this guarantees. userns_mode: "keep-id:uid=999,gid=999" hostname: postgresql domainname: pg.gbrown.org networks: - nginx-bridge # ⚠️ WIREGUARD ADDRESS ONLY. This was previously '5432:5432', which bound the # database to every interface including the public one — the only thing preventing # the internet from reaching a Postgres auth prompt was firewalld plus the Hetzner # cloud firewall. Two independent firewall rules were the sole barrier in front of # all 14 databases. # # Nothing needs the public binding: every consumer (authentik, infisical, adminer, # the payload/convex app stacks, gitea) resolves `postgresql` over the nginx-bridge # container network and never touches the published port at all. This mapping now # exists purely so you can point a desktop client at it across the tunnel. # # Same pattern as watchtower's API and authentik's 9000 — see AGENTS.md §5. ports: ['192.168.2.2:5432:5432'] env_file: .env environment: - POSTGRES_USER - POSTGRES_PASSWORD - POSTGRES_DB - TZ=America/New_York labels: com.centurylinklabs.watchtower.enable: "true" volumes: - ./pg_data:/var/lib/postgresql/data:Z # Runs ONLY when pg_data is empty (fresh install). Recreates every role and # database this VPS needs, so the environment can be rebuilt from scratch without # hand-creating them. Does nothing to an existing database — safe to leave mounted. # The committed file is 00-roles-and-databases.sql.TEMPLATE with placeholder # passwords; copy it to .sql and fill in real values from each service's .env. - ./initdb:/docker-entrypoint-initdb.d:Z tty: true restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] start_period: 20s interval: 30s retries: 5 timeout: 5s