PostgreSQL Docker Compose: A Complete Setup Guide for Developers
Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.
Running PostgreSQL locally used to mean installing a system package, fighting with pg_hba.conf, and hoping you didn’t break some other project’s database version. Docker Compose fixes that. With a single YAML file, you get a reproducible, disposable PostgreSQL instance that behaves the same on your laptop, your CI runner, and your production VPS.
This guide covers everything from a bare-minimum single-container setup to a production-ready configuration with persistent volumes, health checks, backups, and multi-service networking. If you’re deploying alongside other services — like the streaming stack we cover in our Docker networking guide — the same patterns apply.
Why Use Docker Compose for PostgreSQL
PostgreSQL is a stateful service, which makes it a slightly trickier candidate for containerization than a stateless API. But Docker Compose handles the hard parts well:
postgres:16 and never worry about a host upgrade breaking your data format.docker compose up to get an identical database.docker compose down -v wipes the environment cleanly when you’re done testing.The official PostgreSQL Docker image documentation is the canonical reference for supported environment variables and image variants, and it’s worth bookmarking alongside this guide.
Prerequisites
Before you start, make sure you have:
docker compose version should work without a hyphen)If you’re setting this up on a fresh VPS, our Docker installation guide for Ubuntu walks through getting Docker Engine installed correctly before you touch Compose.
Basic PostgreSQL Docker Compose Setup
Start with the simplest possible configuration. Create a directory for your project and add a docker-compose.yml file:
version: "3.9"
services:
db:
image: postgres:16
container_name: pg_container
restart: unless-stopped
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: changeme
POSTGRES_DB: appdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Bring it up with:
docker compose up -d
Check that it’s running:
docker compose ps
docker compose logs db
Connect to it using psql from your host machine (assuming the client is installed):
psql -h localhost -U appuser -d appdb
Or connect from inside the container without installing anything locally:
docker compose exec db psql -U appuser -d appdb
That’s a working database. But this bare-bones version has real problems for anything beyond a quick local test: the password is hardcoded in plaintext, there’s no health check, and you have no backup strategy. Let’s fix each of those.
Environment Variables and Secrets
Hardcoding credentials into docker-compose.yml is a bad habit that tends to leak into git history. Use a .env file instead:
# .env
POSTGRES_USER=appuser
POSTGRES_PASSWORD=a-much-stronger-password-here
POSTGRES_DB=appdb
Update the compose file to reference these variables:
services:
db:
image: postgres:16
restart: unless-stopped
env_file:
- .env
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Add .env to your .gitignore immediately. For production deployments, consider Docker secrets or a dedicated secrets manager rather than environment files — plaintext env vars are visible to anyone who can run docker inspect on the container.
If you need stronger secrets hygiene on a VPS, a managed platform like DigitalOcean offers integrated secrets and managed database options that reduce your operational burden compared to self-hosting everything by hand.
Persistent Volumes and Data Safety
The pgdata named volume in the examples above is what keeps your data alive across container restarts and rebuilds. Without it, running docker compose down or recreating the container wipes your database completely.
A few important volume behaviors to understand:
docker compose down (without -v) preserves named volumes — your data survives.docker compose down -v deletes volumes — your data is gone. Use this only when you actually want a clean slate../data:/var/lib/postgresql/data) give you direct filesystem access to the data files, which is useful for backups but can cause permission issues between host and container UIDs.Here’s a bind-mount variant if you want the database files visible on your host filesystem:
services:
db:
image: postgres:16
restart: unless-stopped
env_file:
- .env
volumes:
- ./pgdata:/var/lib/postgresql/data
Most teams prefer named volumes for portability and let Docker manage the storage location, reserving bind mounts for cases where you need direct host access, like custom backup scripts.
Health Checks and Restart Policies
A container that’s “running” isn’t necessarily ready to accept connections. PostgreSQL takes a moment to initialize, especially on first boot when it’s creating the database cluster. Add a health check so dependent services wait properly:
services:
db:
image: postgres:16
restart: unless-stopped
env_file:
- .env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://appuser:changeme@db:5432/appdb
volumes:
pgdata:
The depends_on with condition: service_healthy ensures your application container won’t start trying to connect until PostgreSQL actually reports ready via pg_isready. This eliminates the classic “connection refused” error on cold starts that plagues naive Compose setups.
Networking Between Services
By default, Compose creates a private network for all services defined in the same file. The app service above reaches PostgreSQL simply by using db as the hostname — Docker’s internal DNS resolves it automatically. You don’t need to expose port 5432 to the host at all unless you want external tools (like a local GUI client) to connect directly.
If you do need host access for debugging with a tool like pgAdmin or DBeaver, keep the port mapping but restrict it:
ports:
- "127.0.0.1:5432:5432"
Binding to 127.0.0.1 instead of 0.0.0.0 prevents the database port from being exposed on your machine’s public network interface — a small change that matters a lot if you’re running this on a cloud VPS rather than a laptop.
Multi-Container Example with pgAdmin
For local development, it’s often convenient to run a web-based admin UI alongside PostgreSQL:
services:
db:
image: postgres:16
restart: unless-stopped
env_file:
- .env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
pgadmin:
image: dpage/pgadmin4:latest
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: [email protected]
PGADMIN_DEFAULT_PASSWORD: changeme
ports:
- "127.0.0.1:8080:80"
depends_on:
- db
volumes:
pgdata:
Access pgAdmin at http://localhost:8080, then add a new server connection pointing to host db, port 5432, using the credentials from your .env file.
Backups and Restores
A container-friendly database is only useful if you can actually back it up. pg_dump works the same way it does on bare metal, just executed inside the container:
docker compose exec db pg_dump -U appuser appdb > backup.sql
Restoring is the inverse:
cat backup.sql | docker compose exec -T db psql -U appuser -d appdb
For scheduled backups, wire this into a cron job on the host:
# crontab entry - daily backup at 2am
0 2 * * * cd /opt/myapp && docker compose exec -T db pg_dump -U appuser appdb | gzip > /backups/appdb-$(date +%F).sql.gz
For anything running in production, don’t rely on cron alone — pair it with monitoring that alerts you if a backup job silently fails. A service like BetterStack can monitor your backup script’s heartbeat and page you if a scheduled dump doesn’t complete, which is far better than discovering a broken backup chain during an actual outage.
Upgrading PostgreSQL Versions Safely
PostgreSQL’s on-disk format isn’t guaranteed compatible across major versions, so you can’t simply bump postgres:15 to postgres:16 in your compose file and expect the existing volume to work. The safe path is:
1. Take a full pg_dump of your current database.
2. Stop the old container: docker compose down (keep the volume for now, don’t use -v).
3. Rename or remove the old volume, then update the image tag in docker-compose.yml.
4. Start the new version: docker compose up -d.
5. Restore the dump into the fresh instance.
Skipping the dump-and-restore step is the single most common cause of “my data disappeared after upgrading” reports in Docker forums. Treat every major version bump as a migration, not a config change.
Production Hardening Checklist
Before you point real traffic at a containerized PostgreSQL instance, review these items:
127.0.0.1 or remove the port mapping entirely if only internal services need access.restart: unless-stopped so the database recovers automatically after a host reboot.postgres:16.3, not just postgres:latest) to avoid surprise upgrades.If you’re hosting this yourself rather than using a managed database, providers like Hetzner offer solid price-to-performance ratios for dedicated database VPS instances, which matters since PostgreSQL performance is heavily tied to disk I/O and available RAM for caching.
Resource Limits
Compose lets you cap CPU and memory so a runaway query doesn’t starve other containers on the same host:
services:
db:
image: postgres:16
deploy:
resources:
limits:
cpus: "2"
memory: 2G
Note that deploy.resources is fully honored under Docker Swarm; under plain docker compose up, Compose v2 does apply these limits on recent Docker Engine versions, but it’s worth verifying with docker stats after deployment rather than assuming it’s enforced.
Common Errors and Fixes
A few issues come up repeatedly when people first containerize PostgreSQL:
POSTGRES_USER env var wasn’t set before the volume was initialized for the first time. Environment variables only take effect on first container creation; changing them later has no effect on an existing volume.docker compose down — someone ran docker compose down -v and deleted the volume along with the containers.Recommended: Ready to put this into practice? DigitalOcean is a tool we use for exactly this, and we have a real, disclosed affiliate relationship with them.
FAQ
Do I need to install PostgreSQL on my host machine if I’m using Docker Compose?
No. The postgres image includes a full PostgreSQL server. You only need the psql client locally if you want to connect from outside the container — otherwise docker compose exec db psql gives you a shell inside the container itself.
How do I change the PostgreSQL password after the container has already been created?
Environment variables like POSTGRES_PASSWORD only apply during the initial database cluster creation. To change it afterward, connect with psql and run ALTER USER appuser WITH PASSWORD 'newpassword';, then update your .env file to match for future reference.
Can I run multiple PostgreSQL versions on the same machine with Docker Compose?
Yes. Since each project’s containers and volumes are isolated by Compose project name, you can run postgres:14 for one project and postgres:16 for another on the same host without conflicts, as long as they don’t share the same host port.
Is it safe to run PostgreSQL in Docker for production workloads?
Many companies do it successfully, but it requires the same operational rigor as bare-metal PostgreSQL: proper backups, monitoring, resource limits, and a tested upgrade path. If you’d rather not manage that yourself, a managed database service removes most of the operational risk.
Why does my data disappear every time I rebuild the container?
You’re likely not using a persistent volume, or you’re running docker compose down -v, which explicitly deletes volumes. Double-check your docker-compose.yml includes a volumes: section mapped to /var/lib/postgresql/data.
How do I check logs if PostgreSQL fails to start?
Run docker compose logs db to see the container’s stdout/stderr, which usually includes the specific PostgreSQL startup error, such as a permission issue or a corrupted data directory.
Wrapping Up
Docker Compose turns PostgreSQL from a fiddly local install into a reproducible, version-pinned service you can spin up and tear down at will. Start with the basic setup, add health checks and named volumes early, and treat backups as non-negotiable before you trust it with real data. Once you’re comfortable with this pattern, it extends naturally to more complex stacks — pairing PostgreSQL with Redis, application containers, and reverse proxies using the same Compose networking model covered in our Docker networking guide.
For teams scaling beyond a single VPS, it’s worth evaluating whether self-managed PostgreSQL in Docker still makes sense compared to a managed offering — the trade-off is control versus operational overhead, and that calculus changes as your data and uptime requirements grow.