The root Dockerfile remains the reference implementation for apps not yet migrated.
This folder contains the new structure:
- Shared base image:
docker/base.Dockerfile - Per-app platform artifacts:
docker/apps/<app>/ - Build orchestration:
docker-bake.hcl
Docker-related platform artifacts live under docker/, not inside the app
source trees. That includes Dockerfiles, Dokku app.json, and any future
container/deploy metadata tied to the image rather than the application code.
Apps checked below have migrated to the per-app Dockerfile pattern under
docker/apps/<app>/. Unchecked apps still use the root Dockerfile.
-
charterafrica -
civicsignalblog -
climatemappedafrica -
codeforafrica -
pesayetu -
roboshield -
techlabblog -
trustlab -
twoopstracker -
vpnmanager
Three tools, four concerns — they do not overlap:
| Concern | Tool |
|---|---|
| Day-to-day development | pnpm dev (native, hot-reload) |
| Local production image testing | docker buildx bake + docker compose |
| Infrastructure (databases, etc.) | docker compose (image: only, no build:) |
| CI builds | docker buildx bake |
For daily feature work, use pnpm dev. Docker is for validating the production
image, typically when changing a Dockerfile or debugging a production-only failure.
Base images (ui-builder-base, ui-runner-base) have an independent version lifecycle
from apps. They only change when Node, pnpm, or Alpine tooling changes. The BASE_TAG
variable pins the base image version used by app builds, keeping it decoupled from TAG
(the app version).
NODE_VERSION, NODE_DIGEST, PNPM_VERSION and TURBO_VERSION in docker-bake.hcl
are literal pins rather than derived values: bake's HCL dialect has no file()
function, so it cannot read the root package.json or pnpm-workspace.yaml.
They are not free-floating. scripts/toolchain-contract.test.mjs fails CI if any of
them drifts from the source of truth:
| Bake variable | Must match |
|---|---|
NODE_VERSION |
package.json#engines.node (same major) |
NODE_DIGEST |
an index digest (sha256:…) if set |
PNPM_VERSION |
package.json#packageManager (exactly) |
TURBO_VERSION |
the turbo pnpm-lock.yaml resolves |
NODE_DIGEST is empty by default, so base images currently float on the
node:<version>-alpine tag. That tag is a mutable pointer — the docker-library team
repoints version tags on Alpine CVE rebuilds — so the same NODE_VERSION can resolve to
different content over time. Setting a digest makes base image builds reproducible.
This is deliberately not mandatory yet: making it so is supply-chain hardening rather
than toolchain standardisation, and it needs a story for keeping the digest fresh
(a bake validation block plus a Renovate custom manager bumping it alongside
NODE_VERSION). See the TODO on the variable in docker-bake.hcl.
Note this asymmetry is intentional, not an oversight: PNPM_VERSION and TURBO_VERSION
need no digest because npm publishes versions immutably, so a version there already pins
content. A Docker tag does not. Each pin uses the strongest immutable identifier its
registry offers.
If you do set one, it must be the index digest, not a platform-specific one, or multi-arch builds break:
docker buildx imagetools inspect node:24.21.0-alpine # read "Digest:"And set it in the same commit as NODE_VERSION. Docker checks only that a digest exists
in library/node, never that it matches the tag beside it, so a stale digest resolves
happily and silently builds the old Node while the logs show the new version. (Docker has
been asked to validate this since 2018 —
moby/moby#37866 — and it is still open.)
build-base-images.yml verifies the pairing against the registry whenever a digest is
present, and skips with a notice when it is not — the check the contract test cannot do
offline.
Changing any of these means republishing base images (build-base-images.yml) and
bumping vars.UI_BASE_TAG. See AGENTS.md § Toolchain versions for the full contract.
The shortest path, the Makefile wraps both steps. Using techlabblog as an example:
make techlabblogOr run the steps manually:
docker buildx bake --file docker-bake.hcl techlabblog
docker compose up techlabblogEnvironment variables: docker compose loads apps/<app>/.env and
apps/<app>/.env.local automatically when invoked via make <app> command.
The runtime vars work without any extra setup.
The bake step does not read those files; build-time args
(NEXT_PUBLIC_*, Sentry secrets, etc.) must be exported in your shell if
you need them locally. For most testing purposes, omitting them is fine, the
image builds correctly. The third-party integrations are just unconfigured.
Base images are built via the build-base-images.yml workflow, which handles
multi-arch builds, GHA caching, and the audit trail. Trigger it manually when
docker/base.Dockerfile or tooling versions in docker-bake.hcl change.
For example:
gh workflow run build-base-images.yml --field tag=v3Pull pre-built base images from the registry, build only app layers. For example:
BASE_TAG=v3 TAG=abc123 REGISTRY=docker.io/codeforafrica/ \
docker buildx bake --file docker-bake.hcl techlabblogAdditional variables available for CI labels:
GIT_REVISION=$(git rev-parse --short HEAD) \
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
BASE_TAG=v3 TAG=abc123 REGISTRY=docker.io/codeforafrica/ \
docker buildx bake --file docker-bake.hcl techlabblog-
Add
docker/apps/<app>/Dockerfilewith stages:pruned(turbo prune: isolates the app's files from the monorepo)deps(pnpm install with cache mount: separated so install layer is cached independently of source changes)builder(next build)runner(minimal runtime image: must be namedrunner, inherited from_app-runner)
-
Add a target in
docker-bake.hclinheriting from_app-runner:
target "<app>" {
inherits = ["_app-runner"]
dockerfile = "docker/apps/<app>/Dockerfile"
tags = ["${REGISTRY}<app>:${TAG}"]
}-
Add
<app>to theappsgroup indocker-bake.hcl. -
Update the
<app>service indocker-compose.ymlto useimage:instead ofbuild::
<app>:
image: codeforafrica/<app>:${TAG:-local}
env_file:
- path: ./apps/<app>/.env
- path: ./apps/<app>/.env.local
required: false
ports:
- 3000:3000- Add a
<app>target to theMakefile:
<app>:
./scripts/bake-up.sh <app>-
Copy
.github/workflows/techlabblog.ymland.github/workflows/_build-techlabblog.ymlto.github/workflows/<app>.ymland.github/workflows/_build-<app>.yml, and update thepathsfilter,target,file-name, image references, and Dokku remote URL. -
If the app deploys via Dokku, add
docker/apps/<app>/app.jsonand copy it into the runtime imageWORKDIR. -
Add
<app>toBUILD_TARGET_CONFIGinscripts/pr-build-targets.mjs(and.github/workflows/_build-<app>.ymltoGLOBAL_BUILD_FILESif it exists), add a matchingbuild-<app>job to.github/workflows/pr-build.yml, and addapps/<app>/**,docker/apps/<app>/**,.github/workflows/_build-<app>.yml, and.github/workflows/<app>.ymltopr-build.yml'spathsfilter. Without this, PRs touching<app>get no pre-merge build validation — breakage is only caught after merging tomain.