35 β Running Databases in Practice β Docker, Seeds, Backups and Pools
Level: Intermediate Β· Time: ~35 min Β· Prerequisites: Lesson 34 β Repositories, Transactions and Data Modelling
Why this matters
Lesson 34 gave you repositories and transactions, and every example assumed a PostgreSQL that is simply there. In practice the database is a service you start, configure, seed, back up and monitor, and one day restore at 22:00 while somebody waits. None of that checks types, and all of it decides whether your data still exists next month. Running the server in Docker makes it reproducible, and the habits below make it fit to hold real production figures once the server is reproducible.
A real PostgreSQL, in one file
# docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: solar
POSTGRES_DB: solar_dev
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is not set β copy .env.example to .env}
ports:
- "5432:5432" # host:container β the host port is what DATABASE_URL uses
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U solar -d solar_dev"]
volumes:
pgdata:
The .env holds POSTGRES_PASSWORD and
DATABASE_URL="postgresql://solar:...@localhost:5432/solar_dev?connection_limit=5", while the committed
.env.example holds a placeholder; refusing to start without the password is deliberate.
| Command | What it does |
|---|---|
docker compose up -d |
creates and starts the service in the background |
docker compose ps |
container state, including (healthy) from the healthcheck |
docker compose logs -f db |
follows the server log β the first place to look when a connection fails |
docker compose exec db psql -U solar -d solar_dev |
an interactive SQL shell inside the container |
docker compose down |
removes the containers; the named volume, and your data, survive |
docker compose down -v |
removes the volume too β every row is gone, the reset for a schema beyond repair |
docker compose exec -T db pg_dump ... |
-T disables the pseudo-terminal, which is what makes redirection work |
Connecting, seeding, and never seeding production
localhost:5432 works from your machine because of the port mapping. A service inside the same Compose
network reaches the database at db:5432 instead, which will not resolve from your terminal β that mismatch is
the most common "connection refused" in development. Load the URL once, fail loudly if it is missing, and export
a single shared PrismaClient; Node 20 reads .env itself with node --env-file=.env dist/main.js.
Seeds write the data the application cannot work without. Two rules make them safe: they are idempotent
(upsert on a natural key, never create), and they refuse production unless a human says otherwise.
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const BANDS = [{ code: "band-a", maxKwhPerMonth: 150, pricePerKwh: 0.09 }, { code: "band-b", maxKwhPerMonth: 400, pricePerKwh: 0.13 }] as const;
async function seed(): Promise<void> {
if (process.env.NODE_ENV === "production" && process.env.ALLOW_PRODUCTION_SEED !== "yes") {
throw new Error("refusing to seed production without ALLOW_PRODUCTION_SEED=yes");
}
for (const band of BANDS) {
await prisma.tariffBand.upsert({ where: { code: band.code }, create: band, update: band });
}
console.log(`seed complete: ${BANDS.length} tariff bands upserted`);
}
seed()
.catch((error: unknown) => { console.error(error); process.exitCode = 1; })
.finally(() => prisma.$disconnect());
Register it with "prisma": { "seed": "tsx prisma/seed.ts" } in package.json, then run npx prisma db seed.
Reference data belongs everywhere; forty fake sites belong in prisma/seed.dev.ts, which no deployment mentions.
[!IMPORTANT] Give each environment its own database, credentials and URL, and make the difference visible in the URL itself:
..._dev,..._staging,..._prod. Most "I seeded the wrong environment" incidents come from three terminal windows that look identical.
Backups you have actually restored
An untested backup is a hypothesis. pg_dump in the custom format is compressed and restores selectively;
write a UTC-stamped dump, keep a rolling window, and restore one into a scratch database every month.
#!/usr/bin/env bash
# scripts/backup.sh
set -euo pipefail
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p backups
docker compose exec -T db pg_dump -U solar -d solar_dev -Fc > "backups/solar_dev_${STAMP}.dump"
find backups -name 'solar_dev_*.dump' -mtime +14 -delete # retention: keep a fortnight
To prove the dump restores β the step people skip and later regret:
docker compose exec -T db createdb -U solar solar_restore_test
docker compose exec -T db pg_restore -U solar -d solar_restore_test --clean --if-exists < backups/solar_dev_*.dump
docker compose exec db psql -U solar -d solar_restore_test -c 'SELECT count(*) FROM "ProductionDay";'
Expected output: the count matches the live table. For SQLite the equivalent is
sqlite3 data/solar.db ".backup 'data/solar-backup.db'" or VACUUM INTO; cp on a live file is not a backup.
Pools, graceful shutdown and health checks
A pool keeps a handful of connections open and reuses them, because each new one costs a round trip. Prisma
holds one pool per client, so processes Γ connection_limit β€ max_connections is the arithmetic that matters:
PostgreSQL defaults to 100, so ten replicas at the default of 10 sit on the limit and one restart stampedes.
For a small application connection_limit=5 is plenty, and pool_timeout=10 fails fast when saturated.
// src/db/shutdown.ts
import { prisma } from "./client.js";
async function shutdown(signal: string): Promise<void> {
console.log(`${signal} received, closing the pool`);
await prisma.$disconnect(); // hands the connections back instead of dropping them
process.exit(0);
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
// src/db/health.ts β one cheap query, on a timer rather than in every request
export async function isDatabaseHealthy(): Promise<boolean> {
try { await prisma.$queryRaw`SELECT 1`; return true; }
catch (error: unknown) { console.error("database health check failed", error); return false; }
}
Three numbers are worth a weekly look. Slow queries: ALTER DATABASE solar_dev SET
log_min_duration_statement = '500ms'; logs the ones you did not expect. Connections: a rising error count
usually means a pool that is too small or connections that are leaking.
Environments, migrations and read-only credentials
Only one environment holds data you cannot recreate, so keep three databases, three URLs and three credential sets, and run migrations as an explicit deployment step before the new code starts, never from application startup where ten replicas would race. Reporting tools have no business writing:
CREATE ROLE solar_reporting LOGIN; -- set the password interactively with \password solar_reporting
GRANT CONNECT ON DATABASE solar_dev TO solar_reporting;
GRANT USAGE ON SCHEMA public TO solar_reporting;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO solar_reporting;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO solar_reporting;
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
docker compose down -v while debugging |
The volume is deleted and the local database is empty | Use down; save -v for a deliberate reset |
Committing .env |
Real credentials in git history forever | Commit .env.example, ignore .env |
| Running the seed twice | Duplicate tariff bands, totals that double | upsert on a natural key |
| Seeding production by accident | Invented figures sit beside real ones and nothing looks wrong | Separate URLs plus a guard in the script |
Copying a live SQLite file with cp |
A torn copy that opens and silently misses rows | sqlite3 ... ".backup" or VACUUM INTO |
| A backup nobody ever restored | The dump turns out to be unusable during the incident | Restore into a scratch database monthly |
Killing the process without $disconnect() |
Connections linger until the server times them out | Handle SIGTERM/SIGINT and close the pool |
Try it
- Create
docker-compose.ymland.env, then start the server:docker compose up -d && docker compose ps.
Expected output: a row containing Up ... (healthy). If it says unhealthy, read the log: the password
variable is the usual suspect.
- Apply migrations and seed twice:
npx prisma migrate deploy && npx prisma db seed && npx prisma db seed.
Expected output: seed complete: 2 tariff bands upserted twice, with no duplicate rows.
- Take a dump, destroy a table, restore it:
bash scripts/backup.sh, thendocker compose exec db psql -U solar -d solar_dev -c 'DROP TABLE "ProductionDay";', then
docker compose exec -T db pg_restore -U solar -d solar_dev --clean --if-exists < backups/solar_dev_*.dump
docker compose exec db psql -U solar -d solar_dev -c 'SELECT count(*) FROM "ProductionDay";'
Expected output: the count after the restore matches the count from before the DROP. Then run
docker compose down -v on purpose and start again, so the day it matters is not the first time.
Key takeaways
- The container is disposable and the named volume is the database: know what
down -vdeletes. - A seed script is idempotent and refuses production unless a human sets a variable.
- A backup counts only once you have restored it into a scratch database and compared row counts.
- Size pools as
processes Γ connection_limit β€ max_connections, and close them onSIGTERM. - Three environments, three URLs, three credential sets β and reporting gets
SELECTand nothing else.
Check yourself
- You run
docker compose down -vto fix a connection problem: what have you just lost? - Your seed script uses
createand someone runs it twice in staging. What does the table look like? - Why is
pg_dump -Fcpreferred over a plain SQL dump, and what does-Tondocker compose execchange?