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.
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: bridgeLet 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:
postgres_data:/var/lib/postgresql/data— The actual database files. This is where Postgres lives. Make sure your host has backups../init-scripts:/docker-entrypoint-initdb.d— Any.sqlor.shfiles here run once, on container first-run. Use this to create additional databases, users, roles, extensions. Hugely useful../postgresql.conf:/etc/postgresql/postgresql.conf:ro— Custom config file. Read-only mount. More on this below.
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.
# Core tuning for self-hosted setupsmax_connections = 100shared_buffers = 256MBeffective_cache_size = 1GBmaintenance_work_mem = 64MBcheckpoint_completion_target = 0.9wal_buffers = 16MBdefault_statistics_target = 100random_page_cost = 1.1effective_io_concurrency = 200
# Logginglog_min_duration_statement = 1000log_connections = onlog_disconnections = onlog_statement = 'mod'log_duration = off
# Replication (if you add it later)wal_level = replicamax_wal_senders = 3max_replication_slots = 3
# Connection pooling hint (use PgBouncer for this, but document it)# shared_preload_libraries = 'pgbouncer'Quick explainer:
shared_buffers = 256MB— Postgres’s in-memory cache. Rule of thumb: 25% of total system RAM, capped at 40%. On a 2GB machine, 256MB–512MB is solid. On an 8GB machine, go to 2GB.effective_cache_size = 1GB— Tells the planner how much OS disk cache to expect. Not actually allocated, just for query optimization. Set this to ~50% of total RAM.max_connections = 100— How many clients can connect. 100 is a safe default; raise it if you’re hitting the limit (watchpg_stat_activity).log_min_duration_statement = 1000— Log queries that take >1 second. This is gold for finding slow queries at 2 AM.wal_level = replica— Enables WAL archiving, which is essential if you ever want backups or replication.
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.
-- Create application databaseCREATE 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 permissionsGRANT 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_dbCREATE 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.
#!/bin/bashset -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, compressedpg_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 backupsfind "$BACKUP_DIR" -name "postgres_backup_*.sql.gz" -mtime +7 -delete
echo "[$(date)] Cleanup complete. Keeping backups from last 7 days."What this does:
- Dumps the entire Postgres instance (all databases, users, schemas).
- Compresses it with gzip (typically 80–90% smaller).
- Saves it with a timestamp.
- Deletes backups older than 7 days.
Run this locally first to test:
docker-compose exec postgres backup.shWhy 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
# Create the directoriesmkdir -p init-scripts backups
# Create a .env filecat > .env << EOFDB_PASSWORD=your_postgres_password_hereAPP_PASSWORD=your_app_user_password_hereEOF
# Start the stackdocker-compose up -d
# Watch the logsdocker-compose logs -f postgres
# Check healthdocker-compose psYou should see:
NAME STATUS PORTSpostgres Up 30s (healthy) 0.0.0.0:5432->5432/tcppostgres-backups Up 20sThe (healthy) marker means the health check passed. That’s your green light.
Connect from your app:
postgresql://app_user:password@localhost:5432/app_dbThe 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:
- Replication: Add a standby Postgres instance with streaming replication. Same compose file, different machine.
- Connection pooling: PgBouncer between your app and Postgres, for connection multiplexing on high-concurrency workloads.
- Monitoring: Prometheus scrape
pg_stat_statements, pipe to Grafana. Find slow queries before your users do.
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.