Skip to content
Go back

Postgres in Docker: The Durable Setup

By SumGuy 10 min read
Postgres in Docker: The Durable Setup

You Don’t Need a DBaaS if You Know How to Treat Postgres Right

Here’s the thing: half the people running Postgres in Docker are doing it wrong. They spin up a container, mount a volume, punch it in production, and then wonder why their data evaporates when the container restarts. The other half are so paranoid about persistence they’ve rented three managed database instances and called it “high availability.”

The truth sits in the middle. Postgres in Docker can be rock-solid, long-lived, and operationally sane. It’s not harder than a regular app container — you just need to stop treating it like one. You need versions pinned, volumes managed properly, health checks that actually tell you something, initialization scripts that set up users and permissions on first run, and a backup strategy that doesn’t rely on hope.

This is the setup. The one you ship.

The Baseline: Why Docker Works (and Doesn’t)

Docker is great at one job: reproducible execution. Spin up the same config on any machine, get the same behavior. Postgres loves that too — same version, same config, same binaries.

But Postgres also cares about data persistence. A container is ephemeral. If you don’t mount a volume, your database dies when the container does. That’s not a Docker problem; that’s you not reading the docs.

The other gotcha: Postgres is stateful. Unlike a web server that can scale horizontally, a database needs one instance you can trust. You can’t load-balance writes across three replicas unless you’ve architected replication (which is a whole thing). So stop treating your Postgres container like it’s disposable. It’s not. It’s the vault.

That said: managed services are 70% convenience tax. If you’re self-hosting anyway, Postgres in Docker on a single machine — with proper volumes, health checks, and backup discipline — is the economic and operational sweet spot. No monthly bill. No vendor lock-in. Just a Compose file you control.

The Setup: Compose File That Doesn’t Suck

Here’s the template. Use this. Adapt it. Ship it.

docker-compose.yml
services:
postgres:
image: postgres:17.2-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=en_US.UTF-8"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
command:
- "postgres"
- "-c"
- "config_file=/etc/postgresql/postgresql.conf"
networks:
- backend
pgbackups:
image: postgres:17.2-alpine
container_name: postgres-backups
restart: unless-stopped
environment:
PGPASSWORD: ${DB_PASSWORD}
volumes:
- ./backups:/backups
- ./backup.sh:/backup.sh:ro
entrypoint: /bin/sh
command: -c 'while true; do /backup.sh; sleep 86400; done'
depends_on:
postgres:
condition: service_healthy
networks:
- backend
volumes:
postgres_data:
driver: local
networks:
backend:
driver: bridge

Let me break this down:

Image tag: postgres:17.2-alpine. Pinned. Specific minor version. Not latest. Not even :17. Your 2 AM self will appreciate the predictability. Alpine saves ~150 MB compared to the full Debian image. Smaller attack surface. Faster deploys.

restart: unless-stopped. The container will restart on crash, reboot, whatever — unless you explicitly stopped it. This is the Goldilocks restart policy. Not always (if you stop it for maintenance, it’ll restart and undo your work). Not no (it dies and stays dead).

Environment variables: Store DB_PASSWORD in a .env file. Never hardcode it. POSTGRES_INITDB_ARGS ensures UTF-8 encoding and a sensible locale — this saves you from charset hell later.

Volumes:

Health check: pg_isready is Postgres’s way of saying “I’m ready to accept queries.” The check runs every 10 seconds; if it fails 3 times in a row, the container is marked unhealthy. start_period: 20s gives Postgres 20 seconds to start before we start complaining. This is the signal that orchestrators (and other containers) use to know when Postgres is actually ready.

Depends_on with condition: The backup container depends on Postgres being service_healthy, not just running. This prevents backups from hammering a Postgres that’s still booting.

Backup sidecar: That pgbackups container runs a backup script every 24 hours (86400 seconds). Same network, same environment. More on the script below.

Tuning for Real Work: postgresql.conf

Out of the box, Postgres assumes you’re running on a potato. Here’s a config file that’s sane for a modern machine without being reckless.

postgresql.conf
# Core tuning for self-hosted setups
max_connections = 100
shared_buffers = 256MB
effective_cache_size = 1GB
maintenance_work_mem = 64MB
checkpoint_completion_target = 0.9
wal_buffers = 16MB
default_statistics_target = 100
random_page_cost = 1.1
effective_io_concurrency = 200
# Logging
log_min_duration_statement = 1000
log_connections = on
log_disconnections = on
log_statement = 'mod'
log_duration = off
# Replication (if you add it later)
wal_level = replica
max_wal_senders = 3
max_replication_slots = 3
# Connection pooling hint (use PgBouncer for this, but document it)
# shared_preload_libraries = 'pgbouncer'

Quick explainer:

Adjust these based on your machine’s RAM and workload. This config is a solid starting point for a home lab or small production setup on 2–4 GB RAM.

Initialization: Setting Up Databases and Roles

Docker runs anything in /docker-entrypoint-initdb.d/ on first container creation. Use this to bootstrap your database.

