Postgres in Docker Compose

services: some-postgres: image: postgres:18 volumes: - pgdata:/var/lib/postgresql environment: POSTGRES_PASSWORD: mysecretpassword shm_size: 256MB volumes: pgdata: name: pgdata

In Docker Compose, Postgres 18 keeps its data in a volume mounted at /var/lib/postgresql: the line pgdata:/var/lib/postgresql under the service’s volumes. Postgres 17 and older use /var/lib/postgresql/data. The one variable the image requires is POSTGRES_PASSWORD.

The docker run command, converted

This is the storage command from the image’s Docker Hub page, with the tag pinned, a named volume in place of the host folder, and the --shm-size its caveats suggest:

docker run --name some-postgres --shm-size=256MB -v pgdata:/var/lib/postgresql -e POSTGRES_PASSWORD=mysecretpassword -d postgres:18
services:
  some-postgres:
    image: postgres:18
    volumes:
      - pgdata:/var/lib/postgresql
    environment:
      POSTGRES_PASSWORD: mysecretpassword
    shm_size: 256MB
volumes:
  pgdata:
    name: pgdata

name: pgdata makes Compose use the volume docker run created, data and all. --shm-size becomes shm_size, written as given: the default 64 MB of /dev/shm can fail large queries with could not resize shared memory segment. -d needs no key.

Postgres 18 moved the data directory

From 18 on, the image sets PGDATA to /var/lib/postgresql/18/docker and declares its volume one level up, so a later pg_upgrade --link sees both versions in one mount. A file carried over from 17 with /var/lib/postgresql/data doesn’t start: the container exits with Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster". On 17 and older the opposite mistake is silent: a mount at /var/lib/postgresql sits above the data, which goes to an anonymous volume and is gone when the container is recreated.

A database the app waits for

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: mysecretpassword
    volumes:
      - pgdata:/var/lib/postgresql
    ports:
      - "127.0.0.1:5432:5432"
    shm_size: 256MB
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h localhost -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 10
  adminer:
    image: adminer:6
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      db:
        condition: service_healthy
volumes:
  pgdata:

-h localhost makes pg_isready use TCP. While the entrypoint runs initdb and the scripts in /docker-entrypoint-initdb.d, a temporary server listens on the Unix socket only, so a plain pg_isready already says accepting connections and the app connects to a server that is about to restart.

Gotchas

  • POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB and the init scripts only act on an empty data directory. Changing them later does nothing; use ALTER USER instead.
  • docker compose down -v deletes the volumes Compose created, database included. A name: volume that docker run made first is left in place.
  • "5432:5432" listens on every interface, and on Linux Docker’s iptables rules let it past ufw. 127.0.0.1: keeps it local; other services reach db:5432 without any ports.
  • postgres:18 follows minor releases (18.6 today) and postgres:18.6 pins one. latest moves to 19 when it ships.

GitHub Actions

A service container takes docker create flags under options, and the job’s steps start once it’s healthy:

services:
  postgres:
    image: postgres:18
    env:
      POSTGRES_PASSWORD: postgres
    ports:
      - 5432:5432
    options: >-
      --health-cmd "pg_isready -h localhost -U postgres"
      --health-interval 5s
      --health-retries 10

Try it in the docker run to Compose Converter

compose.yaml

1 service · 1 thing left out

Left out, and why

  • -dCompose starts services in the background already, so this flag needs no key.

Runs in your browser. Nothing you enter is sent anywhere.