Skip to content

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 FROM lines 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:

StoreIn use
Postgres14, 16, 17, 17.5 and 18.1 - five versions across seven repositories
PostGIS16
Redisredis:latest in 18 places, plus 7.4 and 8
RabbitMQ3.x in four projects, 4.x in two

Each of those is a real cost:

  • :latest changes 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 ​

UseImageStatusDigestUpstream support
Postgrespostgres:18.6-trixiedefaultsha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280to 2030-11-14
Postgrespostgres:17.11-trixieallowed (N-1)sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675to 2029-11-08
PostGISpostgis/postgis:18-3.6defaultsha256:60f6ad1d21ea86a67d47780b9a0d1e1d200500f62b19293fa834d0dea80b8677follows Postgres 18
Redisredis:8.8.2-trixiedefaultsha256:37227fff5638322f4ebea25d6d0dc3ee50848604e82b81426f11507b3ec7d2ccnewest 8.x minor
RabbitMQrabbitmq:4.3.5-managementdefaultsha256:57bddb6fbc3498b5d8b5a14dc6f4506073ebcf94c66ba2a7678c335faa8dd631newest 4.x minor
HAProxyhaproxy:3.4.6-trixiedefaultsha256:e273915691a8eab3f105bf3e39fd4c29a4ecf334e33421903b5589d25e14db26LTS, to 2031
HAProxyhaproxy:3.2.25-trixieallowed (previous LTS)sha256:89c31eabc1ce443a13a42725a04df034b992c9d60580f4e6ef8b7bf5b15deddfLTS, to 2030
MongoDBmongo:9.0.2-nobledefaultsha256:61dd87ff554dc20386c63ebe1f7ede27314370ad5a7bf357c685fab424672158MongoDB lifecycle
MongoDBmongo:8.0.32-nobleallowed (N-1)sha256:0393ab544cbbe92b2dd64719205ecb14a8b3824b17ea75051e2f22482c3e4e66MongoDB 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.6 is built on postgres:18-trixie, so it is the same Debian base as the Postgres rows.
  • RabbitMQ publishes no Debian variant: 4.3.5-management is 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: -noble is 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 ​

  1. Pin the exact patch tag, everywhere the image appears - compose files, Dockerfile FROM, .gitlab-ci.yml image: and services:, and testcontainers code. Never :latest, never a bare major (postgres:17), never a name with no tag.
  2. Tests use the same tag as production. A test suite on 17 while production runs 18 tests the wrong database.
  3. 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.
  4. Debian, not Alpine. Alpine is built on musl, which sorts text differently from glibc - so ORDER BY on 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.
  5. 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:

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:

ProblemWhere
Runtimes past end of supportubuntu:16.04 (2021), ubuntu:20.04 (2025-05), node:18 (2025-04), node:20 (2026-04)
A Go build image past supportgolang:1.25 in the service template, while Go supports only 1.27 and 1.26
Four runtimes for the same kind of Go binaryubuntu:22.04, ubuntu:24.04, debian:trixie-slim, distroless
Moving tagssonarqube:latest, ubuntu, debian:trixie-slim with no date or digest
Alpinenode:18-alpine

Most of the Ubuntu runtimes install nothing but ca-certificates. Distroless ships those already, so it can replace them.

Approved base images ​

RoleImageStatusDigestUpstream support
Go buildgolang:1.27.1-trixiedefaultsha256:433790e515d27dc6003e847e644cc0af956985cf315c1c58a3b73ee2dd305183until Go 1.29 (~2027-08)
Go buildgolang:1.26.8-trixieallowed (N-1)sha256:bdca99a00bc16590cb1a0bb4e698f5fc5d6a64e4d5eef13d9f18a0ee08e5fa65until Go 1.28 (~2027-02)
Go runtime, CGO_ENABLED=0gcr.io/distroless/static-debian13:nonrootdefaultsha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3follows Debian 13
Go runtime, cgogcr.io/distroless/cc-debian13:nonrootallowedsha256:54df941ed0d06a1bd95ef5e0ce391fd8d9f94b64782dc9a60062727849ee3f97follows Debian 13
Python build and runtimepython:3.14.7-slim-trixiedefaultsha256:caaf356f40667c496d405780745b9ac25771c189a51dfcc42430d531ea09f8a2to 2030-10
Python build and runtimepython:3.13.15-slim-trixieallowed (N-1)sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0to 2029-10
Node build (UI bundles)node:24.21.0-trixie-slimdefaultsha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffeto 2028-04-30
Runtime with OS packagesdebian:trixie-20260918-slimdefaultsha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20asecurity to 2028-08, LTS to 2030-06
GPU buildnvidia/cuda:12.9.1-devel-ubuntu24.04defaultsha256:020bc241a628776338f4d4053fed4c38f6f7f3d7eb5919fecb8de313bb8ba47cUbuntu 24.04 to 2029-05
GPU runtimenvidia/cuda:12.9.1-runtime-ubuntu24.04defaultsha256:1287141d283b8f06f45681b56a48a85791398c615888b1f96bfb9fc981392d98Ubuntu 24.04 to 2029-05
GPU buildnvidia/cuda:13.4.1-devel-ubuntu24.04allowedsha256:a3f014424ccc86cf66c84d700d2896491424205dd9457e3116ed4f0e96381311Ubuntu 24.04 to 2029-05
GPU runtimenvidia/cuda:13.4.1-runtime-ubuntu24.04allowedsha256:cdfca8f2adfd99287ebbd4a86b3ad6470425c731aea023ca1d4c653a0d7fec7bUbuntu 24.04 to 2029-05
  • A Node UI bundle ships as static files, so its runtime stage is scratch or the web server that serves it, never node.
  • 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 for debian; trixie-slim moves 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 ​

  1. Build and runtime are separate stages. The final stage FROM is a runtime row, never a build row. The one exception is Python, whose build and runtime are the same image (see Python).
  2. Every FROM pins the tag and the digest, including build stages: golang:1.27.1-trixie@sha256:433790e5.... Pass it as an ARG at the top of the Dockerfile, as the Go template does, so the quarterly bump is a one-line change.
  3. The runtime runs as UID 65532 and GID 65532, never root: USER 65532:65532. That is distroless's built-in nonroot user and group; the same pair goes in compose user: and COPY --chown. python, debian and nvidia/cuda run as root by default and need the group and user created (see Python).
  4. 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.
  5. Only the runtime rows go to production. A golang, node or -devel CUDA image in a deploy file is a mistake.

