This covers why pg-guard behaves the way it does internally: how it
talks to Postgres, how the two nodes talk to each other, the bootstrap
tiebreak algorithm, the coordinated-shutdown protocol, pg_rewind vs.
pg_basebackup, and the process/signal model on each platform. None of
this is required reading to deploy or operate pg-guard -- see
README.md for that (configuration, TLS, the REST API, shutdown modes).
See TESTING.md for dev/test tooling.
pg-guard talks to PostgreSQL two different ways, chosen per operation, not
as a blanket policy.
Shell out to the official binaries (already present in the image/install -- never reimplemented) for anything that operates on the data directory itself, including while the server isn't running as a connectable process:
| Task | Tool |
|---|---|
| Graceful stop on Windows (no real SIGTERM/SIGINT delivery to a child process there) | pg_ctl stop -m fast |
| Resync a diverged former primary | pg_rewind |
| Full resync/clone from the new primary | pg_basebackup |
| Cheap "is the socket accepting connections yet" check before a real session is viable | pg_isready |
| Inspect a stopped cluster's state (last checkpoint, timeline) to decide rewind vs. basebackup | pg_controldata |
Connect directly via pgx (native interface, not database/sql) over
the local connection for anything that's a live query or SQL call against a
running server:
pg_is_in_recovery(),pg_stat_replication,pg_last_wal_receive_lsn()/replay_lsn(),pg_current_wal_lsn()-- role detection and replication lagSELECT pg_promote()-- promotion is a SQL function as of PG12, nopg_ctl promoteneeded- Prometheus metric collection (
/metrics) -- a repeated scrape, not a one-off call - The catch-up-wait loop during a planned switchover, which polls LSN positions on a tight interval
Shelling out to psql for these would mean a fork/exec plus a fresh
connection and auth handshake per check, and parsing text/tabular output
back into typed values -- too slow and fragile for sub-second polling during
a switchover or a metrics scrape. A pooled pgx connection avoids all of
that. Implemented: db.go connects via pgxpool over TCP to
127.0.0.1:PGPORT (not a Unix socket -- identical on Linux and Windows),
lazily (doesn't require Postgres to be up yet at pool creation). Role
detection, replication lag/connected state, and pg_promote() are all
real; the switchover catch-up-polling loop isn't (no switchover logic
exists yet).
pg-guard on one node never opens a database connection to the peer node
-- cross-node role/health awareness goes only through the pg-guard-to-pg-guard
HTTPS API. The Postgres replication stream between the two nodes is separate
and stays entirely under Postgres's own control, using the replication user.
Each node exposes two separate HTTP listeners: the API port
(http://pg-traveler-0:8080 plain, or https://pg-traveler-0:8443 once
TLS is configured -- see README.md's TLS section -- PG_GUARD_PEER_PORT)
for POST /api/*, and the metrics port (http://pg-traveler-0:9100,
always plain, PG_GUARD_PEER_METRICS_PORT) for GET /health|/status.
Nodes talk directly to each other -- no third component. Implemented:
peer.go's checkPeerReachable (plain GET /health against the peer's
metrics port, 2s timeout) backs /status
and the postgres_ha_peer_reachable/postgres_ha_peer_last_seen_seconds
metrics; fetchPeerStatus (GET /status, metrics port) and
requestPeerPromote (POST /api/promote?force=true, API port) drive the
coordinated handover sequence (see Coordinated Shutdown below), the
startup rejoin check (see Rejoin below), and the promote safety guard (see
README.md's REST API section).
Implemented (bootstrap.go, run once before the Startup flow below, on
every platform -- no init container, no manual first-run script needed):
flowchart TD
A[PGDATA has no PG_VERSION] --> B{PG_GUARD_BOOTSTRAP_ROLE}
B -->|primary| P[bootstrapAsPrimary: initdb + pg_hba.conf]
B -->|standby| S[bootstrapAsStandbyWithRetry: clone from peer]
B -->|auto| D{Hostname ordinal}
D -->|"-1"| E["Wait for peer to report primary\n(retry up to 120s)"]
E --> S
E -->|120s elapses, never primary| F[logFatal -- do not silently create a second primary]
D -->|"-0 / no peer configured"| G["Briefly check whether peer\nis already primary (up to 15s)"]
G -->|peer is primary| S
G -->|15s elapses, no primary peer seen| P
- As primary:
initdb(viarunLoggedCommand, so its output flows through pg-guard's own logger -- seeREADME.md's Logging section), thenpg_hba.confgets ahost replication <PG_GUARD_REPL_USER> <peer IP>/32 ...entry scoped to just the configured peer (notall) plus a general entry for application connections. The peer is resolved to an IP up front, not left as a hostname: Postgres's hostname matching inpg_hba.confreverse-resolves the connecting client's IP and compares that name against the rule (Docker's reverse DNS on a Compose network returns<service>.<project>_default, which a plain hostname entry won't match) -- an IP-based rule sidesteps that entirely, and matches Postgres's own documented preference for IP over hostname rules anyway. Auth mode istrustifPOSTGRES_PASSWORDis unset (dev/test, matching today's defaults), orscram-sha-256with a password file if it's set -- a fresh bootstrap can now actually produce a password-protected cluster. Database/role creation (POSTGRES_DB,PG_GUARD_REPL_USERif different fromPOSTGRES_USER) happens idempotently viapgx(ensureRoleAndDatabaseBlocking/ensureRoleAndDatabase) once postgres is actually running -- not shelled out topsql, consistent with Command Execution vs. Direct Connection above. This blocks starting the HTTP API, deliberately: a peer's bootstrap-as-standby decides to runpg_basebackupthe moment this node's/statusreportsprimary, usingPG_GUARD_REPL_USER-- if the API were serving requests while that role was still being created in the background, a peer polling at exactly the wrong moment could see "primary" and attempt to connect as a role that doesn't exist yet (a real failure hit in testing). Nothing external can observe this node as usable until its own setup is actually done. - As standby: reuses
runBasebackup(the same function Rejoin's fallback path uses) -- no separate implementation.PGDATAdoesn't need to already exist. - The hostname-ordinal tiebreak only matters when neither node has an
opinion yet (both starting cold, first cluster init ever) -- if the peer
already reports a role, that always wins regardless of ordinal, so a
disaster-recovery re-bootstrap of the
-0node against an already-running-1primary still correctly bootstraps as standby. - Neither side decides on a single snapshot of the peer's status.
-1waits up to 120s for the peer to report primary, since it's deferring to a peer it already expects to exist.-0(or a single, peer-less node) only waits up to 15s -- short because this is still fundamentally the "nothing else exists yet" fallback, not a real wait -- but it's not an instant, single-shot decision either: on a concurrent two-node restart (both containers/hosts coming up together), the peer's own listener can simply not be reachable yet on the very first check, and a brief retry window is what tells "no primary exists" apart from "can't tell yet." Once either side does see the peer as primary,bootstrapAsStandbyWithRetrykeeps retrying the clone itself (not just the initial "is it primary" check) for the remainder of a 120s budget -- the peer reporting itself primary doesn't guarantee its own post-promotion setup (creating the replication role/database) has finished yet. PG_GUARD_BOOTSTRAP_ROLE=primary/standbyoverrides the whole decision tree, for deployments that would rather be explicit than rely on the hostname convention.
Nothing here is required reading to use pg-guard -- self-bootstrap
replaces all of it. It's here because bootstrap.go/rewind.go doing
this automatically is easy to treat as a black box; seeing the actual
commands (the same ones the old Docker init container ran, before
self-bootstrap replaced it) makes it clear there's no hidden magic, and
gives you the exact sequence to reproduce by hand for debugging or a
from-scratch native install.
On the primary (first node, bootstrapAsPrimary):
# 1. initdb -- local (Unix-socket) connections stay trust always; only
# network connections get a password, and only if POSTGRES_PASSWORD is set.
initdb -D "$PGDATA" -U postgres -E UTF8 --auth-local=trust --auth-host=trust
# (--auth-host=scram-sha-256 --pwfile=<tmpfile with the password> instead,
# if POSTGRES_PASSWORD is set -- see writeBootstrapHBA)
# 2. Grant the standby replication access, scoped to its resolved IP (not
# "all", and not its hostname -- see writeBootstrapHBA's comment on why
# IP-based, not hostname-based) -- plus a general application-access line.
cat >> "$PGDATA/pg_hba.conf" <<EOF
host replication replicator <standby-ip>/32 trust
host all all all trust
EOF
# 3. Enable wal_log_hints -- required for pg_rewind to run at all later
# (see Resync below for why); inherited by every standby automatically
# since pg_basebackup clones the whole data directory.
echo "wal_log_hints = on" >> "$PGDATA/postgresql.conf"
# 4. Start it (pg-guard execs postgres directly as PID 1 instead of pg_ctl).
pg_ctl -D "$PGDATA" -w start
# 5. Once running -- idempotent, see ensureRoleAndDatabase:
psql -d postgres -c "CREATE ROLE replicator WITH REPLICATION LOGIN"
psql -d postgres -c "CREATE DATABASE traveler WITH OWNER postgres ENCODING 'UTF8'"
psql -d postgres -c "CHECKPOINT" # see ensureRoleAndDatabase's comment on whyOn the standby (second node, runBasebackup):
# One command clones the entire cluster from the primary and configures it
# as a standby -- pg_basebackup's -R flag writes standby.signal and
# primary_conninfo (in postgresql.auto.conf) automatically; nothing else
# needed before starting it.
PGPASSWORD=<replicator's password, if any> pg_basebackup \
-h <primary-host> -p 5432 -U replicator \
-D "$PGDATA" -P -R -X stream
pg_ctl -D "$PGDATA" -w start # comes up in standby mode on its ownThat's the entire first-run sequence -- everything else (role/database
creation above, the replication grant, ongoing health/role tracking) is
handled the same way on every subsequent startup too, not just this first
one (see ensureRoleAndDatabase/ensureReplicationHBA).
Implemented (shouldRejoinAtStartup/startPostgres in main.go, run
after Bootstrap above, before the first sup.start() call in the process's
life):
flowchart TD
A[Supervisor starts] --> D[Contact peer /status]
D -->|peer unreachable or standby| F[Start postgres normally]
D -->|peer is primary, local PGDATA not a standby| E[rejoinAsStandby: pg_rewind / pg_basebackup]
E -->|succeeds| F
E -->|fails| G[Leave postgres stopped -- API stays up for POST /api/rejoin]
This single check doubles as the automatic side of Rejoin below: the same code path runs whether it's a fresh container start or the restart that follows a switchover's sentinel exit code.
First cluster initialization can use explicit configuration (which node starts as primary).
Implemented (coordinateHandover in handover.go, shared by
SIGTERM/SIGINT, POST /api/shutdown, and POST /api/switchover --
they differ only in the exit code used afterward, and for shutdown, that it
always means "don't come back"):
sequenceDiagram
participant P as Primary
participant S as Standby
P->>S: GET /status (verify healthy standby)
P->>P: stop local PostgreSQL
P->>S: POST /api/promote?force=true
S->>S: promote
P->>S: poll GET /status (confirm peer became primary, up to 10s)
P->>P: exit (0 for shutdown, sentinel restart code for switchover)
No in-process "restart the child" capability was needed for this: pg-guard's
own process exit code is the restart signal. docker/docker-compose.yml
uses restart: on-failure (not always -- that would restart regardless of
exit code, breaking real shutdown). Real shutdown exits 0 and the
container stays down; switchover exits a sentinel non-zero code, restart: on-failure brings the container straight back up, and the Startup
section's rejoin check detects the peer is now primary and rejoins
automatically -- implementing both Startup and the automatic side of
Rejoin with one check.
Standby shutdown/switchover (target is a standby, not primary): same
coordinateHandover call, just without a peer-promotion step to wait on
(switchover is refused with 400 on a standby; shutdown on a standby
runs the same stop-and-confirm-peer-still-healthy sequence).
PG_GUARD_SHUTDOWN_POLICY (require-switchover default | best-effort |
force) governs how strict the handover in Planned Shutdown / Switchover
above is:
require-switchover: refuses (returns an error, stops nothing) unless the peer is reachable, reports itself standby, and confirms primary within 10s of the promote request.best-effort: same checks, but only logs a warning and proceeds regardless of whether they pass.force: skips peer coordination entirely -- plain stop-and-wait, identical to pg-guard's original pre-HA behavior.
For SIGTERM/SIGINT specifically, a require-switchover/best-effort
refusal still falls back to a forced local stop -- the OS/orchestrator is
asking pg-guard to terminate, so it must eventually comply even when a
clean handover isn't possible right now. POST /api/shutdown and
POST /api/switchover have no such fallback: a refusal returns 409 and
postgres keeps running untouched, by design.
Simplified vs. the original design: true bidirectional negotiation
(a standby detecting its specific peer is shutting down and
cancelling/promoting on its own initiative, with no API call from the
peer) is not implemented -- what ships is one-directional, always
initiated by whichever node received the shutdown/switchover request. Real
safety (a standby's own shutdown still checks the primary is healthy first
under require-switchover), just not the full cancel-and-promote dance,
which needs more signaling infrastructure than this pass builds.
Implemented (startFailoverMonitor in failover.go, a background
goroutine started alongside the API server, active whenever
PG_GUARD_FAILOVER_MODE=automatic, the default):
flowchart TD
A["Peer HTTP unavailable, OR\npeer /status unreachable"] --> B{Timeout exceeded?\nPG_GUARD_FAILOVER_TIMEOUT}
B -->|yes| C[Promote local standby]
B -->|no| A
Ticks every 5s; only evaluates the peer while the local node is a healthy,
running standby (skips entirely if already primary or if local postgres
itself isn't reachable). Tracks how long the peer has been continuously
unhealthy and promotes locally via the same pg_promote() the manual
endpoint uses once that exceeds PG_GUARD_FAILOVER_TIMEOUT (default
60s) -- logged at error level since it's consequential and autonomous.
v1 assumption: enterprise environment -- peer unreachability is treated as server loss, not an arbitrary network partition. This assumption is deliberate and must stay documented, not silently generalized.
Implemented (rejoinAsStandby in rewind.go; the startup trigger is
rejoinAtStartupIfNeeded in main.go). Two entry points, with
deliberately different retry behavior:
- At startup, every restart, automatic:
rejoinAtStartupIfNeededretries the whole "is the peer actually primary?" decision -- not just a single snapshot -- for up to 30s. A confirmed answer either way (peer reachable and explicitly reports non-primary, or localPGDATAis already configured as a standby) returns immediately; only an inconclusive result -- the peer unreachable or erroring -- keeps retrying. If the peer never becomes reachable at all within the window, postgres is left stopped rather than silently starting up as a possibly- stale primary, matching Bootstrap's-1tiebreak: fail loud rather than risk a second primary. POST /api/rejoin: a single, direct call torejoinAsStandby-- no "wait and see if the peer becomes primary" retry, since this is an explicit admin action taken with the peer's state already known. Only valid while postgres isn't currently running under the supervisor.
Once either path decides a rejoin is actually needed, the clone mechanism itself is the same:
flowchart TD
A[Rejoin triggered] --> C{pg_rewind possible?}
C -->|yes| D[pg_rewind --target-pgdata --source-server=peer]
C -->|no| E[wipe PGDATA, pg_basebackup -R -X stream from peer]
D --> F[configureAsStandby: write standby.signal + primary_conninfo]
E --> G[Start as standby]
F --> G
pg_rewind doesn't create standby.signal/set primary_conninfo the way
pg_basebackup -R does, so configureAsStandby writes those manually
after a successful rewind. A former primary does not start writable
after this: it always comes back through this path as a standby.
The Rejoin flow above is the code path; this section is the underlying mechanism -- written for Domino admins/DBAs who know exactly what "get a replica back in sync" means operationally but haven't necessarily worked with PostgreSQL's own replication internals before. Nothing here is Traveler- or Domino-specific -- it's how any two PostgreSQL nodes resynchronize, explained once so the Rejoin diagram's two branches make sense as engineering decisions, not just function names.
- WAL (Write-Ahead Log). Every change to the database is first appended to a sequential log before it's applied to the actual data files -- the log record is the durable fact; applying it to the data files is just a performance optimization (a page can always be reconstructed by replaying WAL). Streaming replication is, literally, the primary shipping this same log to the standby in real time, which replays it as it arrives. There is no document-level or row-level replication happening -- it's a byte-level log of physical changes.
- LSN (Log Sequence Number). A monotonically increasing byte offset
into the WAL stream -- Postgres's "position in the log." Every WAL
record has one;
replication_lag_bytes(README.md'sGET /status,/metrics) is the gap between the primary's current LSN and the furthest-behind standby's. - Timeline. Every time a standby is promoted (
SELECT pg_promote(), seeREADME.md's REST API section forPOST /api/promote), Postgres starts a new timeline: a new branch in the WAL history, recorded permanently in a small.historyfile. This exists specifically so two servers that both descend from the same original data can never be confused about which one's WAL is authoritative after a promotion -- the promoted node's WAL from that point on belongs to a new timeline number, distinct from whatever the old primary might still generate if it's still running.
Any node that wasn't continuously receiving and replaying WAL from the
current primary is, by definition, no longer known to be in sync with
it -- a crash, a coordinated handover, a POST /api/maintenance stop, or
simply having been the primary itself before a promotion happened
elsewhere. shouldRejoinAtStartup (Startup, above) is what actually
detects this: the peer reports itself primary, and this node isn't
already configured as its standby.
flowchart LR
A["Former primary's own WAL,\ntimeline N"] -->|diverges at LSN X| B["New primary's WAL,\ntimeline N+1"]
B -.->|"pg_rewind: copies only blocks\nchanged since LSN X, either side"| A
B ==>|"pg_basebackup: full physical copy,\nno shared history assumed"| C["Fresh clone"]
pg_rewind(the fast path). Compares the target's (this node's) timeline against the source's (the peer's) and finds the exact LSN where they diverged, then copies only the data blocks that changed on either side since that point -- not a full copy. For a large database where the two nodes were only briefly out of step (the normal case after a clean switchover or a short crash), this is a small fraction of the actual data size. It works because Postgres can prove, block by block, "this page is identical" or "this page changed" without re-reading everything -- but that proof depends on eitherwal_log_hints = onordata_checksumsbeing enabled, since otherwise a harmless hint-bit change (metadata Postgres opportunistically updates on read, unrelated to the actual row data) can't be told apart from a real content change. pg-guard enableswal_log_hintsat first bootstrap (writeBootstrapPostgresConf,bootstrap.go) specifically sopg_rewindhas this available -- without it, Postgres refuses to runpg_rewindat all, and every rejoin would silently fall back to the full re-clone below regardless of how small the actual divergence was (confirmed as exactly what was happening before this was added: thepg_rewindbranch was unreachable in every real test run this session). Because it's a physical, timeline-aware operation,pg_rewindalso depends on enough of the relevant WAL history around the divergence point still being retained (governed bymax_wal_size/wal_keep_size, neither of which pg-guard currently tunes, and there's no replication slot reserving it) -- if that WAL has since been recycled, rewind isn't possible and pg-guard falls back automatically. Keeping outages short (PG_GUARD_REBOOT_GRACE_PERIODfor planned ones) reduces how often this comes up, but doesn't eliminate the possibility -- a replication slot guaranteeing retention is the actual fix and isn't implemented yet.pg_basebackup(the always-correct fallback). Makes no assumption about shared history at all: it's a full physical copy of the entire data directory from the primary's current state, the same mechanism used for an initial standby clone (Bootstrap, above) or a real backup. Slower and more expensive for a large database since everything transfers, not just the delta, but it always works regardless of how long the node was gone or how far the histories diverged -- it has no dependency on old WAL still being retained.-Rwritesstandby.signal/primary_conninfoautomatically as part of the same operation;pg_rewinddoesn't touch either, soconfigureAsStandbywrites them by hand afterward (see the Rejoin diagram).
rejoinAsStandby always tries pg_rewind first and only falls back to
pg_basebackup if it fails, for any reason -- so correctness and eventual
availability are never gated on pg_rewind specifically working; it's
purely a speed optimization on top of a fallback that's always safe to
use.
Implemented today. pg-guard execs postgres (PG_GUARD_POSTGRES_BIN,
-D PGDATA, plus any PG_GUARD_EXTRA_ARGS) as its one supervised child --
no wrapper script involved on any platform.
Linux: forwards whichever of SIGTERM/SIGINT it received rather than
normalizing (Postgres gives those distinct smart-vs-fast shutdown meanings
-- notably, the official image's STOPSIGNAL is SIGINT, so docker stop
triggers a fast shutdown, not a smart one, and forwarding the exact signal
received preserves that intentional choice). This holds through the
coordinated-handover path too, not just the plain wait-then-kill one -- the
received signal is threaded from runInteractive's signal handler through
handleTerminationSignal/coordinateHandover/stopLocal (handover.go)
rather than normalized to a constant, so docker stop's SIGINT still
reaches postgres as SIGINT even when a peer handover runs first.
API-triggered shutdown/switchover have no real OS signal to forward, so
those use SIGTERM ("smart shutdown") as a sensible default. As real
container PID 1, it
also reaps every process reparented to it -- not just the tracked child --
via a single wait4(-1, WNOHANG)-draining loop woken by SIGCHLD (the same
model tini/dumb-init use; only one waiter may ever collect a given PID's
exit status, so this reap loop is the sole place that waits on any child).
Verified against a real postgres:17 container running as the non-root
postgres user throughout (no root involved at any point, since there's no
docker-entrypoint.sh doing a privilege drop -- the container itself starts
non-root, see README.md's Deployment section): confirmed clean PID-1
supervision, clean shutdown, a forced-kill escalation path, and zombie
reaping of both organic and synthetic orphaned processes, with zero left
behind.
Windows: os.Process.Signal() only implements os.Kill there -- no
real SIGTERM/SIGINT delivery to an arbitrary child process -- so graceful
stop shells out to pg_ctl stop -D <PGDATA> -m fast instead. Can run
interactively (a normal, non-elevated console process -- Postgres refuses
to run under an admin-flagged token, confirmed) or as a registered Windows
Service:
pg-guard.exe -install-service(needs an elevated prompt -- registering with the Service Control Manager is inherently privileged) creates the service running asNT AUTHORITY\NetworkService-- the same non-admin account the official Postgres Windows installer's own service already uses -- copies the currentPG_GUARD_*/POSTGRES_*/PGDATAenvironment into the service's private registryEnvironmentvalue (a Windows Service does not inherit a user session's environment the way an interactive shell does), and grantsNetworkServicefilesystem access toPGDATAviaicacls(a manually-initdb'd directory has no such grant by default).- Running the installed service is not privileged -- SCM starts it as
NetworkService, andpostgres.exeinherits that from its parent automatically, satisfying Postgres's restriction with zero privilege-drop code inpg-guarditself. pg-guard.exe -uninstall-serviceremoves it.- SCM drives lifecycle via
ChangeRequests instead of OS signals; Stop, Shutdown, and PreShutdown requests all run the same shutdown logic as the interactive path, in a goroutine, while periodicStopPendingcheckpoints keep SCM from deciding the service is hung during a legitimately-longPG_GUARD_SHUTDOWN_WAIT. - PreShutdown is registered (
svc.AcceptPreShutdown), not just Stop/Shutdown -- this matters becausePG_GUARD_SHUTDOWN_WAITdefaults to300sand coordinated handover can add more on top, which exceeds the budget an ordinary Stop/Shutdown gets during a real system shutdown/reboot.SERVICE_CONTROL_PRESHUTDOWNis Windows's own mechanism for exactly this case: a service that registers for it gets called first, with a 3-minute default budget (PreshutdownTimeout, itself extendable via the sameStopPendingcheckpoints) before the regular shutdown sequence even begins. Handled identically to Stop/Shutdown, not as a separate code path -- PreShutdown is the complete shutdown signal for a preshutdown-aware service, not a precursor to also receiving a plain Shutdown for the same event.
Both platforms: waits PG_GUARD_SHUTDOWN_WAIT for the child to exit after
asking it to stop, then force-kills on timeout -- this is stopLocal in
handover.go, the bottom layer coordinateHandover builds on for the
force policy and as its own final step once peer coordination succeeds.
All mutation of the supervised child (sup/currentChild, see state.go)
happens on a single goroutine: the main select loop directly for
SIGTERM/SIGINT, or via a handoverRequests channel for API-triggered
shutdown/switchover/rejoin, so coordinateHandover never runs concurrently
with itself or races the crash-detection path over the same child's exit
event. That crash-detection path -- the child exiting on its own, outside
any requested handover -- still just exits pg-guard with the same code
(unchanged since before this milestone): under restart: on-failure the
container comes back, re-running the Startup rejoin check, which correctly
recovers a crashed former-primary in place (peer still standby, so no
rejoin needed) as well as a crashed former-standby (re-clones if the peer
is now primary).