PostgreSQL on Docker¶
The problem¶
You bring up PostgreSQL with a docker-compose.yml copied from a tutorial: user postgres, a plain-text password inside the file, port 5432 published and the application connecting as superuser. It works until it needs to be operated. One day the container runs out of free connections because every request opens a new one. Another day a query takes minutes and nobody knows which one. And the day you bump the image to the next major version, the container refuses to start with the old data.
PostgreSQL is the most advanced open source object-relational database management system and running it in containers simplifies deployment, but it does not remove operations. This page covers the basics: a sensible deployment, users with minimal permissions, the parameters that really matter, how to diagnose what is slow, backups you can actually restore and how to approach a major upgrade.
What this covers and what it does not
This page covers a single instance. Replication, automatic failover and connection routing are in PostgreSQL High Availability. To place the data on distributed storage see PostgreSQL on Ceph.
📋 Table of Contents¶
- Deployment with Compose
- Initialization and data
- Roles and permissions
- Network access and pg_hba.conf
- Parameter tuning
- Diagnosing slow queries
- Maintenance
- Backups and restore
- Upgrading to a major version
- As a systemd service
- High availability and storage
- Troubleshooting
- Best practices
- References
Deployment with Compose¶
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
POSTGRES_DB: mydatabase
secrets:
- pg_password
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myuser -d mydatabase"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
secrets:
pg_password:
file: ./pg_password.txt
What changes compared with the minimal version and why:
POSTGRES_PASSWORD_FILEreads the password from a file mounted as a secret, so it does not show up indocker inspector in the compose file. Thepg_password.txtfile must have600permissions and stay out of git.- Named volume instead of
./pgdata: avoids permission and ownership problems on the host directory. - Port on
127.0.0.1: if the application is in the same Compose network, publish nothing; it reaches the database by the namedb. If you need external access, use the private IP and not0.0.0.0. pg_isreadylets other services wait for the database to be ready withdepends_onandcondition: service_healthy.
Pin the major version and check the data path
Always use a tag with the major version (16-alpine, not latest). Also, the path where the image expects the data directory can vary between major versions of the image: check the image documentation for the version you deploy before mounting the volume.
To open an interactive session:
docker compose exec db psql -U myuser -d mydatabase
Initialization and data¶
The image runs the .sql, .sql.gz and .sh files it finds in /docker-entrypoint-initdb.d/, in alphabetical order. It is the place to create roles, extensions and the initial schema.
volumes:
- pgdata:/var/lib/postgresql/data
- ./initdb:/docker-entrypoint-initdb.d:ro
A detail that causes many tickets: these scripts only run the first time, when the data directory is empty. If you change a script and restart, nothing happens. To repeat it in a test environment you have to delete the volume; in production, apply changes with migrations on the already-created database.
The data lives in the volume, not in the container. Recreating the container (docker compose up -d after changing the image) keeps the database; docker compose down -v deletes it together with the volume.
Roles and permissions¶
The POSTGRES_USER user is a superuser. The application should not connect with it. Create one role per application with just the right permissions:
CREATE ROLE app LOGIN PASSWORD 'change-this-key';
CREATE DATABASE appdb OWNER app;
REVOKE CONNECT ON DATABASE appdb FROM PUBLIC;
GRANT CONNECT ON DATABASE appdb TO app;
For a read-only role (reports, Grafana, an analyst), connected to appdb:
CREATE ROLE reader LOGIN PASSWORD 'another-key';
GRANT USAGE ON SCHEMA public TO reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO reader;
The last command is the one people forget: without ALTER DEFAULT PRIVILEGES, tables created later will not be readable by reader. In psql, \du lists the roles and \l the databases with their owners. Change passwords with ALTER ROLE app PASSWORD '...'.
Network access and pg_hba.conf¶
Two settings control who gets in: listen_addresses (which interfaces the server listens on) and pg_hba.conf (who may authenticate, from where and how). The official image listens on all interfaces of the container; the real exposure is decided by how you publish the port.
Each pg_hba.conf line has the format type, database, user, address and method. They are evaluated top to bottom and the first match wins:
# TYPE DATABASE USER ADDRESS METHOD
local all all scram-sha-256
host appdb app 10.0.0.0/24 scram-sha-256
host all all 0.0.0.0/0 reject
Use scram-sha-256 and not md5, and certainly not trust, which lets anyone in without a password. A host ... reject line at the end makes it explicit that anything not listed is out.
Depends on the version and the image
The default method for new passwords depends on password_encryption and the version. If you migrate from an old database with md5 hashes, passwords must be set again to move to SCRAM. With the official image, pg_hba.conf lives in the data directory and is generated at initialization; to replace it, mount it with -c hba_file=... or edit it and reload with SELECT pg_reload_conf();.
When connecting from outside the host, encrypt the connection with TLS (ssl = on and certificates) or use a VPN such as WireGuard.
Parameter tuning¶
The defaults are designed for a small machine. They are passed to the image with command: ["postgres", "-c", "key=value"] or stored with ALTER SYSTEM. The most important ones:
| Parameter | What it controls | Usual starting point |
|---|---|---|
shared_buffers |
PostgreSQL's own cache memory | Around 25 % of the dedicated RAM |
effective_cache_size |
Hint to the planner about total cache (including the system's) | Between 50 and 75 % of RAM |
work_mem |
Memory per sort or hash operation | A few MB; it multiplies by operations and connections |
maintenance_work_mem |
Memory for VACUUM and index creation |
Quite a bit higher than work_mem |
max_connections |
Maximum simultaneous connections | The minimum needed; use a pooler |
command:
- postgres
- -c
- shared_buffers=1GB
- -c
- effective_cache_size=3GB
- -c
- work_mem=16MB
- -c
- max_connections=100
Two traps. work_mem is not a per-connection limit: a complex query can use it several times, and with hundreds of connections consumption shoots up until the kernel kills the process. And raising max_connections is not the fix for an app that opens too many connections: each one consumes memory. Put a pooler such as PgBouncer in front (see PostgreSQL High Availability) and keep the number moderate.
shared_buffers and max_connections require a restart; others apply with SELECT pg_reload_conf();. The pending_restart column of the pg_settings view shows which ones are waiting for a restart. Remember that the container has its own memory limit: compute the values against that limit, not against the host's RAM.
Diagnosing slow queries¶
First, what is happening right now? The pg_stat_activity view shows every connection:
SELECT pid, usename, state, now() - query_start AS duration, left(query, 80) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY duration DESC;
Reading the result: the rows with the largest duration are the candidates. A prolonged idle in transaction state is suspicious, because it holds locks and stalls VACUUM. To stop a query use SELECT pg_cancel_backend(pid);, and if it does not respond, pg_terminate_backend(pid), which closes the whole connection.
Second, why is that query slow? EXPLAIN ANALYZE actually runs the query and shows the plan with real timings:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;
In the output look for three things: a sequential scan (Seq Scan) on a large table when you expected an index, a big gap between estimated and actual rows (stale statistics: run ANALYZE) and the nodes that concentrate the time. Careful: since it runs the query, wrap it in BEGIN; ... ROLLBACK; if it is an UPDATE or DELETE.
To see the costliest accumulated queries there is the pg_stat_statements extension. It must be loaded with shared_preload_libraries=pg_stat_statements (requires a restart) and enabled with CREATE EXTENSION pg_stat_statements;.
Maintenance¶
PostgreSQL does not delete dead rows instantly: VACUUM cleans them, and the autovacuum process launches it on its own. Leave it enabled. What is worth watching:
- Tables with many dead rows, visible in
pg_stat_user_tables(columnn_dead_tup). - Transactions open for hours, which prevent cleanup.
- A manual
VACUUM (ANALYZE)after a bulk load, to refresh the planner statistics. - Volume growth: watch the free disk space, because a full database can go read-only or stop.
pg_size_pretty(pg_database_size('appdb')) gives the size of a database in a readable format.
Backups and restore¶
pg_dump produces a logical copy of a database, consistent even while in use. The custom format (-Fc) is compressed and allows selective restore:
docker compose exec -T db pg_dump -U myuser -Fc mydatabase > backup-$(date +%F).dump
docker compose exec -T db pg_dumpall -U myuser --globals-only > globals-$(date +%F).sql
pg_dump does not include roles or tablespaces; pg_dumpall --globals-only saves them separately. Without that second file, restoring on a new machine fails because of missing roles.
To restore with pg_restore:
docker compose exec -T db createdb -U myuser restored
docker compose exec -T db pg_restore -U myuser -d restored --no-owner < backup-2026-10-10.dump
A plain SQL dump (-Fp, the default) is restored with psql -f instead of pg_restore. The logical copy works for a moderately sized database; for large databases or point-in-time recovery you need physical copies (pg_basebackup) and WAL archiving, which are covered together with replication in PostgreSQL High Availability.
Schedule dumps with a systemd timer or cron, store them off the host and encrypt them; retention and encryption criteria are in Secure Backup. And above all, test the restore: a backup that has never been restored is nothing more than an assumption.
Upgrading to a major version¶
Minor versions (for example, within 16) are upgraded by changing the image tag. Major ones are not: the data directory format changes and the new version rejects the previous version's volume. Two paths:
- Dump and restore. Run
pg_dump(and--globals-only) with the old version, bring up a new volume with the new version and restore. It is the simplest and safest, with downtime proportional to size. pg_upgrade. Converts the data directory in place and is faster on large databases. In containers it requires having the binaries of both versions, so it is more delicate.
In any case, rehearse first with a copy, check the release notes for incompatible changes and keep the old volume until the new one is validated. After upgrading, run ANALYZE to regenerate the statistics.
As a systemd service¶
With Podman, a Quadlet file turns the container into a service (details in Podman Quadlet):
# ~/.config/containers/systemd/postgres.container
[Container]
Image=docker.io/library/postgres:16-alpine
Environment=POSTGRES_USER=myuser POSTGRES_DB=mydatabase
Secret=pg_password,type=env,target=POSTGRES_PASSWORD
Volume=pgdata.volume:/var/lib/postgresql/data
PublishPort=127.0.0.1:5432:5432
HealthCmd=pg_isready -U myuser -d mydatabase
[Service]
Restart=always
[Install]
WantedBy=default.target
The secret is created beforehand with podman secret create pg_password -, reading the password from standard input. With Docker, restart: unless-stopped and a systemd service that invokes docker compose up serve a similar purpose. Databases stop slowly: give systemd a generous TimeoutStopSec so PostgreSQL shuts down cleanly.
High availability and storage¶
A single instance is a single point of failure. For streaming replication, Patroni, automatic failover and split-brain prevention, follow PostgreSQL High Availability. To run PostgreSQL with the volume on a Ceph cluster (I/O performance, access mode, snapshots) see PostgreSQL on Ceph. Neither replaces the logical backups of the previous section.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
FATAL: sorry, too many clients already |
max_connections reached, usually for lack of a pooler |
Close idle connections, add PgBouncer and tune the app pool |
FATAL: password authentication failed |
Wrong password, or the role was created with another | ALTER ROLE ... PASSWORD; remember POSTGRES_PASSWORD only applies on first start |
FATAL: no pg_hba.conf entry for host |
No rule admits that IP, user or database | Add the line to pg_hba.conf and reload with pg_reload_conf() |
Changes to the initdb.d script are not applied |
They only run with an empty data directory | Apply a migration, or recreate the volume in tests |
| The container will not start after a major version change | The volume belongs to another major version | Go back to the previous version and do a dump and restore |
Permission denied when mounting ./pgdata |
The directory owner does not match the container user | Use a named volume, or fix the owner |
| A query that used to be fine is now very slow | Stale statistics or a missing index | ANALYZE, and review the plan with EXPLAIN ANALYZE |
| The disk fills up | Bloated tables, accumulated WAL or huge logs | Check pg_size_pretty, autovacuum and long transactions |
| The container dies with no error | Out of memory from excessive work_mem or shared_buffers |
Recompute against the container limit and lower connections |
pg_restore fails because of missing roles |
Restored without the global roles | Restore the pg_dumpall --globals-only file first |
Best practices¶
- Pin the major version of the image and plan major upgrades.
- Use
POSTGRES_PASSWORD_FILEor a secret, never the password in the compose file. - One role per application with minimal permissions; the app never connects as superuser.
scram-sha-256inpg_hba.conf, notrust, and the port bound to a private IP.- Use a pooler instead of raising
max_connectionswithout limit. - Compute memory against the container limit and remember
work_memmultiplies. - Leave autovacuum enabled and watch long transactions.
- Run
pg_dumppluspg_dumpall --globals-only, off the host, and test the restore. - Monitor disk space, connections and slow queries.
- For critical production, do not stop at one instance: move to High Availability.