Container images
Which third-party images a project may run, how they are written down, and how they reach us.
It covers two kinds of image:
- Data stores - Postgres, PostGIS, Redis and RabbitMQ, which a project runs next to its service.
- Base images - the
FROMlines of the images the team builds: Go, Python, Node and GPU services.
Why
Left to each project, the images drift. A survey of the team's repositories on 2026-09-11 found:
| Store | In use |
|---|---|
| Postgres | 14, 16, 17, 17.5 and 18.1 - five versions across seven repositories |
| PostGIS | 16 |
| Redis | redis:latest in 18 places, plus 7.4 and 8 |
| RabbitMQ | 3.x in four projects, 4.x in two |
Each of those is a real cost:
:latestchanges under you. The same compose file starts a different server after any pull, so "it worked yesterday" stops being evidence.- Different majors behave differently. A query tested against Postgres 17 and run against 14 is tested against the wrong database.
- Old majors stop getting security fixes. Postgres 14 reaches end of life on 2026-11-12.
Approved images
| Use | Image | Status | Digest | Upstream support |
|---|---|---|---|---|
| Postgres | postgres:18.6-trixie | default | sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280 | to 2030-11-14 |
| Postgres | postgres:17.11-trixie | allowed (N-1) | sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 | to 2029-11-08 |
| PostGIS | postgis/postgis:18-3.6 | default | sha256:60f6ad1d21ea86a67d47780b9a0d1e1d200500f62b19293fa834d0dea80b8677 | follows Postgres 18 |
| Redis | redis:8.8.2-trixie | default | sha256:37227fff5638322f4ebea25d6d0dc3ee50848604e82b81426f11507b3ec7d2cc | newest 8.x minor |
| RabbitMQ | rabbitmq:4.3.5-management | default | sha256:57bddb6fbc3498b5d8b5a14dc6f4506073ebcf94c66ba2a7678c335faa8dd631 | newest 4.x minor |
| HAProxy | haproxy:3.4.6-trixie | default | sha256:e273915691a8eab3f105bf3e39fd4c29a4ecf334e33421903b5589d25e14db26 | LTS, to 2031 |
| HAProxy | haproxy:3.2.25-trixie | allowed (previous LTS) | sha256:89c31eabc1ce443a13a42725a04df034b992c9d60580f4e6ef8b7bf5b15deddf | LTS, to 2030 |
| MongoDB | mongo:9.0.2-noble | default | sha256:61dd87ff554dc20386c63ebe1f7ede27314370ad5a7bf357c685fab424672158 | MongoDB lifecycle |
| MongoDB | mongo:8.0.32-noble | allowed (N-1) | sha256:0393ab544cbbe92b2dd64719205ecb14a8b3824b17ea75051e2f22482c3e4e66 | MongoDB lifecycle |
- default - what a new project uses.
- allowed - an existing project may stay on it; plan the move to the default before its support ends.
- PostGIS
18-3.6is built onpostgres:18-trixie, so it is the same Debian base as the Postgres rows. - RabbitMQ publishes no Debian variant:
4.3.5-managementis Ubuntu 24.04. It is the non-Alpine choice, for the same reason as the rest (see rule 4). - HAProxy: only LTS lines (every even minor) are approved; an odd minor is supported for months, not years.
- MongoDB publishes no Debian variant either:
-nobleis Ubuntu 24.04. - MongoDB 8.0 and later refuse to start on Linux 6.19 and newer (SERVER-121912: its TCMalloc breaks the kernel's rseq ABI; no fixed release yet). Servers on an LTS kernel are not affected. A developer machine on 6.19 or newer cannot run these rows locally; see MongoDB.
Anything not in this table needs an exception.
Rules
- Pin the exact patch tag, everywhere the image appears - compose files, Dockerfile
FROM,.gitlab-ci.ymlimage:andservices:, and testcontainers code. Never:latest, never a bare major (postgres:17), never a name with no tag. - Tests use the same tag as production. A test suite on 17 while production runs 18 tests the wrong database.
- Production deploy files also pin the digest:
postgres:18.6-trixie@sha256:4ef4dbc9.... A tag can be pushed again with different contents; a digest cannot. - Debian, not Alpine. Alpine is built on musl, which sorts text differently from glibc - so
ORDER BYon a text column, and every text index, can behave differently between an Alpine test container and a glibc server. One flavor everywhere removes the question. - Write Docker Hub names as they are (
postgres:18.6-trixie). The registry proxy is configured on the Docker daemon, so the names in the repositories do not change when it is in front.
Data stores
Each data store, and HAProxy, has its own page, with a docker run for local development, a hardened compose.yml for a server, why each line is there, and how to upgrade:
- Postgres and PostGIS
- Redis
- MongoDB
- RabbitMQ
- HAProxy, the blue/green switch in front of HTTP roles
Every snippet on those pages is run for real by tests/image-pages/run.sh, which also fails if a page pins a different digest from the table above.
Base images
The image a service builds is two images, not one. The build image holds the compiler and the package manager. The runtime image holds only what the running process needs. A Go binary does not need golang at runtime, and shipping it there ships a compiler, a shell and a package manager as attack surface.
Why a base-image standard
The 2026-09-25 survey of every Dockerfile in the team's repositories found:
| Problem | Where |
|---|---|
| Runtimes past end of support | ubuntu:16.04 (2021), ubuntu:20.04 (2025-05), node:18 (2025-04), node:20 (2026-04) |
| A Go build image past support | golang:1.25 in the service template, while Go supports only 1.27 and 1.26 |
| Four runtimes for the same kind of Go binary | ubuntu:22.04, ubuntu:24.04, debian:trixie-slim, distroless |
| Moving tags | sonarqube:latest, ubuntu, debian:trixie-slim with no date or digest |
| Alpine | node:18-alpine |
Most of the Ubuntu runtimes install nothing but ca-certificates. Distroless ships those already, so it can replace them.
Approved base images
| Role | Image | Status | Digest | Upstream support |
|---|---|---|---|---|
| Go build | golang:1.27.1-trixie | default | sha256:433790e515d27dc6003e847e644cc0af956985cf315c1c58a3b73ee2dd305183 | until Go 1.29 (~2027-08) |
| Go build | golang:1.26.8-trixie | allowed (N-1) | sha256:bdca99a00bc16590cb1a0bb4e698f5fc5d6a64e4d5eef13d9f18a0ee08e5fa65 | until Go 1.28 (~2027-02) |
Go runtime, CGO_ENABLED=0 | gcr.io/distroless/static-debian13:nonroot | default | sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3 | follows Debian 13 |
| Go runtime, cgo | gcr.io/distroless/cc-debian13:nonroot | allowed | sha256:54df941ed0d06a1bd95ef5e0ce391fd8d9f94b64782dc9a60062727849ee3f97 | follows Debian 13 |
| Python build and runtime | python:3.14.7-slim-trixie | default | sha256:caaf356f40667c496d405780745b9ac25771c189a51dfcc42430d531ea09f8a2 | to 2030-10 |
| Python build and runtime | python:3.13.15-slim-trixie | allowed (N-1) | sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 | to 2029-10 |
| Node build (UI bundles) | node:24.21.0-trixie-slim | default | sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffe | to 2028-04-30 |
| Runtime with OS packages | debian:trixie-20260918-slim | default | sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a | security to 2028-08, LTS to 2030-06 |
| GPU build | nvidia/cuda:12.9.1-devel-ubuntu24.04 | default | sha256:020bc241a628776338f4d4053fed4c38f6f7f3d7eb5919fecb8de313bb8ba47c | Ubuntu 24.04 to 2029-05 |
| GPU runtime | nvidia/cuda:12.9.1-runtime-ubuntu24.04 | default | sha256:1287141d283b8f06f45681b56a48a85791398c615888b1f96bfb9fc981392d98 | Ubuntu 24.04 to 2029-05 |
| GPU build | nvidia/cuda:13.4.1-devel-ubuntu24.04 | allowed | sha256:a3f014424ccc86cf66c84d700d2896491424205dd9457e3116ed4f0e96381311 | Ubuntu 24.04 to 2029-05 |
| GPU runtime | nvidia/cuda:13.4.1-runtime-ubuntu24.04 | allowed | sha256:cdfca8f2adfd99287ebbd4a86b3ad6470425c731aea023ca1d4c653a0d7fec7b | Ubuntu 24.04 to 2029-05 |
- A Node UI bundle ships as static files, so its runtime stage is
scratchor the web server that serves it, nevernode. - Distroless publishes no versioned tags, so the digest is the only pin. Write both:
gcr.io/distroless/static-debian13:nonroot@sha256:e2e927ec.... - Debian's dated tag (
trixie-20260918-slim) is the exact tag fordebian;trixie-slimmoves with every point release. - CUDA publishes no Debian variant, so the GPU rows are Ubuntu 24.04, the same way RabbitMQ is.
- Anything not in this table needs an exception.
Base image rules
- Build and runtime are separate stages. The final stage
FROMis a runtime row, never a build row. The one exception is Python, whose build and runtime are the same image (see Python). - Every
FROMpins the tag and the digest, including build stages:golang:1.27.1-trixie@sha256:433790e5.... Pass it as anARGat the top of the Dockerfile, as the Go template does, so the quarterly bump is a one-line change. - The runtime runs as UID 65532 and GID 65532, never root:
USER 65532:65532. That is distroless's built-innonrootuser and group; the same pair goes in composeuser:andCOPY --chown.python,debianandnvidia/cudarun as root by default and need the group and user created (see Python). - Debian trixie, not Alpine and not Ubuntu, except for the GPU rows. The musl problem from rule 4 applies here too, and Python wheels built for manylinux assume glibc.
- Only the runtime rows go to production. A
golang,nodeor-develCUDA image in a deploy file is a mistake.
Go
Choose the runtime row from what the binary needs:
| The binary | Runtime |
|---|---|
is built with CGO_ENABLED=0 (the default in the template) | distroless/static-debian13:nonroot |
| links C libraries through cgo | distroless/cc-debian13:nonroot |
runs OS tools (ffmpeg, git) as subprocesses | debian:trixie-20260918-slim with those packages and a nonroot user |
| uses a GPU | the CUDA runtime row |
Start from templates/docker/Dockerfile. A binary built outside Docker skips the build stage and copies the binary into the runtime image directly; the template's last comment shows how.
The build image's Go minor must be at least the go line in go.mod. Go supports only its two newest minors, so a go.mod more than two minors behind is a sign the code has not been built on a supported toolchain.
Python
Python uses python:3.X.Y-slim-trixie for both stages. Build the virtualenv in the first stage and copy it into a second stage of the same tag. A venv records the interpreter's absolute path (/usr/local/bin/python), so a venv copied into a different image breaks, as do C-extension wheels built against a different glibc.
ARG PYTHON_IMAGE=python:3.14.7-slim-trixie@sha256:caaf356f40667c496d405780745b9ac25771c189a51dfcc42430d531ea09f8a2
FROM ${PYTHON_IMAGE} AS build
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY requirements.txt /tmp/
RUN pip install --no-cache-dir --require-hashes -r /tmp/requirements.txt
FROM ${PYTHON_IMAGE}
# The image has no non-root user; create the same UID and GID distroless uses.
RUN groupadd --system --gid 65532 nonroot \
&& useradd --system --uid 65532 --gid 65532 --home-dir /home/nonroot --create-home nonroot
COPY --from=build /opt/venv /opt/venv
COPY --chown=65532:65532 src/ /app/
ENV PATH=/opt/venv/bin:$PATH \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER 65532:65532
WORKDIR /app
ENTRYPOINT ["python", "-m", "app"]The second stage leaves out the compilers and headers that the build stage may install for C extensions.
GPU and media
- CUDA 12 is the default, because the CUDA major is bound to the host driver. CUDA 13 needs NVIDIA driver 580 or newer on every host that runs the image. Move a service to 13 only after its hosts are on that driver.
- Build on
-devel, run on-runtime. A-develimage is several gigabytes of compilers and headers the process never uses. - A
-baseruntime needs an exception. It fits only a binary that bundles its own CUDA libraries and needs nothing from the image but the driver interface.
Registry proxy
A pull-through cache for Docker Hub, so builds and CI do not depend on Docker Hub being up, and are not throttled by its pull limits.
| Address | 10.0.0.106:5001 from a workstation, registry:5001 from the runner |
| Deploy from | templates/registry-proxy/ |
| Status | not yet deployed |
Our own images stay on the existing registry at port 5000. A registry in proxy mode refuses pushes, so the two cannot be one instance.
On a Docker host - a workstation, the CI runner - point the daemon at the proxy in /etc/docker/daemon.json:
{
"registry-mirrors": ["http://10.0.0.106:5001"],
"insecure-registries": ["10.0.0.106:5000", "10.0.0.106:5001"]
}Docker then pulls every Docker Hub image through the proxy with no change to the image names, and falls back to Docker Hub when the proxy is down. The template's README shows how to confirm a pull really went through it.
Where the daemon cannot be configured (docker-in-docker, another runtime), name the proxy in the image. Official images need the library/ segment: registry:5001/library/postgres:18.6-trixie. A bare registry:5001/postgres:... is not found. For testcontainers, set TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX=registry:5001 and write official images as library/postgres:18.6-trixie.
Config mounts
A service's configuration MUST be bind-mounted as a directory, read-only, never as a single file. The default is the deploy/ folder next to the compose file, mounted at /app/deploy, with the service reading deploy/config.yaml:
services:
api:
volumes:
- ./deploy:/app/deploy:roWhy: a single-file bind mount (./config.yaml:/app/config.yaml) pins the file's inode at the moment the container starts. Almost every way of changing a file replaces the inode instead of writing into it: vim, sed -i, scp, ansible, and any editor that writes a new file and renames it over the old one. The host then shows the new config while the container keeps reading the old one, and a restart may still not pick it up. A directory mount resolves the name inside the folder on every open, so the container always sees what is on disk. A changed config still needs a restart or a reload to take effect; the mount only guarantees that what the process reads is what the operator wrote.
- Files in
deploy/that hold secrets (config.yaml, keys) are listed in.gitignoreand kept on the host; CI ships the tracked files and never overwrites them. - They are
0600, or0640with a group, owned by the container's user (seesecurity.md). - A service whose image runs as
65532needs the folder readable by that uid;chownit once when preparing the host.
Exceptions
An image that is not approved goes in this table, by merge request to this file, with a reason, an owner and an expiry date. An expired exception is a tech-debt ticket.
| Image | Repository | Reason | Owner | Expires |
|---|---|---|---|---|
Keeping it current
- At the quarterly tech health review (Tech Debt & Investment): bump every row to the newest patch tag and digest, check the support dates, and move a new major to default once it has a
.1release. - Between reviews: a patch bump for a security fix goes in by merge request at any time.
- Go ships a minor every six months (February and August), which drops the oldest supported one. The review after each release moves the Go rows up one: the new minor becomes default once it has a
.1, and the old default becomes allowed. - Base-image bumps move every service together. A bump updates the base rows here and the Go template. Then every repository takes the same rows, in one merge request per repository. No two services run different Debian or Go builds for longer than one bump.
Scope
Not covered, and not required to follow the tables:
- Devcontainers (
.devcontainer/Dockerfile), which never reach a server. They should still use the same Go minor as the build row. - Forks of upstream projects (
ipfs/kubo,depin-workspace/storj,go-livepeer-openpool,lpms), which keep upstream's Dockerfiles so merges stay clean. - CI tool images (
hadolint,golangci-lint,sonarqube,maven), which follow rule 1 - an exact tag, never:latest- but need no approved row. - The team's own images on
registry:5000(for examplestream-core-ffmpeg), whose own Dockerfiles follow these rules instead.
Rollout
Duplicate checkouts of one repository are listed once.
Data-store rollout
State on 2026-09-11.
| Repository | Now | Target | Owner | Deadline |
|---|---|---|---|---|
aioz-ipfs/pinning-service | postgres:14, redis:latest, rabbitmq:3-management-alpine | Postgres 18, Redis 8, RabbitMQ 4 | @ | Postgres before 2026-11-12 |
depin-workspace/depin | postgres:18.1-alpine (compose, CI) | postgres:18.6-trixie (same major: reindex) | @ | next quarter |
depin-workspace/hub | postgres:17.5-alpine3.22, postgres:16-alpine (transcode), rabbitmq:4-management, redis:8 (tests) | Postgres 18 or 17, RabbitMQ and Redis pinned | @ | next quarter |
aioz-map/backend, crawler-service, parser-service | postgis/postgis:16-3.4-alpine, rabbitmq:3-management(-alpine), rabbitmq:3.13-management, postgres:17 (crawler local) | postgis/postgis:18-3.6, RabbitMQ 4 | @ | RabbitMQ next quarter; PostGIS before 2028-11 |
stream/aioz-stream, aioz-stream-core | postgres:16-alpine, rabbitmq:3-management, redis:7.4.3-alpine | Postgres 18, RabbitMQ 4, Redis 8 | @ | RabbitMQ next quarter; Postgres before 2028-11 |
templates/aioz-common, aioz-template (tests) | postgres:17-alpine, redis:8-alpine, rabbitmq:4-alpine | the approved tags | @ | next quarter |
stream/aioz-stream (HAProxy) | haproxy:3.0-alpine | haproxy:3.4.6-trixie | @ | next quarter |
aioz-map/parser-service (HAProxy) | own parser-service-haproxy image | the approved haproxy image with a conf.d mount | @ | next quarter |
aioz-map/crawler-service | mongo:7 (local), mongo:7.0 (devcontainer) | mongo:9.0.2-noble (7.0 to 8.0 to 9.0, one major at a time) | @ | before MongoDB 7.0's end of life |
Base-image rollout
State on 2026-09-25. Everything below moves in the first base-image bump, one merge request per repository. Rows marked now run on something past upstream support and do not wait for the bump.
| Repository | Now | Target | Owner | Deadline |
|---|---|---|---|---|
templates/team-playbook (templates/docker/Dockerfile) | golang:1.25-trixie, older distroless digest | golang:1.27.1-trixie, the approved distroless digest | @ | now |
aioz-ipfs/pinning-service | ubuntu:20.04 | distroless/static-debian13:nonroot | @ | now |
go-aioz-media (scripts/docker-go-compiler) | ubuntu:16.04 | golang:1.27.1-trixie | @ | now |
stream/aioz-stream-docs | node:18-alpine | node:24.21.0-trixie-slim | @ | now |
stream/aioz-stream-sdk-generator | node:20-bookworm-slim, golang:1.25-bookworm, maven:3.9-eclipse-temurin-11 | node:24.21.0-trixie-slim, golang:1.27.1-trixie, maven at an exact tag | @ | now |
aioz-map/backend | ubuntu:22.04 | distroless/static-debian13:nonroot | @ | first bump |
depin-workspace/hub (Dockerfile-account, -uplink, -wm, -worker) | ubuntu:22.04 | distroless/static-debian13:nonroot | @ | first bump |
go-aioz-media (cmd/aiozworker) | ubuntu:22.04 | distroless/static-debian13:nonroot | @ | first bump |
stream/aioz-stream-core (Dockerfile.server) | ubuntu:22.04 | distroless/static-debian13:nonroot | @ | first bump |
stream/aioz-stream-core (Dockerfile) | nvidia/cuda:12.6.2-devel-ubuntu24.04, 12.8.1-base-ubuntu24.04 | 12.9.1-devel and 12.9.1-runtime, or an exception for -base | @ | first bump |
depin-workspace/depin (Dockerfile.coord, .edgeserver, .relay) | ubuntu:24.04 | distroless/static-debian13:nonroot | @ | first bump |
depin-workspace/depin (cmd/*/Dockerfile) | debian:trixie-slim, no digest | distroless/static-debian13:nonroot, or the dated debian row if it needs packages | @ | first bump |
runner/sonarq | sonarqube:latest | an exact tag | @ | first bump |
Each distroless target assumes the binary is built with CGO_ENABLED=0 and needs no OS tools. Check that before the move; a binary that fails it takes the cc or debian row from the Go table.