Skip to content

Container images

Every service image in the monorepo is built by the same small set of pieces. These are the following: one build-time toolchain image, one runtime base per language, and one shared Dockerfile per language family. This page explains why it is shaped that way, so the next change to it does not have to rediscover the reasoning.

The single most important rule is at the end: keep the families symmetric. The bulk of this page exists because they once diverged.

The rules this reasoning produced are in build rules. That page is what you must do. This one is why.

The pieces

Image Built from Used by
builder python:3.13.7-slim + Mise installing the root [tools] the build stage of both shared Dockerfiles, and py-build.bash
go-runtime debian:trixie-slim the runtime stage of Go service images
python-runtime python:3.13.7-slim the runtime stage of Python service images

All three are published to the platform-staging Artifact Registry by build-base-images.yml and consumed by digest.

builder is based on python:3.13.7-slim rather than a bare Debian image because it has to build both families. A Python virtualenv records the interpreter it was built against, and python-runtime is what runs it. So the build stage must ship that interpreter. Same image, not merely the same version. Both therefore pin the same digest, and //infra/docker:check-image-pins fails if they drift apart. Nothing is lost on the Go side: python:3.13.7-slim is itself Debian trixie, and the Go binaries are CGO_ENABLED=0 static.

Mise installs the toolchain to /opt/mise, not to its default under /root. /root is mode 0700, and py-build.bash runs the builder as the invoking user so that the wheels it emits are owned by that user. Under /root the toolchain would be unreachable to it.

The shape of a service build

Both shared Dockerfiles follow the same five steps:

  1. FROM the builder image, pinned by digest.
  2. cmp the repo's mise.lock against the copy baked at /opt/builder-manifest, failing a build whose pinned builder predates the lock.
  3. Stage the workspace. Manifests come first, so the dependency layer is reused when only source changes.
  4. Run one Mise task to produce the payload: //<project>:install for Go, //<service>:sync-venv for Python.
  5. FROM the matching runtime base, pinned by digest, and copy the payload in.

Step 4 is the rule, not a convenience: a Dockerfile must not spell out a build command of its own. If the image runs go build or uv sync directly, then the image and a developer build two different things. Only one of them is covered by CI. Put the command in a task under mise/scripts/ and have both call it.

Step 3 uses COPY --parents for the manifests, which needs # syntax=docker/dockerfile:1 as the literal first line of the Dockerfile. Once any other comment or instruction precedes it, a syntax= line is read as an ordinary comment and silently ignored.

How a version reaches a binary

BUILD_VERSION and BUILD_COMMIT arrive as build arguments, become -X linker flags in GO_LD_FLAGS, and set BuildVersion and BuildCommit in lyceum.technology/daedalus/version. docker-env.bash supplies the arguments, the root mise.toml names the package in GO_VERSION_PACKAGE, and go-env.bash composes the flags.

Two things to know before you debug it:

  • The linker silently ignores -X for a symbol that is not linked. A binary that does not import the version package accepts the flags and drops them. That is how stamping was broken in six of eleven Go projects while looking configured: the flags were set, the package was reachable only through daedalus/handler or daedalus/metrics, and only five services imported either.
  • 0.0.0/unknown means the arguments never arrived, not that the binary is unversioned. go-env.bash falls back to unknown when git rev-parse is unavailable, which is every container build. The version has to come in from outside.

stream_execlet is excluded. It is the only Go module with no daedalus dependency, and it is being retired.

Pins

Every base reference is <image>:<tag>@sha256:<digest>. The digest is what pulls. The tag is for a human reading the file. Three things keep that honest:

  • //infra/docker:repin-base-images rewrites the digests of our own published images, in every file that names them. It is the only thing that should.
  • //infra/docker:check-image-pins is static text: all copies of a digest agree, and the Python base tag matches .python-version. It runs in pre-commit.
  • //infra/docker:check-pinned-tags pulls each pinned image and asks what it is, catching a tag that no longer describes its digest. It needs a registry, so it runs in check-image-pins.yml.

check-pinned-tags deliberately does not compare a pin against the tag's current digest upstream. Upstream rebuilds a tag for OS patches, which moves it away from a pin we chose on purpose. That comparison would then fail on every rebuild rather than on a real problem.

Why one build path at all

The Makefiles it is replacing are already thin: 18 of them, most a single include. What makes the change worth doing is what had grown around them.

  • The wildcards lied. app and llm-router had no lint or format target, so a repository-wide sweep was silently partial, and Python CI never called Make at all.
  • The seven Go images diverged on everything: source copying, CGO_ENABLED, artefact paths, and every line of the runtime stage. One Dockerfile ends that, once the per-service ones are removed.
  • Five build workflows carry five copies of one setup job, two of them quietly broken.
  • Python's checks covered far less than they looked. Critical-only lint over one directory. The full ruleset surfaced hundreds of findings, including real F821 runtime bugs.

Same pattern each time: the tooling reported success over work it was not doing. Fixing that is worth more than the convenience of a single command.

Keep the families symmetric

Go and Python once built completely differently: Go on the Mise builder, Python on its own base with uv copied in from a third image and uv sync spelled out in the Dockerfile. Every difference grew its own machinery to manage it:

  • Python pinned uv separately, so a check existed to hold that pin equal to mise.toml.
  • Python pinned its own Python base, so a check existed to hold it equal to python-runtime's.
  • Go staged a hand-written list of sibling modules, which did not scale past two.
  • Because Go staged only a sliver of the repo, most [monorepo].config_roots entries were missing inside the image. Mise then warned once per missing root on every invocation, including shimmed tools such as go. That produced six things:
    • A script to rewrite config_roots inside the image.
    • A second script to check the rewrite.
    • A Mise task.
    • A pre-commit hook.
    • A build argument.
    • A per-service declaration.

None of that was solving a real problem. It was managing a difference that did not need to exist. Unifying the two builds deleted all of it and left the checks that guard something real.

So before adding a task, a check or a hook to the image pipeline, ask whether the check guards a genuine invariant or a consequence of the two families having drifted. If it is drift, then close the gap instead. The check you were about to write deletes itself.