Go ​

Choose the runtime row from what the binary needs:

The binaryRuntime
is built with CGO_ENABLED=0 (the default in the template)distroless/static-debian13:nonroot
links C libraries through cgodistroless/cc-debian13:nonroot
runs OS tools (ffmpeg, git) as subprocessesdebian:trixie-20260918-slim with those packages and a nonroot user
uses a GPUthe 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.

dockerfile
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 -devel image is several gigabytes of compilers and headers the process never uses.
  • A -base runtime 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.

Address10.0.0.106:5001 from a workstation, registry:5001 from the runner
Deploy fromtemplates/registry-proxy/
Statusnot 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:

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:

yaml
services:
  api:
    volumes:
      - ./deploy:/app/deploy:ro

Why: 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 .gitignore and kept on the host; CI ships the tracked files and never overwrites them.
  • They are 0600, or 0640 with a group, owned by the container's user (see security.md).
  • A service whose image runs as 65532 needs the folder readable by that uid; chown it 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.

ImageRepositoryReasonOwnerExpires

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 .1 release.
  • 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 example stream-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.

RepositoryNowTargetOwnerDeadline
aioz-ipfs/pinning-servicepostgres:14, redis:latest, rabbitmq:3-management-alpinePostgres 18, Redis 8, RabbitMQ 4@Postgres before 2026-11-12
depin-workspace/depinpostgres:18.1-alpine (compose, CI)postgres:18.6-trixie (same major: reindex)@next quarter
depin-workspace/hubpostgres: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-servicepostgis/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-corepostgres:16-alpine, rabbitmq:3-management, redis:7.4.3-alpinePostgres 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-alpinethe approved tags@next quarter
stream/aioz-stream (HAProxy)haproxy:3.0-alpinehaproxy:3.4.6-trixie@next quarter
aioz-map/parser-service (HAProxy)own parser-service-haproxy imagethe approved haproxy image with a conf.d mount@next quarter
aioz-map/crawler-servicemongo: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.

RepositoryNowTargetOwnerDeadline
templates/team-playbook (templates/docker/Dockerfile)golang:1.25-trixie, older distroless digestgolang:1.27.1-trixie, the approved distroless digest@now
aioz-ipfs/pinning-serviceubuntu:20.04distroless/static-debian13:nonroot@now
go-aioz-media (scripts/docker-go-compiler)ubuntu:16.04golang:1.27.1-trixie@now
stream/aioz-stream-docsnode:18-alpinenode:24.21.0-trixie-slim@now
stream/aioz-stream-sdk-generatornode:20-bookworm-slim, golang:1.25-bookworm, maven:3.9-eclipse-temurin-11node:24.21.0-trixie-slim, golang:1.27.1-trixie, maven at an exact tag@now
aioz-map/backendubuntu:22.04distroless/static-debian13:nonroot@first bump
depin-workspace/hub (Dockerfile-account, -uplink, -wm, -worker)ubuntu:22.04distroless/static-debian13:nonroot@first bump
go-aioz-media (cmd/aiozworker)ubuntu:22.04distroless/static-debian13:nonroot@first bump
stream/aioz-stream-core (Dockerfile.server)ubuntu:22.04distroless/static-debian13:nonroot@first bump
stream/aioz-stream-core (Dockerfile)nvidia/cuda:12.6.2-devel-ubuntu24.04, 12.8.1-base-ubuntu24.0412.9.1-devel and 12.9.1-runtime, or an exception for -base@first bump
depin-workspace/depin (Dockerfile.coord, .edgeserver, .relay)ubuntu:24.04distroless/static-debian13:nonroot@first bump
depin-workspace/depin (cmd/*/Dockerfile)debian:trixie-slim, no digestdistroless/static-debian13:nonroot, or the dated debian row if it needs packages@first bump
runner/sonarqsonarqube:latestan 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.