fix(docker): harden Postgres readiness checks in compose and startup (#595)

Pin the compose Postgres healthchecks to POSTGRES_DB (pg_isready was
defaulting to the username, silently reporting healthy while spamming
FATAL logs when USER and DB differ), and make docker/wait-for-postgres.mjs
log the target host and error code instead of a silent retry loop. Adds a
change-me note next to the default password in README and Docker Hub.

Refs #592
This commit is contained in:
SnapOtter
2026-07-21 14:01:02 +08:00
committed by GitHub
parent df92f7ee42
commit 4ba7503f15
7 changed files with 71 additions and 7 deletions
+1
View File
@@ -58,6 +58,7 @@ services:
image: postgres:17-alpine
environment:
POSTGRES_USER: snapotter
# Change this for any non-local deployment.
POSTGRES_PASSWORD: snapotter
POSTGRES_DB: snapotter
volumes: ["SnapOtter-pgdata:/var/lib/postgresql/data"]
+1
View File
@@ -75,6 +75,7 @@ services:
image: postgres:17-alpine
environment:
POSTGRES_USER: snapotter
# Change this for any non-local deployment.
POSTGRES_PASSWORD: snapotter
POSTGRES_DB: snapotter
volumes: ["SnapOtter-pgdata:/var/lib/postgresql/data"]
+2 -2
View File
@@ -122,7 +122,7 @@ services:
container_name: SnapOtter-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-snapotter}
# Set a strong password -- CHANGE THIS for any non-local deployment.
# Set a strong password. CHANGE THIS for any non-local deployment.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-snapotter}
POSTGRES_DB: ${POSTGRES_DB:-snapotter}
volumes:
@@ -130,7 +130,7 @@ services:
restart: unless-stopped
mem_limit: 1g
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter} -d ${POSTGRES_DB:-snapotter}"]
interval: 10s
timeout: 5s
retries: 12
+2 -2
View File
@@ -114,7 +114,7 @@ services:
container_name: SnapOtter-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-snapotter}
# Set a strong password -- CHANGE THIS for any non-local deployment.
# Set a strong password. CHANGE THIS for any non-local deployment.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-snapotter}
POSTGRES_DB: ${POSTGRES_DB:-snapotter}
volumes:
@@ -122,7 +122,7 @@ services:
restart: unless-stopped
mem_limit: 1g
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter} -d ${POSTGRES_DB:-snapotter}"]
interval: 10s
timeout: 5s
retries: 12
+20 -3
View File
@@ -1,9 +1,26 @@
import { connect } from "node:net";
const url = new URL(process.env.DATABASE_URL);
const socket = connect(Number(url.port || 5432), url.hostname, () => {
const host = url.hostname;
const port = Number(url.port || 5432);
const socket = connect(port, host, () => {
socket.end();
process.exit(0);
});
socket.on("error", () => process.exit(1));
setTimeout(() => process.exit(1), 3000).unref();
socket.on("error", (err) => {
// Surface the actual reason instead of exiting silently, so the container
// log distinguishes DNS failure (ENOTFOUND), refused connection
// (ECONNREFUSED), and unreachable host instead of just looping on
// "Waiting for Postgres...". This is a raw TCP probe, so it cannot report
// authentication or "database does not exist" errors; those surface later
// when the app's Postgres driver connects.
console.error(`Postgres not reachable at ${host}:${port}: ${err.code || err.message}`);
process.exit(1);
});
setTimeout(() => {
console.error(`Postgres connection to ${host}:${port} timed out after 3s`);
process.exit(1);
}, 3000).unref();
@@ -0,0 +1,27 @@
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = dirname(fileURLToPath(import.meta.url));
const script = resolve(here, "../../../docker/wait-for-postgres.mjs");
function runProbe(databaseUrl: string) {
return spawnSync(process.execPath, [script], {
env: { ...process.env, DATABASE_URL: databaseUrl },
encoding: "utf8",
timeout: 15_000,
});
}
describe("wait-for-postgres probe", () => {
it("names the unreachable target instead of failing silently", () => {
// `.invalid` never resolves (RFC 2606), so the probe fails fast rather than
// waiting for a real host. The point of the fix is that the container log
// now says which host:port could not be reached (DNS failure, refused
// connection, or timeout) instead of just "Waiting for Postgres..." forever.
const res = runProbe("postgres://user:pass@snapotter-db-nope.invalid:5432/snapotter");
expect(res.status).toBe(1);
expect(res.stderr).toContain("snapotter-db-nope.invalid:5432");
});
});
@@ -150,4 +150,22 @@ describe("Dockerfile build args", () => {
expect(Number(fallback?.[1])).toBeGreaterThanOrEqual(dockerfileDefault);
}
});
it("targets the app database (not the role) in the compose Postgres healthchecks", () => {
// pg_isready with no -d defaults the probe database to the username. When
// POSTGRES_USER and POSTGRES_DB differ, the healthcheck silently keeps
// reporting healthy while its underlying query fails, and Postgres logs
// `FATAL: database "<user>" does not exist` on a loop. Pin the probe to
// POSTGRES_DB so it fails loudly when the database is genuinely missing.
for (const [name, compose] of [
["docker-compose.yml", composeCpu],
["docker-compose-gpu.yml", composeGpu],
] as const) {
const line = compose.split(/\r?\n/).find((l) => l.includes("pg_isready"));
expect(line, `${name} should have a pg_isready healthcheck`).toBeDefined();
expect(line, `${name} pg_isready must target POSTGRES_DB with -d`).toContain(
"-d ${POSTGRES_DB",
);
}
});
});