Skip to content

Postgres ​

UseImageStatus
Postgrespostgres:18.6-trixiedefault
Postgrespostgres:17.11-trixieallowed (N-1)
PostGISpostgis/postgis:18-3.6default

Pinned, as every compose file and Dockerfile writes them:

text
postgres:18.6-trixie@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
postgres:17.11-trixie@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675
postgis/postgis:18-3.6@sha256:60f6ad1d21ea86a67d47780b9a0d1e1d200500f62b19293fa834d0dea80b8677

The tags and digests come from the approved images table; when they disagree, the table wins. Everything below works for PostGIS too: swap the image and keep the rest.

Local development ​

docker run
sh
docker run -d --name postgres-dev \
  -p 127.0.0.1:5432:5432 \
  -e POSTGRES_USER=app -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=app \
  -v postgres-dev-data:/var/lib/postgresql \
  --shm-size 256m \
  --health-cmd 'pg_isready -U app -d app' \
  --health-interval 5s --health-timeout 5s --health-retries 10 \
  postgres:18.6-trixie@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
check docker run
sh
docker exec postgres-dev psql -U app -d app -c 'select version()'

Connect with postgres://app:dev@127.0.0.1:5432/app?sslmode=disable. Remove it, data included, with docker rm -f postgres-dev && docker volume rm postgres-dev-data.

On a server ​

Prepare the host once, next to the compose file. The password is a file the container reads at start, never an environment value that docker inspect shows.

prepare the host
sh
mkdir -p secrets
openssl rand -hex 24 > secrets/postgres_password
sudo chown 999:999 secrets/postgres_password
sudo chmod 0400 secrets/postgres_password
compose.yml
yaml
services:
  postgres:
    image: postgres:18.6-trixie@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets: [postgres_password]
    volumes:
      - postgres-data:/var/lib/postgresql
    ports:
      - "127.0.0.1:5432:5432"
    shm_size: 256m
    read_only: true
    tmpfs:
      - /var/run/postgresql:size=16m,uid=999,gid=999,mode=2775
      - /tmp:size=64m,mode=1777
    cap_drop: [ALL]
    cap_add: [CHOWN, DAC_READ_SEARCH, SETGID, SETUID]
    security_opt:
      - no-new-privileges:true
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "app", "-d", "app"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    stop_grace_period: 1m
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  postgres-data: {}

secrets:
  postgres_password:
    file: ./secrets/postgres_password
check compose
sh
docker compose exec postgres psql -U app -d app -c 'select version()'

The service connects with the same password, from its own .env, as postgres://app:<password>@postgres:5432/app.

Why each line is there:

  • /var/lib/postgresql, not /var/lib/postgresql/data. From 18 the image keeps its data in a per-major folder under /var/lib/postgresql, so a volume on the old path is not where the data is. On 17, mount /var/lib/postgresql/data.
  • shm_size: 256m. Docker's default 64 MB of shared memory is too little for parallel queries, which fail with "could not resize shared memory segment".
  • The capabilities. The entrypoint starts as root, reads the secret (DAC_READ_SEARCH: read-only, since the file belongs to 999), fixes the data folder's owner and drops to the postgres user (999); these four are what that needs, and they are gone once the server runs.
  • The secret is owned by 999 and 0400. Only the database user can read it, inside the container and on the host.
  • read_only. Postgres writes only to its volume, its socket folder and /tmp, which are mounted writable.
  • stop_grace_period: 1m. Postgres finishes a clean shutdown in that time; Docker's default 10 seconds can kill it mid-checkpoint, and the next start replays the WAL.
  • Port on 127.0.0.1. The database is reached by the service over the compose network; the loopback port is for a person on the host. Never publish it on 0.0.0.0.

Upgrading ​

Default 18, allowed 17. Anything older moves by its deadline:

MajorUpstream end of lifeDeadline here
142026-11-12now
152027-11-11none in use
162028-11-09before 2028-11

A major upgrade is not a tag change. Postgres refuses to start on a data directory from another major. Move with pg_upgrade, or with pg_dump and pg_restore into a fresh server.

Alpine to Debian on the same major - keeping the data volume and changing only the image - leaves every text index sorted by musl's rules while glibc now does the comparing. Reindex the text-collated indexes (REINDEX DATABASE is the blunt version), and check with pg_amcheck --heapallindexed before taking traffic. A dump and restore rebuilds every index, so it needs no reindex.

PostGIS moves with its Postgres major. After the upgrade, run SELECT postgis_extensions_upgrade(); in every database that uses it.