Recipe: PostgreSQL
Persistent data, secrets, healthcheck.
Running a database in a container goes wrong in two expensive ways. Someone types docker rm and the data leaves with the container, the way a hotel room gets stripped the moment you check out. Or the password sits in the open in docker inspect, waiting to be copied out of the next crash log. Neither has to happen, and the official postgres image already ships the fix for both. Put the data on a named volume so it outlives the container using it. Hand the password over as a file instead of an environment variable. Add a healthcheck (a command Docker runs on a timer to decide whether the service is really working) so nothing tries to connect before the server is ready.
Stand it up with docker run
$ docker volume create pgdatapgdata$ mkdir -p secrets && printf 'S3cr3tP@ss' > secrets/pg_pw.txt$ 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 \-v "$PWD/secrets/pg_pw.txt:/run/secrets/pg_pw:ro" \postgres:16-alpine9f3c1e77a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6
That one docker run is the standalone form, handy when you want to poke at something for ten minutes. The line doing the heavy lifting is -v pgdata:/var/lib/postgresql/data. It parks the database files on the named volume instead of the container's writable layer (the scratch space Docker throws away along with the container), so docker rm takes the container and leaves the bytes behind. The second -v handles the password. It bind-mounts your file, read-only, at the exact path POSTGRES_PASSWORD_FILE points to. On Swarm (Docker's own clustering mode) you would use a real docker secret instead of a bind mount, but the image cannot tell the two apart. Both look like a file sitting under /run/secrets. Use this form for a quick check. For anything you will run more than once, move to Compose (Docker's format for describing a set of containers in one YAML file), where the volume, the secret and the healthcheck all sit in one place you can read at a glance.
Keep the password out of inspect
The postgres image will take POSTGRES_PASSWORD as a plain environment variable and start up perfectly happily. Don't let it. An environment variable is writing on the outside of the box: docker inspect prints it back to anyone who asks, every process inside the container can read it, and any crash handler that dumps the environment carries it off to your log system. The _FILE variant closes that door. POSTGRES_PASSWORD_FILE holds a path, not a password, and the image opens that file and reads the real value at startup. Bind-mount the file yourself, or let Compose mount it for you as a secret. Either way the value never lands in the environment, so inspect shows a path and finds nothing worth stealing. Most official database images follow the same _FILE convention, so the habit you build here carries straight over to MySQL, Mongo and the rest.
services:db:image: postgres:16-alpineenvironment:POSTGRES_DB: paymentsPOSTGRES_USER: appPOSTGRES_PASSWORD_FILE: /run/secrets/pg_pwsecrets: [pg_pw]volumes: ["pgdata:/var/lib/postgresql/data"]healthcheck:test: ["CMD-SHELL", "pg_isready -U app -d payments"]interval: 10stimeout: 3sretries: 5start_period: 30svolumes:pgdata: {}secrets:pg_pw:file: ./secrets/pg_pw.txt
$ docker compose up -d[+] Running 3/3✔ Network payments_default Created✔ Volume payments_pgdata Created✔ Container payments-db-1 Started
Prove it actually works
Started and ready are two different words. A container can be up and running while Postgres is still sorting itself out inside. Give it a few seconds, check that the healthcheck turned green, then log in and run a real query against a real table. Most recipes stop at 'container started'. This is the step that shows the database doing work rather than only booting.
$ docker inspect -f '{{.State.Health.Status}}' $(docker compose ps -q db)healthy$ docker compose exec db psql -U app -d payments -c "CREATE TABLE ledger (id serial PRIMARY KEY, amount numeric);"CREATE TABLE$ docker compose exec db psql -U app -d payments -c "INSERT INTO ledger (amount) VALUES (42.00);"INSERT 0 1$ docker compose exec db psql -U app -d payments -c "SELECT * FROM ledger;"id | amount----+--------1 | 42.00(1 row)
Notice that psql -U app never asked for a password. That is not a hole. Inside the container you are talking to Postgres over a Unix socket, a special file on the local filesystem that two programs on the same machine use to talk to each other. Think of it as a service door that only opens from inside the building. Traffic across it never touches a network, and the official image trusts it for exactly that reason. The password exists to guard connections arriving over the network, which is where strangers actually knock. The rows you wrote a moment ago live on the pgdata volume, not in the container. Prove it: delete the container and bring it straight back.
$ docker compose down[+] Running 2/2✔ Container payments-db-1 Removed✔ Network payments_default Removed$ docker compose up -d[+] Running 2/2✔ Network payments_default Created✔ Container payments-db-1 Started$ docker compose exec db psql -U app -d payments -c "SELECT count(*) FROM ledger;"count-------1(1 row)
State, secrets, and readiness
Postgres in a container is a sound choice when three statements are true at the same time: the data sits on a named volume, the credentials arrive as a file rather than an environment variable, and a healthcheck proves the server is answering before anything downstream is allowed to start. Drop the first and you lose data on the next cleanup. Drop the second and the password turns up in inspect output. Drop the third and your app races the database on every cold start, and sometimes loses.
On a shared host, don't publish 5432 to the world. Put the app and the database on the same user-defined network and let the app connect by container name. Set memory limits on purpose, because Postgres will happily use whatever you hand it, and you still want room left for the operating system's page cache (the copy of recently read disk blocks the kernel keeps in spare memory). Running the database yourself buys you control and charges you in operations. A managed service does the backups and the version upgrades; here that work is yours.
Back up with pg_dump or volume snapshots on a schedule you will actually keep, then restore into a throwaway container so you know the backup is real rather than merely present. Write the major-version upgrade steps down before the day you need them. One gotcha worth learning early: scripts you drop into /docker-entrypoint-initdb.d run only when the data directory is empty, so they will never repair a volume that already holds a database. On Swarm, use real secrets, and either pin the service to the node holding the volume or use a storage driver that follows the container around.
Play the bad day out in your head. Someone deletes the container and the volume survives, so recovery is a new container with the same mount and the same password file, and you are back in a minute. Now the other version, where the password only ever existed in a compose file that vanished with somebody's laptop and the volume is encrypted with it. That is not a recovery, that is an archaeology project. Keep recovery credentials in your secret system and run the recreate drill once a quarter, so the first time you do it is not during an incident.
Disk shape matters more than people expect. Postgres writes to a write-ahead log (WAL, a sequential journal it flushes to disk before it tells you a transaction committed), so a disk that is slow at small synchronous writes makes every commit feel sluggish. Give pgdata its own volume storage instead of sharing a tired root disk with your image layers, which is a common source of latency nobody can explain. Watch docker stats, and alert on the healthcheck going red rather than only on the process exiting, because a Postgres that is running and refusing connections still looks alive to a process check.
When you run this for real, put three things in the ticket: the volume name and the host it lives on, where the password file came from and who is allowed to read it, and the exact healthcheck output you saw while the database was working. That last one earns its keep, because the next person on call needs to know that 'healthy' from docker inspect is the normal answer here and anything else is news. The bar is simple: a teammate should be able to rebuild this database from the ticket alone, without messaging you to ask which volume held the payments data.
Try this
Run these on a lab engine (Docker 24 or newer is fine). Read the sample output first, so you know what a healthy result looks like before you lean on the command somewhere that matters.
$ docker volume create pgdata$ docker run -d --name pg --network appnet -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD_FILE=/run/secrets/db_password --secret db_password --health-cmd='pg_isready -U postgres' --health-interval=5s postgres:16# if not on Swarm, mount a password file instead of --secret$ docker inspect pg --format '{{.State.Health.Status}}'healthy# STATUS: READY — volume mounted; health=healthy
Takeaway
Named volume for the data, a file for the password, a healthcheck that gates everything starting behind it, and no published port unless something outside the host genuinely has to reach 5432. Then go run the drill yourself: docker compose down, docker compose up -d, and count the rows in ledger. If the count comes back, you have proof that the container is disposable and the data is not, instead of a hope.