Postgres
| Use | Image | Status |
|---|---|---|
| Postgres | postgres:18.6-trixie | default |
| Postgres | postgres:17.11-trixie | allowed (N-1) |
| PostGIS | postgis/postgis:18-3.6 | default |
Pinned, as every compose file and Dockerfile writes them:
postgres:18.6-trixie@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
postgres:17.11-trixie@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675
postgis/postgis:18-3.6@sha256:60f6ad1d21ea86a67d47780b9a0d1e1d200500f62b19293fa834d0dea80b8677The 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 -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:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280docker 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.
mkdir -p secrets
openssl rand -hex 24 > secrets/postgres_password
sudo chown 999:999 secrets/postgres_password
sudo chmod 0400 secrets/postgres_passwordservices:
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_passworddocker 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 thepostgresuser (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 on0.0.0.0.
Upgrading
Default 18, allowed 17. Anything older moves by its deadline:
| Major | Upstream end of life | Deadline here |
|---|---|---|
| 14 | 2026-11-12 | now |
| 15 | 2027-11-11 | none in use |
| 16 | 2028-11-09 | before 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.