Skip to content

Latest commit

 

History

History
131 lines (106 loc) · 5.39 KB

File metadata and controls

131 lines (106 loc) · 5.39 KB

postgres-barman

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
Loading

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.

Using it

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 gosu

archive_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.

Two things that will bite you

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.

Why not ghcr.io/cloudnative-pg/postgresql

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.

Everything is pinned, in both directions

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 together

Consumers 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.