Limited Time Offer: 40% off
Back to Blog

Postgres Docker: The Complete 2026 Guide

JayJay

Here is a Postgres container that works today, on Postgres 18:

BASH
docker run -d \
  --name postgres \
  -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 \
  -v pgdata:/var/lib/postgresql \
  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:

BASH
docker logs postgres

The last line you want to see is:

database system is ready to accept connections

Open a psql session inside the container:

BASH
docker exec -it postgres psql -U postgres
psql (18.4 (Debian 18.4-1.pgdg13+1))
Type "help" for help.

postgres=#

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:

BASH
docker stop postgres && docker rm postgres

Your data survives that, because it lives in the pgdata volume rather than the container. Removing the volume is what destroys it:

BASH
docker volume rm pgdata

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 laterPostgres 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:

BASH
docker run -d -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data postgres:18
Error: in 18+, these Docker images are configured to store database data in a
       format which is compatible with "pg_ctlcluster" (specifically, using
       major-version-specific directory names).  This better reflects how
       PostgreSQL itself works, and how upgrades are to be performed.

       See also https://github.com/docker-library/postgres/pull/1259

       Counter to that, there appears to be PostgreSQL data in:
         /var/lib/postgresql/data (unused mount/volume)

       This is usually the result of upgrading the Docker image without
       upgrading the underlying database using "pg_upgrade" (which requires both
       versions).

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:

BASH
# Postgres 17: this looks reasonable and silently loses your data
docker run -d -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql postgres:17

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.

TagBaseSizeUse when
18 / 18-trixie / latestDebian 13 (trixie)~440 MBDefault. Widest extension support.
18-alpineAlpine 3.24~280 MBImage size matters more than extensions
18-bookwormDebian 12~440 MBYou need to match an older host toolchain
18.4Debian 13~440 MBReproducible 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:

BASH
docker exec postgres psql -U postgres -tAc 'SHOW server_version;'
18.4 (Debian 18.4-1.pgdg13+1)

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:

BASH
-v pgdata:/var/lib/postgresql

Bind mounts map a host directory into the container:

BASH
-v /Users/you/pgdata:/var/lib/postgresql

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:

BASH
docker exec postgres psql -U postgres -c \
  "CREATE TABLE persist_check(id int); INSERT INTO persist_check VALUES (42);"
docker restart postgres
docker exec postgres psql -U postgres -tAc "SELECT id FROM persist_check;"
42

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.

VariableDefaultNotes
POSTGRES_PASSWORDnoneRequired, unless POSTGRES_HOST_AUTH_METHOD=trust
POSTGRES_USERpostgresSets the superuser name. Does not create an extra role.
POSTGRES_DBvalue of POSTGRES_USERNot postgres. See below.
POSTGRES_INITDB_ARGSnonePassed through to initdb
POSTGRES_HOST_AUTH_METHODscram-sha-256trust disables passwords entirely
PGDATA/var/lib/postgresql/18/dockerChanging 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:

BASH
docker run -d -e POSTGRES_PASSWORD=secret -e POSTGRES_USER=myapp postgres:18
docker exec <container> psql -U myapp -d myapp -tAc \
  "SELECT datname FROM pg_database WHERE datistemplate=false ORDER BY 1;"
myapp
postgres

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:

BASH
docker exec -it postgres psql -U postgres -c \
  "ALTER USER postgres WITH PASSWORD 'newsecret';"

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:

BASH
docker exec -it postgres psql -U postgres -h 127.0.0.1

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):

BASH
docker exec -it postgres psql -U postgres -d myapp

From your host machine, you need the port published with -p 5432:5432, and then Postgres is just a normal server on localhost:

BASH
psql -h localhost -p 5432 -U postgres -d myapp

If port 5432 is already taken, usually by a Homebrew or Postgres.app install, you will see:

Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:5432 -> 127.0.0.1:0: listen tcp 0.0.0.0:5432: bind: address already in use

Remap the host side and leave the container side alone:

BASH
-p 5433:5432

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:

BASH
# From an app container, on a shared network:
psql -h postgres -p 5432 -U postgres -d myapp

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.

DB Pro

Work With Your Databases Like A Pro

Query, explore, and manage your databases with a beautiful desktop app and built-in AI.

Download Now
DB Pro Dashboard

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.

BASH
docker run -d \
  --name postgres \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql \
  -v ./initdb:/docker-entrypoint-initdb.d \
  postgres:18

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:

BASH
# Second start, with a brand new 02-second.sql added:
docker exec postgres psql -U postgres -tAc "SELECT to_regclass('second_run');"

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:

psql: error: connection to server at "localhost" (::1), port 5432 failed: Connection refused
	Is the server running on that host and accepting TCP/IP connections?

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:

BASH
docker compose down -v    # or: docker volume rm pgdata

Postgres with Docker Compose

For anything beyond a throwaway container, use Compose. Here is a complete compose.yaml that works on Postgres 18:

YAML
services:
  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
      POSTGRES_USER: myapp
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql
    shm_size: 256mb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s

volumes:
  pgdata:

With a .env file next to it:

POSTGRES_PASSWORD=secret
BASH
docker compose up -d

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:

error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD

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:

bare pg_isready (socket)      -> ACCEPTING   (init still running)
pg_isready -h 127.0.0.1 (TCP) -> NOT READY   (init still running)

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:

YAML
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1 -c 'SELECT 1' || exit 1"]

Then an app service can wait for a database that is genuinely ready:

YAML
  app:
    image: myapp:latest
    depends_on:
      db:
        condition: service_healthy

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:

YAML
    command:
      - "postgres"
      - "-c"
      - "shared_buffers=512MB"
      - "-c"
      - "max_connections=200"

For a full config file, mount it and point Postgres at it:

YAML
    volumes:
      - pgdata:/var/lib/postgresql
      - ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
    command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]

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:

BASH
docker exec postgres cat /var/lib/postgresql/18/docker/postgresql.conf > postgresql.conf

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.

BASH
# Back up a single database
docker exec postgres pg_dump -U myapp -d myapp -Fc > myapp.dump

# Back up everything, including roles
docker exec postgres pg_dumpall -U postgres > cluster.sql

Restoring a custom-format dump:

BASH
docker exec -i postgres pg_restore -U myapp -d myapp --clean --if-exists < myapp.dump

And a plain SQL dump:

BASH
docker exec -i postgres psql -U postgres < cluster.sql

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:

FATAL:  database files are incompatible with server
DETAIL:  The data directory was initialized by PostgreSQL version 17, which is not compatible with this version 18.4.

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:

BASH
# On the old version
docker exec postgres-17 pg_dumpall -U postgres > cluster.sql

# Start a fresh 18 container with a NEW volume, then
docker exec -i postgres-18 psql -U postgres < cluster.sql

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:

BASH
lsof -i :5432

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:

BASH
docker run -d \
  --name postgres \
  -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 \
  -v pgdata:/var/lib/postgresql \
  --shm-size=256m \
  postgres:18

The rules behind it:

  1. Mount /var/lib/postgresql on 18+, /var/lib/postgresql/data on 17 and earlier.
  2. Pin the major version. latest will eventually hand you an incompatible data directory.
  3. Environment variables only apply on first start. Changing POSTGRES_PASSWORD later does nothing.
  4. Init scripts only run on an empty data directory. They are not migrations.
  5. Force TCP in your healthcheck. A socket pg_isready reports healthy before the database is ready.
  6. 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