postgres:18 + barman-cloud-*, for continuous WAL archiving and point-in-time
recovery.
ghcr.io/metsaapp/postgres-barman:18
ghcr.io/metsaapp/postgres-barman:18-20260712 # immutable
One image, every database that backs up to object storage. It is deliberately
generic: nothing in it knows which database it will hold. The bucket, the server
name and the credentials all arrive as the accessory's cmd: and environment.
flowchart LR
subgraph img ["postgres-barman"]
PG["postgres 18"]
BC["barman-cloud-*"]
end
PG -->|"archive_command<br/>(every WAL segment)"| S3[("S3 bucket")]
PG -->|"barman-cloud-backup<br/>(nightly base backup)"| S3
S3 -->|"barman-cloud-restore<br/>+ wal-restore"| REC["recovered database"]
C1["your databases"] --> img
style S3 fill:#1f6feb,color:#fff
style img fill:#8957e5,color:#fff
style REC fill:#238636,color:#fff
WAL archiving alone cannot restore anything: a base backup is what those segments replay from. You need both halves, which is why this image carries the tools for both and why pgctl drills the result on a schedule.
A Kamal accessory, roughly:
postgres:
image: ghcr.io/metsaapp/postgres-barman:18
env:
clear:
POSTGRES_USER: myapp
POSTGRES_DB: myapp
AWS_DEFAULT_REGION: us-east-1
secret:
- POSTGRES_PASSWORD
- AWS_ACCESS_KEY_ID # barman-cloud-* reads these straight from the
- AWS_SECRET_ACCESS_KEY # container's environment
directories:
# The PARENT, not .../data. PG18 stores data in a version-specific subdir
# (/var/lib/postgresql/18/docker) and declares its volume at the parent.
- postgres_data:/var/lib/postgresql
cmd: >-
postgres
-c wal_level=replica
-c archive_mode=on
-c archive_timeout=3600
-c archive_command="barman-cloud-wal-archive -z --cloud-provider aws-s3 --endpoint-url https://s3.example.com s3://my-backups/myapp myapp-production %p"
-c shared_preload_libraries=pg_stat_statements
options:
user: "0" # the entrypoint chowns PGDATA, then drops to postgres via gosuarchive_mode only takes effect on a full restart, never a reload.
pg_stat_statements is baked in (init/), because every consumer wanted it and
each was mounting its own identical copy of the same SQL file. The library still
has to be preloaded by the caller, since that is a server setting rather than an
image one.
Start it as root. The official entrypoint chowns PGDATA (a Kamal bind mount
arrives owned by the ssh user) and then exec gosu postgres. Skip that -- by
setting --entrypoint postgres, or a non-root user: -- and the server refuses
outright: "root" execution of the PostgreSQL server is not permitted.
An empty data directory boots as a healthy empty database. The entrypoint
decides whether a data directory is populated by testing -s $PGDATA/PG_VERSION.
Find nothing, and it runs initdb. So a restore that silently produced an empty
directory comes up as a pristine, ready, empty Postgres that passes every
health check you would think to write. pgctl asserts PG_VERSION between restore
and boot for exactly this reason, and it is the single most important guard in the
drill.
It already bundles Barman, and it was the first choice. Their PG18 image dropped
the entrypoint entirely (Entrypoint=[], Cmd=[bash]) -- it is a bare server
image for the CloudNativePG Kubernetes operator, which supplies its own init.
Their PG17 image was still built FROM the official one and worked as a drop-in;
18 is not. Booting it under Kamal put postgres up as root, and the server refused.
The official image keeps the contract the accessories depend on:
POSTGRES_USER/_DB/_PASSWORD init, /docker-entrypoint-initdb.d, and the
chown-then-drop-privileges startup.
The base is pinned by digest, not by postgres:18. That tag is repointed on
every patch release, so two builds of an identical Dockerfile can produce
different servers -- the wrong property for the image you restore a database with.
It is not just the version number. This image's contract with pgctl is that PGDATA
lives at /var/lib/postgresql/18/docker, that the entrypoint chowns it and drops
privileges, and that it runs initdb when it finds no PG_VERSION there. pgctl
hardcodes that path and leans on that behaviour. The digest makes that contract one
fixed thing.
The build asserts what the digest is supposed to contain (PG_MAJOR, PG_VERSION,
and that the official entrypoint is still there), so a careless bump fails the
build rather than silently shipping a Postgres whose data directory has moved. To
bump it, deliberately, in a PR:
docker buildx imagetools inspect postgres:18 --format '{{.Manifest.Digest}}'
# update PG_IMAGE and EXPECT_PG_VERSION togetherConsumers pin us the same way. Every push to main publishes :18 and an
immutable :18-YYYYMMDD; pgctl's recovery image pins this one by digest at release
time. A moving tag means a rebuild silently changes what your database pulls on its
next accessory restart, and there is no way to say "the image we were running last
Tuesday".
The build also fails if any of the six barman-cloud-* tools is missing, rather
than letting you discover at 3am that archiving was never wired.