Skip to content
datahacks.dev
PostgreSQL

Running PostgreSQL in Docker With a Persistent Volume

Run PostgreSQL in Docker with a named volume so data survives restarts, set the password and database on first start, publish the port safely and use Compose.

7 min read
Summarize in AI
Flat illustration of shipping containers stacked on a ship, with the Docker logo.

A container is the quickest way to get a clean PostgreSQL on any machine, and it has one trap that catches nearly everyone once: containers are disposable, and a database inside a container's own filesystem is disposable with it. Remove the container and the data goes too. The fix is a volume, and with it the container becomes the right tool: start a server in a second, throw it away, start another against the same data. This article shows the command, the settings that apply only on first start, the Compose version and the mistakes around ports and versions. It is the container route in setting up PostgreSQL for development without fighting it, and the native route is in installing PostgreSQL on macOS, Linux and Windows. Once it runs, creating your first database user and schema in PostgreSQL is the next step.

The command

Shell
docker volume create pg17data

docker run --name pg17 -d \
  -e POSTGRES_PASSWORD=dev-only-password \
  -e POSTGRES_USER=shop \
  -e POSTGRES_DB=shop \
  -p 127.0.0.1:5432:5432 \
  -v pg17data:/var/lib/postgresql/data \
  postgres:17

Each part has a job.

--name pg17 gives the container a stable name for later commands (docker stop pg17, docker logs pg17). -d runs it in the background.

-e POSTGRES_PASSWORD=... is required; the image refuses to start without a password for the superuser (or an explicit decision to allow trust authentication, which you should not make). POSTGRES_USER names the superuser, defaulting to postgres; POSTGRES_DB names a database to create, defaulting to the user's name.

-p 127.0.0.1:5432:5432 publishes container port 5432 on the host's loopback interface only. Written as -p 5432:5432, Docker publishes on all interfaces, which on a laptop on a shared network exposes the database. If port 5432 is taken on the host by another PostgreSQL, map a different host port: -p 127.0.0.1:5433:5432, and connect to 5433.

-v pg17data:/var/lib/postgresql/data mounts the named volume at the data directory, so the database files live in the volume.

postgres:17 pins the major version. A bare postgres means latest, which moves to the next major when it is released; an existing volume created by 17 will not start under 18 (the server says the files are from an incompatible version) and you are left with an upgrade you did not plan.

Check it:

Shell
docker logs pg17 | tail -5
docker exec pg17 pg_isready -U shop -d shop
psql "postgresql://shop:[email protected]:5432/shop" -c "SELECT version();"

The log should end with database system is ready to accept connections.

The first-start-only rule

The image's entrypoint looks at the data directory when the container starts. If it is empty, the entrypoint runs initdb, creates the superuser and database from the environment variables and runs any initialization scripts. If it already contains a cluster, it skips all of that and starts the server.

So changing POSTGRES_PASSWORD in a command or a Compose file and restarting does nothing to an existing volume: the old password still applies. That is the single most common Docker PostgreSQL confusion ("I set the password but it says authentication failed"). To change a password, change it inside the database (ALTER ROLE shop PASSWORD '...'). To start over with new settings, remove the volume, which deletes the data:

Shell
docker rm -f pg17
docker volume rm pg17data

Do this only when you mean it. docker volume ls shows which volumes exist, and docker volume inspect pg17data shows where the data lives on the host.

Initialization scripts

Files placed in /docker-entrypoint-initdb.d/ run once, on first start, in alphabetical order. They can be .sql, .sql.gz or .sh. This is the right place for schema and seed data.

Shell
docker run --name pg17 -d \
  -e POSTGRES_PASSWORD=dev-only-password \
  -v pg17data:/var/lib/postgresql/data \
  -v "$PWD/db/init":/docker-entrypoint-initdb.d:ro \
  postgres:17

