The shared builder images for False Systems products: one pinned toolchain
per language, rebuilt every week, published for linux/amd64 and
linux/arm64.
They are build stages only. Runtime images stay on upstream bases pinned by
digest in each product repo. The builders are Debian 13 (trixie), so what
they build must run on a trixie runtime: gcr.io/distroless/cc-debian13 for
Rust services, debian:trixie-slim for vartio's release. A product moves its
runtime stage to trixie in the same PR that moves it onto these images;
see Runtime pairing.
| Image | Tags | Base | For |
|---|---|---|---|
ghcr.io/false-systems/rust-builder |
1.97.1, 1.97.1-YYYYMMDD |
rust:1.97.1-slim-trixie |
ahti, cloudtrail-shipper, polku, jalki, selko |
ghcr.io/false-systems/rust-builder |
1.97.1-ebpf, 1.97.1-ebpf-YYYYMMDD |
rust-builder:1.97.1 from the same run |
jalki (selko's Linux sensor only after it pins the same nightly and bpf-linker) |
ghcr.io/false-systems/elixir-builder |
1.19.5-otp28.5-trixie, …-YYYYMMDD |
hexpm/elixir:1.19.5-erlang-28.5.0.5-debian-trixie-20260824-slim |
vartio |
Rust 1.97.1, not 1.97.0: 1.97.0 has a P-critical x86_64 miscompile (rust-lang/rust#159035).
rust-builder
-
Rust 1.97.1 (minimal profile) with
rustfmtandclippy, and the targetsx86_64-unknown-linux-gnuandaarch64-unknown-linux-gnu. -
A build guard: the image does not build unless
rustc --versionequals the tag. -
Native C/C++ toolchain:
gcc,g++,make,clang19,lld,cmake,pkg-config,protobuf-compiler+libprotobuf-dev(the well-known.prototypes),libssl-dev,git,curl. -
A cross toolchain for the other architecture:
gcc/g++and the libc headers for x86_64 on the arm64 image, and for aarch64 on the amd64 image. The environment is preset, socargo build --target <x86_64|aarch64>-unknown-linux-gnulinks on either host with no extra flags. It covers crates that compile C or C++ throughcc, such as ring and zstd.CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc CC_x86_64_unknown_linux_gnu / CXX_… / AR_… = x86_64-linux-gnu-{gcc,g++,ar} CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc CC_aarch64_unknown_linux_gnu / CXX_… / AR_… = aarch64-linux-gnu-{gcc,g++,ar}There are no cross
-devlibraries, andPKG_CONFIG_ALLOW_CROSSis not set. A cross build that needs a system library (OpenSSL throughopenssl-sys, for example) must use rustls or a vendored build instead. Native builds can uselibssl-dev. -
Prebuilt static binaries in
/usr/local/bin, each checked against the sha256 its release publishes:cargo-chef0.1.78cargo-auditable0.7.6sccache0.18.0
They are outside
$CARGO_HOME, so a cache mount there cannot hide them. -
RUSTC_WRAPPERis not set. Turn sccache on per build (see below). -
rustup's auto-self-update is off.
$RUSTUP_HOMEand$CARGO_HOMEare world-writable, as upstream leaves them, so a non-root build user works.
rust-builder:…-ebpf (everything above, plus)
nightly-2026-09-25withrust-src, for-Z build-std=coreagainstbpfel-unknown-none. This is the date jalki pins injalki-ebpf/rust-toolchain.toml, and$EBPF_NIGHTLYnames it.bpf-linker0.10.4, built from crates.io with--lockedthroughcargo auditable. Its default featurerust-llvm-22loads libLLVM at link time from the sysroot of the rustc that calls it: the nightly's LLVM, 23.1 for this date. It is pinned because 0.11 needs a system LLVM throughllvm-config, and that release broke every jalki build.- The smoke test links a real BPF object with this pair. jalki's own
xtask build-ebpf --release(origin/main after jalki#95) was also run in the image: it used the preinstalled nightly, with no toolchain download. - selko's Linux sensor does not fit yet: it takes an unpinned
nightlyand bpf-linker 0.11 with a system LLVM. It can move here once it pinsnightly-2026-09-25and bpf-linker 0.10.4, or once this image moves to its pair.
elixir-builder
- Elixir 1.19.5 on OTP 28.5 (28.5.0.5), Debian 13. The build checks both against the tag.
build-essential,git,ca-certificates.- Hex 2.5.1 and rebar3 3.25.1 are already installed, under
MIX_HOME=/opt/mixandHEX_HOME=/opt/hex. Both are pinned (Hex by version, rebar3 by URL and sha512), so a weekly rebuild cannot change them unseen. Both paths are world-writable, so they work for anyUSERorHOME. Product Dockerfiles can dropmix local.hex --force && mix local.rebar --force.
Every image runs apt-get upgrade at build time and is rebuilt every Monday.
That rebuild is where the Debian security fixes come from: the pinned
upstream digests are effectively static. rust:1.97.1-slim-trixie stopped
getting new digests once a newer Rust shipped, and hexpm publishes new dated
tags instead of re-pushing old ones, so Dependabot rarely has a base digest to
move. If the schedule stops, the images silently age; see
Keeping the weekly rebuild alive.
Approximate unpacked sizes (arm64): rust-builder 1.8 GB, -ebpf 2.5 GB, elixir-builder 0.5 GB.
<version>, e.g.rust-builder:1.97.1, is a moving tag. Every build ofmainmoves it: a push, the Monday rebuild, or a manual run.<version>-YYYYMMDDis immutable. A second build on the same day gets-YYYYMMDD.2, and so on. Use it to go back to one exact week.- Each tag is a multi-arch index: one
linux/amd64image, onelinux/arm64image, and an attestation manifest for each, holding an SPDX SBOM and SLSA provenance (mode=max).ci/publish.shcomputes each index withimagetools create --dry-runand checks it before any tag moves, so an index that lacks any of them is never tagged. All of a group's indexes are checked first, so a failed check moves neitherrust-builder:<v>norrust-builder:<v>-ebpf. After tagging, it inspects the pushed index and checks it again.
Pin the moving tag plus the index digest in a literal FROM line. Let
Dependabot move the digest. The tag says which toolchain the build uses, and
the digest makes the build reproducible.
# Rust service, cargo-chef + sccache.
FROM ghcr.io/false-systems/rust-builder:1.97.1@sha256:<index digest> AS chef
WORKDIR /build
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
ARG TARGETARCH
ENV RUSTC_WRAPPER=sccache SCCACHE_DIR=/sccache
COPY --from=planner /build/recipe.json recipe.json
RUN --mount=type=cache,id=myrepo-cargo-registry,target=/usr/local/cargo/registry \
--mount=type=cache,id=myrepo-sccache-${TARGETARCH},target=/sccache \
cargo chef cook --release --locked --recipe-path recipe.json
COPY . .
RUN --mount=type=cache,id=myrepo-cargo-registry,target=/usr/local/cargo/registry \
--mount=type=cache,id=myrepo-sccache-${TARGETARCH},target=/sccache \
cargo auditable build --release --locked
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:<digest>
COPY --from=builder /build/target/release/myservice /usr/local/bin/# Elixir release (vartio).
FROM ghcr.io/false-systems/elixir-builder:1.19.5-otp28.5-trixie@sha256:<index digest> AS builder
ENV MIX_ENV=prod
# ... deps.get / compile / release as today, without `mix local.hex/rebar`.
# trixie, never bookworm: the release carries this ERTS (see Runtime pairing).
FROM debian:trixie-slim@sha256:<digest>
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3t64 libtinfo6 libstdc++6 ca-certificates \
&& rm -rf /var/lib/apt/lists/*Get the index digest from the build's job summary, or with:
docker buildx imagetools inspect ghcr.io/false-systems/rust-builder:1.97.1 \
--format '{{json .Manifest}}' | jq -r .digestIn the product repo's .github/dependabot.yml, the docker ecosystem needs
no registry credentials, because these packages are public. Let it refresh the
digest, and ignore version changes, so that the Rust version moves only
together with rust-toolchain.toml (and Elixir with .tool-versions):
- package-ecosystem: docker
directory: "/"
schedule: { interval: weekly }
ignore:
- dependency-name: "false-systems/rust-builder"
update-types: ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]
- dependency-name: "false-systems/elixir-builder"
update-types: ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]Dependabot names a Docker dependency without its registry, so a
ghcr.io/false-systems/... name would match nothing and ignore nothing.
Keep rust-toolchain.toml at the image's version (channel = "1.97.1"). The
image's toolchain then satisfies it, and rustup downloads nothing.
Rule: a trixie builder goes with a trixie runtime, in the same PR. Never
pair these builders with debian:bookworm-slim or distroless/cc-debian12.
When a product moves its build stage onto one of these images, the same PR
moves its runtime stage:
| Product | Runtime stage |
|---|---|
| Rust services (ahti, polku, cloudtrail-shipper, jalki, selko) | gcr.io/distroless/cc-debian13:nonroot@sha256:<digest> (or debian:trixie-slim@sha256:<digest> where the image needs a shell) |
| vartio | debian:trixie-slim@sha256:<digest> with libssl3t64, libtinfo6, libstdc++6, ca-certificates |
Why: a binary needs the newest glibc (and OpenSSL) symbol version it was
linked against, and trixie has newer ones than bookworm. Measured in
elixir-builder: the ERTS a release carries needs GLIBC_2.38 (beam.smp)
and OPENSSL_3.4.0 (crypto.so). trixie has glibc 2.41 and OpenSSL 3.5;
bookworm has glibc 2.36 and OpenSSL 3.0, so the release does not start there.
Rust is less exposed today (a plain binary needs GLIBC_2.34, and no Rust
product links openssl-sys), but one new -sys crate can change that, so
the rule is the same.
Gate it in the product's CI. The simplest gate starts the binary or release
once in the runtime image. Where that is awkward, check the symbol versions in
the build stage, which has readelf:
# Fails if anything under the path needs a newer glibc than trixie's 2.41.
# For OpenSSL, use OPENSSL_ and 3.5.0.
need="$(find target/release/myservice -type f -exec readelf -V --wide {} + 2>/dev/null |
grep -oE 'GLIBC_[0-9][0-9.]*' | cut -d_ -f2 | sort -uV | tail -n1)"
printf '%s\n' "$need" 2.41 | sort -VC || { echo "needs glibc ${need}" >&2; exit 1; }For vartio, point find at the release directory (_build/prod/rel/vartio)
and check both GLIBC_ and OPENSSL_.
-
Resolve the new upstream digest:
docker buildx imagetools inspect rust:1.98.0-slim-trixie(theDigest:line). -
In one PR:
rust/Dockerfile: theFROMline (tag and digest) and theRUST_BUILDER_VERSIONARG default.docker-bake.hcl:RUST_BUILDER_VERSION.rust-ebpf/Dockerfile: theRUST_BUILDERARG default (standalone builds only).- This README, and the Rust
ignorein.github/dependabot.ymlif it names a version.
CI builds, smoke-tests and scans both architectures on the PR. If the
FROMline and the tag disagree, the build guard fails it. -
Merge. The publish job creates the new tag, e.g.
rust-builder:1.98.0. The old tag stays where it is, but it is no longer rebuilt, so move the products soon. -
In each product repo, one PR changes
rust-toolchain.tomland theFROMline (new tag and digest) together.
Tool bumps (cargo-chef, cargo-auditable, sccache in rust/Dockerfile)
change the version ARG and both sha256 sums. Take the sums from the
release's <asset>.sha256 files, and cross-check them against
gh api repos/<owner>/<repo>/releases/tags/<tag> --jq '.assets[] | "\(.name) \(.digest)"'.
Change EBPF_NIGHTLY in rust-ebpf/Dockerfile in the same week as jalki's
jalki-ebpf/rust-toolchain.toml. The smoke test links a BPF object with the
new pair, so a nightly whose LLVM bpf-linker cannot drive fails on the PR,
before anything is published. Moving bpf-linker to 0.11 or later means
bringing a system LLVM (llvm-config) into this image first.
hexpm publishes a new dated tag for each rebuild; it does not re-push old ones. Dependabot therefore rarely has a digest to move, so check for a newer tag by hand now and then:
curl -s 'https://hub.docker.com/v2/repositories/hexpm/elixir/tags?page_size=100&name=1.19.5-erlang-28.5&ordering=last_updated' |
jq -r '.results[] | select(.name | test("debian-trixie-.*-slim$")) | "\(.last_updated) \(.name) \(.digest)"' | headPut the new tag and digest in elixir/Dockerfile. If the Elixir version or
the OTP major.minor changes, also change ELIXIR_BUILDER_VERSION and
ELIXIR_BUILDER_OTP in docker-bake.hcl and the Dockerfile ARG defaults. The
build guard checks them. Bump vartio's .tool-versions together with its
FROM line.
Hex and rebar3 are pinned in elixir/Dockerfile (HEX_VERSION,
REBAR3_VERSION, REBAR3_URL, REBAR3_SHA512). On an Elixir or OTP major
bump, check that the pinned Hex supports the new Elixir, and take rebar3's
line for the new OTP major from hex.pm's index:
curl -s https://builds.hex.pm/installs/rebar.csv | tail # version,sha512,elixir build,otp major
# URL: https://builds.hex.pm/installs/<elixir build>/rebar3-<version>-otp-<otp major>The build fails if the installed Hex or rebar3 is not the pinned version, or if the rebar3 download does not match its sha512.
docker buildx bake --load rust-builder # native arch, tagged as in CI
docker buildx bake --load rust # + rust-ebpf
docker buildx bake --load elixir-builder
ci/smoke.sh rust elixir # the CI smoke tests
ci/scan.sh rust elixir # the CI Trivy gateci/scan.sh runs Trivy from its image, pinned by digest in the script, with
the Docker socket mounted, exactly as CI does. TRIVY=trivy uses a local
install instead.
On a Mac where the buildx plugin is not registered with the docker CLI, set
BUILDX=/opt/homebrew/lib/docker/cli-plugins/docker-buildx and call that
binary instead of docker buildx. Set IMAGE_REGISTRY=local to keep local
images away from the real tags.
- Triggers: a push to
mainthat touches the images, every Monday at 04:00 UTC, manual dispatch, and every pull request (build and check only; no path filter, so the four build checks always report and can be required onmain). - Build: one job per group (
rust,elixir) and architecture, on GitHub-hosted native runners:ubuntu-24.04for amd64 andubuntu-24.04-armfor arm64. No QEMU, and no layer cache: every build starts from the pinned bases. - Checks, in order:
bake --load, thenci/smoke.sh, thenci/scan.sh. Trivy (its own image, pinned by digest) reports HIGH and CRITICAL findings in the log and the job summary. It fails on any CRITICAL that has a fix. Unfixed findings are reported but do not block. - Push: from
mainonly. Each arch is pushed by digest, with the SBOM andmode=maxprovenance. That build reuses every layer of the checked one. - Publish: one job per group (
rust,elixir), so one group's failure does not hold back the other's weekly rebuild.ci/publish.shcomputes the merged index withimagetools create --dry-run, checks it, and only then sets the moving and dated tags. It checks the pushed index again and writes the digests to the job summary. - Permissions:
contents: readeverywhere, andpackages: writeonly in the build and publish jobs, where only the main-only steps use it. Pull requests from forks and from Dependabot get a read-only token regardless. Noid-token: nothing is signed yet (Phase 3). - Pinning: every action is pinned to a commit SHA, and Dependabot bumps the
actions weekly. It also watches the base digests, but those rarely move
(see above). Trivy's image digest in
ci/scan.shmoves by hand.
GitHub disables scheduled workflows in a public repository after 60 days without repository activity, and it does so silently: nothing fails, the Monday run just stops. Here that can happen easily, because the base digests rarely move and so Dependabot rarely opens a PR. The images would then stop getting Debian fixes while every consumer keeps pulling them.
- Check:
gh workflow list --all -R false-systems/base-imagesshowsBuildasdisabled_inactivity(without--all, a disabled workflow is not listed at all). - Re-enable:
gh workflow enable build.yml -R false-systems/base-images, thengh workflow run build.yml -R false-systems/base-images --ref mainto rebuild at once. - Alarm:
ci/staleness.shfails when the image behind any moving tag is more thanSTALE_DAYS(default 10) days old. It reads the images anonymously and needs onlydocker buildxandjq, so it can run from anywhere:git clone https://github.com/false-systems/base-images && base-images/ci/staleness.sh. A disabled schedule cannot report itself, so the alarm has to live outside this repository. Follow-up (false-infra, not done yet): a weekly scheduled job there that runsci/staleness.shand fails loudly.
This repository is public so that its CI minutes are free, and so that anyone, including a customer's air-gapped mirror, can pull its images anonymously. That makes two rules non-negotiable:
- Never use a self-hosted runner here. Any pull request, from anyone, runs
its own copy of the workflow, so it can change
runs-on. The org's own machines must never run that code. - Nothing secret in the logs. They are public. The only credential is the
job's own
GITHUB_TOKEN, forghcr.io.
The org enforces the first rule as well. Since 2026-09-27 the org's only
runner group, Default, has "Allow public repositories" off, and every
org runner is in it: the ARC pods, the Mac runners, the deploy runner and the
Windows box. No public repository can reach any of them, so fork-PR code of a
public repo never runs on the org's machines, and a self-hosted label here
would leave the job queued forever. The repo-level runners (selko, perusta)
belong to private repos. A new runner group must keep the setting off. To
check:
gh api orgs/false-systems/actions/runner-groups \
--jq '.runner_groups[] | "\(.name) allows_public_repositories=\(.allows_public_repositories)"'Fork pull requests also need a maintainer's approval before any workflow runs
(the org's policy is all_external_contributors), and the org's default
workflow token is read-only.
GHCR creates each package private. After the first successful run on
main, set rust-builder and elixir-builder to Public under
Org → Packages → package settings (there is no API for this). Otherwise
Dependabot, the ARC runners, ci/staleness.sh and customers cannot pull them
anonymously.