CoursesDocker in depthPractical recipes

Recipe: PostgreSQL

Persistent data, secrets, healthcheck.

Intermediate12 min · lesson 22 of 30

A database in Docker is where beginners lose data and leak credentials, so it is the recipe worth getting exactly right. Three things matter: the data must live on a named volume (never the writable layer), the password must arrive as a secret file (never a plain -e), and a healthcheck must gate anything that depends on it. The official postgres image supports all three out of the box.

terminal
# a named volume so data survives docker rm; a Docker secret for the password
$ docker volume create pgdata
$ printf 'S3cr3tP@ss' | docker secret create pg_pw - # (swarm) — or a file for compose
$ docker run -d --name db \
-e POSTGRES_DB=payments -e POSTGRES_USER=app \
-e POSTGRES_PASSWORD_FILE=/run/secrets/pg_pw \
-v pgdata:/var/lib/postgresql/data \
postgres:16-alpine

Secrets, not environment variables

The postgres image accepts POSTGRES_PASSWORD directly, but that value then shows up in docker inspect, the process environment, and often logs. The _FILE-suffixed variants (POSTGRES_PASSWORD_FILE) tell it to read the value from a file instead — mount that file as a Docker secret (tmpfs, in memory) and the credential never appears in inspect or on disk. Most official database images support this _FILE convention.

compose.yaml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: payments
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/pg_pw # value read from the file
secrets: [pg_pw]
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d payments"]
interval: 10s
timeout: 3s
retries: 5
volumes: { pgdata: {} }
secrets: { pg_pw: { file: ./secrets/pg_pw.txt } }

The healthcheck earns its keep

pg_isready reports when the server is actually accepting connections, which is later than “container started.” With the healthcheck defined, docker ps shows (healthy), and any app service using depends_on: { db: { condition: service_healthy } } waits for it — eliminating the flaky “app crashed on boot because the DB was not up yet” race.

terminal
$ docker compose up -d
$ docker inspect -f '{{.State.Health.Status}}' $(docker compose ps -q db)
healthy
$ docker compose exec db psql -U app -d payments -c '\l' # connect and list DBs
The data directory belongs on a volume, full stop
If you forget -v pgdata:/var/lib/postgresql/data, Postgres writes to the container’s writable layer — which pays copy-on-write on every write and is deleted the moment the container is removed. That is how people lose a database to a routine docker rm. Mount the volume before you put a byte of real data in.