Scripts run as the superuser against the POSTGRES_DB database. They run only when the data directory is empty, so editing them later has no effect on an existing volume; apply changes with migrations instead. A failing script aborts the initialization, and the container exits. Check docker logs for the line with the error.

Docker Compose

Compose records the same settings in a file the whole team shares.

YAML
services:
  db:
    image: postgres:17
    container_name: shop-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: dev-only-password
      POSTGRES_DB: shop
    ports:
      - "127.0.0.1:5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:

docker compose up -d starts it, docker compose logs -f db follows the log, docker compose down stops and removes the container but keeps the volume, and docker compose down -v also removes the volumes, which deletes the data. The health check lets other services use depends_on with condition: service_healthy to wait until the database accepts connections. Keep real passwords out of the file: use an .env file that Compose reads (and that is not committed), or Docker secrets.

Other containers on the same Compose network reach the database at the hostname db (the service name) on port 5432, not localhost and not the published host port. A web service whose connection string says localhost inside its own container is talking to itself.

Named volume or bind mount

A named volume is managed by Docker, with good performance on all platforms and a layout you do not touch. It is the right default. A bind mount (-v ./pgdata:/var/lib/postgresql/data) maps a host directory, which makes the files visible and easy to back up, and brings trouble: on Docker Desktop for Mac and Windows the performance can be poor, permissions between host and container must line up (the data directory must be owned by the container's postgres user and mode 0700) and a stray git add can try to commit a database. Use a named volume unless you have a reason.

Where the data directory is

The mount path above, /var/lib/postgresql/data, was the image's data directory for many versions. Newer major versions of the official image moved the default data directory to a version-specific location under /var/lib/postgresql, and recommend mounting the volume at that parent path. Mounting at the old path with a new image can create an anonymous volume that you do not expect. Read the image's Docker Hub page for the exact guidance for your major version before you choose the mount, and verify with docker exec pg17 psql -U shop -c "SHOW data_directory;" that the path reported is the one you mounted.

Backups and moving data

A volume is not a backup. Take dumps from the running container:

Shell
docker exec pg17 pg_dump -U shop -Fc shop > shop.dump
cat shop.dump | docker exec -i pg17 pg_restore -U shop -d shop_copy --no-owner

Create the target database first. The dump format is portable across hosts and across minor versions. To copy a volume's raw files to another machine, stop the container first; copying a running data directory yields an inconsistent copy.

Settings and extensions

Pass server settings on the command line after the image name: postgres:17 -c log_min_duration_statement=200 -c shared_buffers=256MB. Or mount a config file and point to it with -c config_file=/etc/postgresql/postgresql.conf. Extensions that ship with the server (pg_stat_statements, pgcrypto, pg_trgm) are available in the standard image; others need a custom image built FROM postgres:17 with the package added. Extensions are enabled with CREATE EXTENSION in each database that uses them.

Give the container a reasonable shared memory size if you raise shared_buffers a lot (--shm-size=256m), since the container's default /dev/shm is small.

Common problems

Authentication failed after changing the password variable. The volume already existed; see above.

Container exits immediately. docker logs shows why: a missing POSTGRES_PASSWORD, an incompatible data directory or a failing init script.

Port already allocated. Another process holds 5432. Change the host port.

Connection refused from the host right after start. The server is still initializing; wait for the ready message or use the health check.

Data gone. The container ran without a volume, or docker compose down -v ran. A dump is the only protection.

Permission denied on a bind mount. The directory's owner or mode is wrong for the container user.

When Docker is the wrong way to run PostgreSQL

Docker is a good fit for development and tests. In production it is a decision with trade-offs: you take on the care of volumes, upgrades, backups and storage performance, which a managed service or a well-run native install handles for you. On a machine with limited resources, or on Docker Desktop with slow file sharing, a native install may feel faster. And when a team shares one long-lived development database, a server with proper backups beats everyone's laptop containers. Use containers for the disposable parts. The official image page documents every variable and path, and the PostgreSQL manual on server setup covers the settings inside.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.