init-scripts/01-init.sql
-- Create application database
CREATE DATABASE app_db
ENCODING 'UTF8'
LOCALE 'en_US.UTF-8';
-- Create application user (restricted, not superuser)
CREATE ROLE app_user WITH LOGIN PASSWORD :'APP_PASSWORD';
-- Grant permissions
GRANT CONNECT ON DATABASE app_db TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT CREATE ON SCHEMA public TO app_user;
-- Create another DB for testing (optional)
CREATE DATABASE app_test
ENCODING 'UTF8'
LOCALE 'en_US.UTF-8';
GRANT CONNECT ON DATABASE app_test TO app_user;
GRANT USAGE ON SCHEMA public TO app_test;
GRANT CREATE ON SCHEMA public TO app_test;
-- Extensions
\c app_db
CREATE EXTENSION IF NOT EXISTS uuid-ossp;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

Why split this out? Because you can change this script and rerun it. You can’t easily reapply init scripts if the volume already exists. So: pin it, test it locally, commit it to git, and sleep soundly knowing your database schema is auditable code.

The app_user role: Not a superuser. Never use superuser in your app connection string. That’s how you get one typo away from disaster. Create a restricted role with only the permissions it needs.

Backups: Because Your Data Matters

The sidecar container runs this script daily. Store it, version it, test it.

backup.sh
#!/bin/bash
set -e
BACKUP_DIR="/backups"
POSTGRES_HOST="postgres"
POSTGRES_USER="postgres"
POSTGRES_DB="postgres"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/postgres_backup_$TIMESTAMP.sql.gz"
echo "[$(date)] Starting database backup..."
# Full database dump, compressed
pg_dump \
-h "$POSTGRES_HOST" \
-U "$POSTGRES_USER" \
-v \
--format=plain \
"$POSTGRES_DB" | gzip > "$BACKUP_FILE"
echo "[$(date)] Backup complete: $BACKUP_FILE"
# Cleanup: keep only the last 7 daily backups
find "$BACKUP_DIR" -name "postgres_backup_*.sql.gz" -mtime +7 -delete
echo "[$(date)] Cleanup complete. Keeping backups from last 7 days."

What this does:

Run this locally first to test:

Terminal window
docker-compose exec postgres backup.sh

Why not cloud backup? You can layer cloud backup on top of this (e.g., sync ./backups to S3). But start with local backups you can restore quickly. Test restores monthly. An untested backup is a backup that doesn’t exist.

Running It: Bringing the Stack Up

Terminal window
# Create the directories
mkdir -p init-scripts backups
# Create a .env file
cat > .env << EOF
DB_PASSWORD=your_postgres_password_here
APP_PASSWORD=your_app_user_password_here
EOF
# Start the stack
docker-compose up -d
# Watch the logs
docker-compose logs -f postgres
# Check health
docker-compose ps

You should see:

NAME STATUS PORTS
postgres Up 30s (healthy) 0.0.0.0:5432->5432/tcp
postgres-backups Up 20s

The (healthy) marker means the health check passed. That’s your green light.

Connect from your app:

postgresql://app_user:password@localhost:5432/app_db

The Gotchas (and How to Dodge Them)

Gotcha 1: Volume doesn’t exist.
If postgres_data is missing, Docker creates it automatically on first run. That’s fine. But make sure your host doesn’t run out of disk space. A full disk will silent-kill your Postgres container when it tries to write a checkpoint. Monitor /var/lib/docker/volumes/ on your host machine.

Gotcha 2: Locale mismatch.
If you set POSTGRES_INITDB_ARGS to UTF-8 in the env but your init script sets it differently, Postgres gets confused. Pin the locale in both places, or let one config win and stick with it.

Gotcha 3: Config file changes don’t reload.
You edited postgresql.conf and restarted the container, but Postgres is still using old settings. Some config changes require a full restart (pg_ctl reload doesn’t cut it). Check SELECT pg_postmaster_start_time(); before and after restart. If the start time didn’t change, the container didn’t actually restart.

Gotcha 4: Backup scripts that silently fail.
If pg_dump can’t connect, it exits with status 1. The set -e in the script catches that and the container logs the error. But if you’re not watching logs, you won’t notice. Script-as-sidecar means the backup runs every time and either succeeds or is visible in logs. Beats cron jobs you forget about.

Scaling Up (Later)

This setup works for single-machine deployments with a few GB of data. If you outgrow it:

But you don’t need any of that yet. Start here. Get the basics solid first.

The Takeaway

Postgres in Docker isn’t black magic. It’s not production-unsafe. It’s just a stateful service that needs you to think about how it stores data, how it gets backed up, and how it handles restarts.

Pinned versions, volume mounts, health checks, initialization scripts, and daily backups. That’s the formula. You can run this on a $10/month VPS, a home lab machine, or a Kubernetes cluster. The config stays the same.

The hardest part isn’t setting it up. It’s remembering to test your backups. Do that once, and you’ll sleep better.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Boundary vs Teleport

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts