- Shell 89.2%
- Dockerfile 10.8%
psql does not interpolate :'db'/:"db" variables in -c command strings (only via stdin/file), so docker_provision_databases sent the literal :'db' to the server and aborted init with a syntax error as soon as a database was provisioned. Interpolate the names (constrained to [a-z0-9_] by their env var origin) directly in bash instead, matching how docker_provision_roles already builds its SQL. Add test/smoke-test.sh: builds the image and asserts provisioning, tenant isolation, config pass-through, the pg_cron-in-postgres fix, and seed-vs-reconcile behaviour end-to-end. All 14 checks pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| docs/adr | ||
| test | ||
| .dockerignore | ||
| .gitignore | ||
| CONTEXT.md | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| README.md | ||
PostgreSQL 18 Image
Production-ready PostgreSQL 18 Docker image with dynamic UID/GID, 12 pre-installed extensions (+1 optional), non-root execution, and automatic version mismatch detection.
src.ci/srv/postgres:latest
Quick Start
services:
db:
image: "src.ci/srv/postgres:latest"
volumes:
- "db-volume:/data"
environment:
POSTGRES_PASSWORD: "changeme"
docker run -d -e POSTGRES_PASSWORD=changeme -v db-data:/data src.ci/srv/postgres:latest
Key Features
- Non-root execution — runs as
postgresuser, never as root - Dynamic UID/GID — configurable at build time to match your host permissions
- 12 extensions — all pre-installed, activate on demand with
CREATE EXTENSION(+ optionalpg_tricklevia build arg) - Auto-configured
pg_cron—shared_preload_librariesset andCREATE EXTENSION pg_cronrun (in thepostgresdatabase) on first init - Declarative databases & roles — create extra databases and roles from
POSTGRES_DB_*/POSTGRES_ROLE_*env vars; owner-provisioned databases are isolated by default - ENV config pass-through — set any
postgresql.confparameter viaPOSTGRES_CONF_*without touching a config file - Version guard — detects old PG16/PG17 data and shows migration instructions
- Healthcheck — built-in
pg_isreadycheck (30s interval, 3s timeout, 4 retries) - Docker secrets — supports
POSTGRES_PASSWORD_FILEand friends - Init scripts — drop
.sh,.sql,.sql.gz,.sql.xz,.sql.zstinto/docker-entrypoint-initdb.d/
Extensions
All extensions are installed from the official PostgreSQL Debian repository, except pg_trickle which is built from source via pgrx and is opt-in at build time (see ENABLE_PG_TRICKLE below).
| Extension | Version | Description |
|---|---|---|
| age | 1.7.0 | Graph database extension (openCypher queries) |
| extra_window_functions | 1.0 | Additional window functions |
| numeral | 1 | Numeral datatypes |
| pg_cron | 1.6 | Job scheduler |
| pg_repack | 1.5.3 | Table reorganization with minimal locks |
| pg_similarity | 1.0 | String similarity functions |
| pg_trickle ¹ | 0.81.0 | Streaming differential view maintenance (opt-in via ENABLE_PG_TRICKLE=true) |
| pgmemcache | 2.3.0 | Memcached interface |
| pgpcre | 1 | Perl Compatible Regular Expressions |
| postgis | 3.6.2 | Geospatial types and functions |
| set_user | 4.2.0 | Dynamic user switching with audit logging |
| table_log | 0.6.1 | Table change logging |
| vector | 0.8.2 | Vector data type for AI/ML (ivfflat, hnsw) |
¹ pg_trickle is only built into the image when the build arg ENABLE_PG_TRICKLE=true is passed (Rust toolchain + pgrx add several minutes and ~1 GB to the build). It is pinned to tag v0.81.0 of trickle-labs/pg-trickle (the project moved from the former grove/pg-trickle). When enabled, it is added to shared_preload_libraries and CREATE EXTENSION pg_trickle is executed automatically in POSTGRES_DB on first init.
pg_cron is always preloaded and activated on first init — no manual postgresql.conf editing required. Because pg_cron is single-database (its scheduler and metadata live in the database named by cron.database_name, default postgres), the extension is created in the postgres database, not in POSTGRES_DB. Schedule jobs against any other database with cron.schedule_in_database('jobname', '* * * * *', 'SQL…', 'target_db').
Databases, roles & configuration
Beyond the official POSTGRES_USER / POSTGRES_DB / POSTGRES_PASSWORD (which keep working
unchanged), this image can provision multiple databases and roles declaratively, and pass
PostgreSQL config through from the environment. See the design rationale in
docs/adr/ and the glossary in CONTEXT.md.
Databases and roles
Objects are declared by embedding their name in the variable name, using a closed set of
suffixes parsed from the right (_OWNER, _OPTIONS, _PASSWORD_FILE, _PASSWORD). The
name is lowercased; underscores in names are fine because only the known suffixes are stripped.
environment:
# role "appuser": can log in and create databases, password from a Docker secret
POSTGRES_ROLE_APPUSER_OPTIONS: "LOGIN,CREATEDB,CONNECTION LIMIT 20"
POSTGRES_ROLE_APPUSER_PASSWORD_FILE: "/run/secrets/appuser_pw"
# role "bi": login only
POSTGRES_ROLE_BI_OPTIONS: "LOGIN"
POSTGRES_ROLE_BI_PASSWORD: "insecure-inline-password" # _FILE is preferred
# database "app" owned by appuser, "my_app" owned by appuser, "scratch" with no owner
POSTGRES_DB_APP_OWNER: "appuser"
POSTGRES_DB_MY_APP_OWNER: "appuser"
POSTGRES_DB_SCRATCH: "1" # bare declaration: no known suffix -> database "scratch"
_OPTIONSis a comma-separated list of standard role attributes (LOGIN,CREATEDB,CONNECTION LIMIT n, …). They map straight ontoCREATE ROLE … WITH ….- A role with
LOGINbut no password aborts startup — unlessPOSTGRES_HOST_AUTH_METHOD=trust— exactly mirroring how the official image treats an emptyPOSTGRES_PASSWORD. - A database literally named
foo_ownercollides with the_OWNERsuffix; create that one via a SQL init script instead.
Tenant isolation
A database declared with an owner is isolated on creation: CONNECT is revoked from
PUBLIC and granted only to its owner, so other roles cannot connect to it. A database without
an owner stays vanilla (any role may connect). The primary POSTGRES_DB is unaffected.
This is connection-level isolation only — catalog names (pg_database, pg_roles) remain
visible server-wide. For full isolation, run separate instances.
Config pass-through
Any POSTGRES_CONF_<PARAM> variable becomes <param> = <value> in an include file
(conf.d/00-env.conf) that is regenerated on every start. Unset a variable and the
parameter falls back to its PostgreSQL default — the image never shifts PostgreSQL's own
defaults on its own.
environment:
POSTGRES_CONF_MAX_CONNECTIONS: "200"
POSTGRES_CONF_SHARED_BUFFERS: "512MB"
POSTGRES_CONF_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: "5min"
Seed vs. reconcile
By default, databases and roles are provisioned only on first init of an empty /data
(the official one-shot behaviour). Set RECONCILE_ON_START=true to additionally re-apply the
declared databases and roles on every start:
- additive — missing databases/roles are created, existing ones are
ALTERed (including re-applying a_PASSWORD_FILE, which enables secret rotation). Objects are never dropped; removing a variable does not delete a database or role. - Config pass-through always applies on every start, regardless of this flag.
Runtime environment variables (this image's additions)
| Variable | Description |
|---|---|
POSTGRES_ROLE_<NAME>_OPTIONS |
Comma-separated role attributes (e.g. LOGIN,CREATEDB) |
POSTGRES_ROLE_<NAME>_PASSWORD_FILE |
File containing the role's password (preferred) |
POSTGRES_ROLE_<NAME>_PASSWORD |
Inline role password (discouraged; use _FILE) |
POSTGRES_DB_<NAME>_OWNER |
Owner role for a provisioned (and isolated) database |
POSTGRES_DB_<NAME> |
Bare database declaration (no owner, no isolation) |
POSTGRES_CONF_<PARAM> |
Sets postgresql.conf parameter <param> |
RECONCILE_ON_START |
true re-applies declared databases/roles on every start |
Upgrading from PG16 or PG17
The entrypoint automatically detects if the mounted data directory (or any subdirectory) contains a database from an older PostgreSQL version and will abort with clear migration instructions:
ERROR: Found PostgreSQL 17 data in /data,
but this container runs PostgreSQL 18.
You have two options:
pg_dump / pg_restore (safe, works always):
# 1. Dump from old container
docker exec old-pg pg_dumpall -U postgres > backup.sql
# 2. Start fresh PG18 container
docker run -d -e POSTGRES_PASSWORD=... -v new-data:/data src.ci/srv/postgres:18
# 3. Restore
docker exec -i new-pg psql -U postgres < backup.sql
pg_upgrade (faster, in-place) — requires both old and new PG binaries. See the pg_upgrade documentation.
Build Arguments
Build your own image with custom UID/GID:
services:
db:
image: "src.ci/srv/postgres:1010"
volumes:
- "db-volume:/data"
environment:
POSTGRES_USER: "myuser"
POSTGRES_DB: "mydb"
POSTGRES_PASSWORD: "mypass"
build:
context: "docker/postgres"
args:
SRV_UID: "1010"
SRV_GID: "1010"
| Argument | Default | Description |
|---|---|---|
SRV_UID |
1000 |
User ID for the postgres user and data ownership |
SRV_GID |
100 |
Group ID for the postgres user and data ownership |
SRV_LOCALE |
en_GB |
Locale for initdb (extended with .UTF-8 for all LC_* categories) |
SRV_VERSION |
0 |
Version label (org.opencontainers.image.version) |
SRV_APT_GET_INSTALL |
Additional Debian packages to install at build time | |
ENABLE_PG_TRICKLE |
Set to true to build and enable pg_trickle (adds Rust + pgrx to the build) |
Important: The data directory /data must exist with matching UID/GID before the container starts. If Docker creates it, it will be root-owned and initdb will fail.
Image Details
| Base image | buildpack-deps:bookworm |
| PostgreSQL | 18.3 (from apt.postgresql.org) |
| Data directory | /data |
| User | postgres (configurable UID/GID) |
| Home | /var/lib/postgresql |
| Port | 5432 |
| Entrypoint | Official docker-library/postgres entrypoint |
Tags
| Tag | PostgreSQL | Status |
|---|---|---|
latest, 18 |
18.3 | current |
17 |
17.x | archived |
Support
- Discord — People, Postgres, Data (highlight Getty)
- IRC — Libera.Chat
#postgresql(message Getty) - Email — getty@conflict.industries