Postgres Docker: The Complete 2026 Guide
Here is a Postgres container that works today, on Postgres 18:
If you have used Postgres with Docker before, that volume path probably looks wrong. Most guides tell you to mount /var/lib/postgresql/data. That advice was correct until October 2025, and on postgres:18 it now stops the container from starting at all.
This guide covers what changed, plus the things that break afterwards: volumes, init scripts that silently never run, healthchecks that report success too early, and major version upgrades.
Quick start: run Postgres in Docker
The command above pulls the image, starts it in the background, and exposes Postgres on port 5432. POSTGRES_PASSWORD is the only environment variable the image requires.
Check that it started:
The last line you want to see is:
Open a psql session inside the container:
Three things about that command are worth knowing. docker exec connects over a Unix socket inside the container, which is always trusted, so it never asks for a password. -U postgres is the default superuser. And if you skip -it, you get no interactive prompt.
To stop and remove the container:
Your data survives that, because it lives in the pgdata volume rather than the container. Removing the volume is what destroys it:
The Postgres 18 PGDATA change that breaks older guides
This is the part that catches everyone, so it is worth understanding rather than copying.
Postgres 18 changed both where the image stores data and which directory it declares as a volume:
| Postgres 18 and later | Postgres 17 and earlier | |
|---|---|---|
PGDATA | /var/lib/postgresql/18/docker | /var/lib/postgresql/data |
Declared VOLUME | /var/lib/postgresql | /var/lib/postgresql/data |
The data directory now includes the major version number. That is deliberate: it matches the layout pg_ctlcluster uses, which lets pg_upgrade --link work without crossing a mount point boundary.
The practical consequence is that the mount path is different depending on your Postgres version, and the two versions fail in opposite ways.
On Postgres 18 and later, mount /var/lib/postgresql
If you use the old path on postgres:18, the container exits immediately with code 1:
This is the good outcome. The image detects the stale mount and refuses to start rather than starting with an empty database and letting you believe your data is being saved. It checks /proc/self/mountinfo, so bind mounts get caught too.
On Postgres 17 and earlier, mount /var/lib/postgresql/data
Here the failure is inverted, and much worse. If you mount the parent directory on an older image:
The container starts. Postgres runs. Everything looks fine. But the declared VOLUME at /var/lib/postgresql/data creates an anonymous volume that shadows your mount, so writes land somewhere you are not looking and vanish when the container is removed. The official README warns about this in capitals for good reason.
So: 18 and later, mount the parent. 17 and earlier, mount the data directory. If you pin your image version (and you should), you only have to get this right once.
Choosing a Postgres Docker image tag
postgres:latest moves to a new major version when one is released, which means an unattended docker pull can hand you a data directory your database cannot read. Pin the major version at minimum.
| Tag | Base | Size | Use when |
|---|---|---|---|
18 / 18-trixie / latest | Debian 13 (trixie) | ~440 MB | Default. Widest extension support. |
18-alpine | Alpine 3.24 | ~280 MB | Image size matters more than extensions |
18-bookworm | Debian 12 | ~440 MB | You need to match an older host toolchain |
18.4 | Debian 13 | ~440 MB | Reproducible builds, CI pinning |
As of Postgres 18, latest, 18, 18-trixie, and trixie all point at the same image. The default Debian base moved from bookworm to trixie in August 2025, so tutorials referencing bookworm as the default are out of date. You can confirm what you are running:
pgdg13 means Debian 13, which is trixie.
On Alpine: it saves about 160 MB, but it uses musl rather than glibc, and anything outside postgres-contrib has to be compiled yourself. PostGIS is the usual casualty. For a local dev database, that tradeoff is fine. For anything where you might add extensions later, take the Debian image.
On Apple Silicon: you do not need --platform linux/amd64. Every tag ships native arm64, and forcing amd64 puts you under emulation for no reason. If you are copying that flag from an older tutorial, drop it.
Persisting data with volumes
Containers are disposable. Your database should not be. There are two ways to keep data, and they are not equivalent.
Named volumes are managed by Docker and are the right default:
Bind mounts map a host directory into the container:
Bind mounts look appealing because you can see the files. In practice they cause two problems. On macOS and Windows, the filesystem bridge is significantly slower than a named volume, and a database is exactly the kind of workload that notices. And when the entrypoint tries to chown the data directory to the postgres user, that can silently fail on macOS bind mounts, SMB, or NFS, after which Postgres rejects the directory for having permissions it does not trust.
Use a named volume unless you have a specific reason not to. To inspect the data, use psql or a GUI client rather than the filesystem.
To confirm your data actually persists, write something and restart:
If you get 42 back, the volume is wired up correctly. If you get an error about a missing relation, it is not, and you should re-read the section above before putting anything real in this database.
Environment variables that matter
Every one of these applies only when the data directory is empty, which is to say only on the very first start. This is the single most common source of confusion with this image.
| Variable | Default | Notes |
|---|---|---|
POSTGRES_PASSWORD | none | Required, unless POSTGRES_HOST_AUTH_METHOD=trust |
POSTGRES_USER | postgres | Sets the superuser name. Does not create an extra role. |
POSTGRES_DB | value of POSTGRES_USER | Not postgres. See below. |
POSTGRES_INITDB_ARGS | none | Passed through to initdb |
POSTGRES_HOST_AUTH_METHOD | scram-sha-256 | trust disables passwords entirely |
PGDATA | /var/lib/postgresql/18/docker | Changing this is rarely a good idea on 18+ |
Two of these reliably trip people up.
POSTGRES_DB defaults to POSTGRES_USER, not to postgres. If you set POSTGRES_USER=myapp and leave POSTGRES_DB unset, you get a database called myapp:
Applications that assume a database named postgres and a user named myapp will fail to connect with database "postgres" does not exist, or connect to the wrong database entirely.
Changing POSTGRES_PASSWORD later does nothing. The password is set by initdb on first run. Editing the variable and restarting will not change it, because the data directory is no longer empty. To actually change it:
Related to this: docker exec ... psql connects over the local Unix socket, which is configured as trust. It will never prompt you for a password regardless of what you set. This leads people to believe their password did not apply. Test it over TCP instead:
For secrets, four variables support a _FILE suffix so you can point at a mounted file rather than an environment variable: POSTGRES_PASSWORD_FILE, POSTGRES_USER_FILE, POSTGRES_DB_FILE, and POSTGRES_INITDB_ARGS_FILE. PGDATA does not.
Connecting to Postgres in a Docker container
How you connect depends on where you are connecting from, and the three cases have genuinely different answers.
From inside the container (no password, always works):
From your host machine, you need the port published with -p 5432:5432, and then Postgres is just a normal server on localhost:
If port 5432 is already taken, usually by a Homebrew or Postgres.app install, you will see:
Remap the host side and leave the container side alone:
Then connect on 5433. The port inside the container is still 5432, which matters for the next case.
From another container on the same Docker network, use the service name as the hostname, not localhost:
Inside a container, localhost means that container, not the host and not the database. This is the single most common Docker networking mistake, and it produces could not translate host name "postgres" to address or a connection refused, depending on what you got wrong.
With a GUI client, connect to localhost on whichever host port you published, exactly like a local install. Any Postgres client works: pgAdmin, psql, or DB Pro if you want to browse the schema and run queries without living in the terminal. There is nothing container-specific about the connection, which is the point of publishing the port.
Work With Your Databases Like A Pro
Query, explore, and manage your databases with a beautiful desktop app and built-in AI.
Download Now
Running init scripts on first start
Anything you put in /docker-entrypoint-initdb.d runs when the database is first created. This is how you seed a schema without baking a custom image.
Supported file types are .sh, .sql, .sql.gz, .sql.xz, and .sql.zst. Anything else is ignored. Files run in locale-sorted glob order, which is why the 01-, 02- naming convention exists.
Shell scripts behave differently depending on the executable bit: an executable .sh file is executed, a non-executable one is sourced into the entrypoint's own shell. Sourced scripts can therefore change the entrypoint's environment, which is occasionally useful and occasionally baffling.
Three things about this mechanism cause real pain.
They only run when the data directory is empty. Add a new script to an existing volume and it will not run. Nothing warns you:
Empty. The script was skipped. Init scripts are for bootstrapping a fresh database, not for migrations. Use a real migration tool for schema changes.
psql -h localhost fails inside an init script. During initialization the server runs with listen_addresses='', meaning Unix socket only, so anything reaching for TCP gets:
Drop the -h flag. Scripts run as the postgres user against POSTGRES_DB already, so psql -c '...' is all you need.
A failing script leaves you half-initialized. If a script errors out, the entrypoint stops, but the data directory is now non-empty. Restarting skips initialization entirely and you get a database with part of your schema and no warning. The fix is to remove the volume and start over, not to restart:
Postgres with Docker Compose
For anything beyond a throwaway container, use Compose. Here is a complete compose.yaml that works on Postgres 18:
With a .env file next to it:
Several details in that file are deliberate.
There is no version: key. It is obsolete. Compose validates against the latest schema regardless and warns if you include it. Any tutorial opening with version: '3.8' was written for a tool that no longer works that way.
${POSTGRES_PASSWORD:?...} fails loudly if the variable is missing, rather than starting a container you cannot log into:
shm_size: 256mb raises shared memory from Docker's 64 MB default. Parallel queries allocate shared memory segments, and on the default you eventually hit could not resize shared memory segment. Docker's own sample sets this without explaining why.
The healthcheck forces TCP, and that is the whole point. The official Postgres image ships no HEALTHCHECK of its own, so depends_on: condition: service_healthy does nothing at all unless you define one. Most tutorials that do define one get it subtly wrong.
The healthcheck that lies
A bare pg_isready connects over the Unix socket. During initialization, the entrypoint runs a temporary server on that socket while your init scripts execute. So pg_isready reports success while the database is still being built.
Watching a container with a deliberately slow init script:
The socket check is a false positive. Because the init server sets listen_addresses='', forcing TCP with -h 127.0.0.1 fails until the real server is up, which is exactly the behaviour you want. That is why the healthcheck above passes -h 127.0.0.1.
Worth knowing about the honest limits of pg_isready: it reports whether the server is responding, and validates nothing else. Per the Postgres docs, correct user and database names are not required to get a status back. It will return success even if POSTGRES_DB does not exist yet. If you want certainty, run an actual query instead:
Then an app service can wait for a database that is genuinely ready:
Note the $$ in the healthcheck. Compose interpolates $, so a single $POSTGRES_USER would be substituted by Compose at parse time rather than by the shell inside the container.
Using a custom postgresql.conf
For a handful of settings, pass them as command flags. This is the least fragile option because it does not depend on file paths or permissions:
For a full config file, mount it and point Postgres at it:
Mount the file read-only. A full config file replaces the defaults rather than layering on top of them, so start from the one the image generated rather than from a blank file:
Backing up and restoring
Use pg_dump, not a copy of the volume. A filesystem copy of a running database is not guaranteed to be consistent.
Restoring a custom-format dump:
And a plain SQL dump:
Note -i on restore and its absence on backup. Restores read the dump from your host on stdin, so the container needs stdin attached.
Upgrading Postgres major versions in Docker
Changing postgres:17 to postgres:18 and restarting does not work. Data directories are not compatible across major versions, and the server will refuse to start:
This is a refusal, not corruption. Your data is intact. Point the old tag at it and it will start again.
The official image deliberately does not handle major upgrades, because doing so requires both versions' binaries present at once. That leaves you three options.
Dump and restore. Always works, needs downtime, and is the one to pick unless you have a reason not to:
pg_upgrade --link. Much faster on large databases. This is what the Postgres 18 directory layout was designed to enable, since the version-specific subdirectory means the upgrade no longer crosses a mount boundary. It needs an image with both versions installed.
pgautoupgrade. A third-party drop-in image that detects the existing version and upgrades in place. Convenient, but it is not the official image and it modifies your data directory in place. Their own docs are explicit that you are expected to have backups first.
Whichever you choose, take a dump before you start.
Troubleshooting
Error: in 18+, these Docker images are configured to store database data...
You mounted /var/lib/postgresql/data on Postgres 18 or later. Mount /var/lib/postgresql instead. See the PGDATA section above.
If this appeared after changing your image tag from 17 to 18, you have an actual Postgres 17 data directory and the message is telling you a version upgrade is needed, not just a path change. Go to the upgrade section.
database files are incompatible with server
You pointed a newer Postgres at an older data directory. Nothing is broken. Start the old version again to get your data back, then follow the upgrade path above.
bind: address already in use
Something already owns port 5432 on your host, usually a local Postgres install. Find it:
Either stop it, or publish on a different host port with -p 5433:5432.
could not translate host name "postgres" to address
Your app container cannot resolve the database's hostname. Both containers need to be on the same Docker network, and the hostname must be the Compose service name. Compose does this for you within one file. Across separate files, you need an explicit shared network.
password authentication failed for user "postgres"
Almost always because POSTGRES_PASSWORD was changed after the first start, where it has no effect. Either reset it with ALTER USER, or remove the volume and start fresh. Note that docker exec ... psql will still connect without a password over the socket, which is not evidence the password works.
could not resize shared memory segment
Docker's 64 MB /dev/shm default is too small for parallel queries. Set shm_size: 256mb in Compose, or --shm-size=256m on docker run.
chmod: changing permissions of '/var/lib/postgresql/...': Permission denied
A bind mount where the entrypoint cannot take ownership of the data directory. Common on macOS, NFS, and SMB. Use a named volume instead.
initdb: could not look up effective user ID 1000
You passed --user 1000:1000 with a UID that does not exist in the container's /etc/passwd. initdb needs to resolve the UID to a name. Either mount /etc/passwd:/etc/passwd:ro, or drop the --user flag and let the entrypoint drop privileges itself.
Init scripts did not run and there is no error
The data directory was not empty, so initialization was skipped. This is by design. Remove the volume (docker compose down -v) and start again. If a previous init script failed partway, this is also what you are seeing.
Should you run Postgres in Docker in production?
For local development and CI, absolutely. Matching your production Postgres version exactly, in a disposable container, is straightforwardly better than a system-wide install.
For production, it depends on what you are already doing. Postgres in a container is not slower in any way that matters. Storage is the whole question. You need a volume backed by real, durable storage rather than an ephemeral disk; you need backups that are tested; and you need to be sure that whatever orchestrates your containers will not reschedule the database onto a node where its data is not.
If you already run stateful workloads on Kubernetes with an operator that understands Postgres, containerized Postgres is reasonable. If you are reaching for Docker because it makes the database easier to set up, a managed service will almost certainly cost you less than the first serious incident will.
The honest version: Docker solves packaging. It does not solve the parts of running a database that are actually hard, which are backups, upgrades, failover, and knowing that your storage did what it claimed.
Bottom line
The one command worth remembering, current as of Postgres 18:
The rules behind it:
- Mount
/var/lib/postgresqlon 18+,/var/lib/postgresql/dataon 17 and earlier. - Pin the major version.
latestwill eventually hand you an incompatible data directory. - Environment variables only apply on first start. Changing
POSTGRES_PASSWORDlater does nothing. - Init scripts only run on an empty data directory. They are not migrations.
- Force TCP in your healthcheck. A socket
pg_isreadyreports healthy before the database is ready. - Plan the upgrade before you need it. Changing the tag is not an upgrade.
Once it is running and you are looking at real data, how to fix slow Postgres queries picks up where this leaves off.
Jay
Keep Reading
How to Fix Slow Postgres Queries
A practitioner's guide to diagnosing and fixing slow Postgres queries. The diagnostic stack, reading EXPLAIN like a power user, index design, the configuration knobs that change query plans, and the tools worth installing.
ClickHouse vs PostgreSQL: OLAP vs OLTP
ClickHouse and PostgreSQL serve different purposes. Here's when to use a columnar analytics database vs a general-purpose relational database.