Single-RUN image built by build.sh on ubuntu:26.04: build-essential, distro plus latest Go, nvm with Node LTS, corepack with yarn/pnpm cached, wrangler, hugo extended, golangci-lint, pyenv with latest Python 3, pip and virtualenv. runit services supervised under runsvinit as PID 1. Docs, man pages, caches and non-English locales stripped. Includes build logs for the 2026-11-05 release (amd64 and arm64).
baseimage
A fat Docker base image that trades size for build speed. Everything a
typical Go, Node, Python, or Hugo project needs is preinstalled, so child
images only have to copy their source and build, instead of spending the
first few minutes of every build on apt-get install and toolchain
downloads.
Image: git.eeqj.de/sneak/baseimage, built for linux/amd64 and
linux/arm64.
What is in it
| Component | Source | Location |
|---|---|---|
| Ubuntu 26.04 | ubuntu:26.04, fully upgraded |
/ |
| init | runsvinit (built from Go) |
/usr/local/sbin/runsvinit |
| services | runit (apt) |
/etc/sv, /etc/service |
| build-essential | apt | gcc, make, pkg-config |
| Go (distro) | apt golang-go |
/usr/lib/go-* |
| Go (latest) | go.dev tarball | /usr/local/go |
| golangci-lint | GitHub release | /usr/local/bin/golangci-lint |
| Hugo (extended) | GitHub release .deb |
/usr/local/bin/hugo |
| Node (distro) | apt nodejs, npm |
/usr/bin/node |
| nvm | git tag | /usr/local/nvm |
| Node (LTS) | nvm install --lts |
/usr/local/nvm/current |
| corepack | npm install -g corepack |
nvm LTS global bin |
| yarn, pnpm | corepack, cache pre-populated | /usr/local/corepack |
| wrangler | npm install -g wrangler |
nvm LTS global bin |
| Python (distro) | apt python3, pip, venv |
/usr/bin/python3 |
| pyenv | git tag | /usr/local/pyenv |
| Python (latest) | pyenv install |
/usr/local/pyenv/versions/* |
| virtualenv | apt, and pip in pyenv Python | virtualenv on PATH |
Plus the usual odds and ends: git, curl, wget, jq, rsync,
openssh-client, zip/unzip, xz-utils, bzip2, and the -dev
headers needed to compile CPython and common native extensions.
The "latest" toolchains win on PATH. go, node, npm, yarn,
python3, and pip all resolve to the manually installed current
versions; the distro packages are there as a fallback and to satisfy apt
dependencies in child images.
PATH order:
/usr/local/go/bin
/root/go/bin
/usr/local/nvm/current/bin
/usr/local/pyenv/shims
/usr/local/pyenv/bin
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment variables set in the image: NVM_DIR, NVM_SYMLINK_CURRENT,
COREPACK_HOME, COREPACK_ENABLE_DOWNLOAD_PROMPT=0,
COREPACK_ENABLE_AUTO_PIN=0, PYENV_ROOT, GOPATH, LANG=C.UTF-8.
Yarn and corepack
yarn is provided by corepack, which is how the Yarn project ships Yarn
4. Corepack reads the packageManager field in a project's package.json
and runs exactly that version. The corepack cache in /usr/local/corepack
is pre-populated with current Yarn stable (the default when a project does
not pin one), Yarn 1 classic, and current pnpm, so projects pinning any of
those do not download anything at run time. A project pinning some other
version will be fetched on first use.
Pinned versions
Only the sources that have no "latest" lookup are pinned, as build args in
the Dockerfile:
| Arg | What |
|---|---|
RUNSVINIT_COMMIT |
peterbourgon/runsvinit |
NVM_GIT_TAG |
nvm-sh/nvm release tag |
PYENV_GIT_TAG |
pyenv/pyenv release tag |
Everything else resolves to its current release at build time: Ubuntu packages, Go, Node LTS, Python, Hugo, golangci-lint, npm, yarn, pnpm, wrangler.
Services and init
PID 1 is runsvinit, a small
Go program written for exactly this job. It starts runsvdir on
/etc/service, reaps orphaned zombies, and on SIGTERM or SIGINT runs
sv stop on every service before letting runsvdir exit. docker stop
therefore stops services gracefully. The project is archived upstream, so
it is built from a pinned commit at image build time.
To add a service in a child image, create a runit service directory and
link it into /etc/service:
FROM git.eeqj.de/sneak/baseimage:latest
COPY sv/myapp/run /etc/sv/myapp/run
RUN chmod +x /etc/sv/myapp/run && ln -s /etc/sv/myapp /etc/service/myapp
A minimal run script:
#!/bin/sh
exec 2>&1
exec /usr/local/bin/myapp
Services should log to stdout/stderr; the container's log is the combined output of all services.
The image sets CMD ["/usr/local/sbin/runsvinit"] and no ENTRYPOINT, so
a child image that only needs one unsupervised process can override CMD
and ignore runit entirely, and docker run -it image bash works as
expected.
Cleanup
Installation leaves nothing behind that production does not need:
- apt lists and cache are removed, and
dpkgis configured (seerootfs/etc/dpkg/dpkg.cfg.d/01_nodoc) to exclude/usr/share/doc(except copyright files),/usr/share/man,/usr/share/info, and locales other than English. Package installs in child images inherit that config. - npm, nvm, pip, and Go build/module caches are emptied.
- Python test suites and the Go toolchain's test tree are stripped.
/root/gois removed; it is recreated on first use.
Development headers (-dev packages) are deliberately kept: they are
what child images need to build things.
Building
Native architecture only:
docker build -t git.eeqj.de/sneak/baseimage:latest .
Release build
Each architecture is built natively on a host of that architecture (arm64
locally, amd64 on fsn1app1 via DOCKER_HOST=ssh://root@fsn1app1), pushed
under a per-arch tag, and then joined into a multi-arch manifest:
# on each host, for its own arch
docker build --progress=plain -t baseimage:dev . 2>&1 | tee logs/YYYY-MM-DD/build-ARCH.log
docker tag baseimage:dev git.eeqj.de/sneak/baseimage:ARCH
docker push git.eeqj.de/sneak/baseimage:ARCH
# once both per-arch tags are in the registry
docker buildx imagetools create \
-t git.eeqj.de/sneak/baseimage:latest \
-t git.eeqj.de/sneak/baseimage:YYYY-MM-DD \
git.eeqj.de/sneak/baseimage:amd64 git.eeqj.de/sneak/baseimage:arm64
Tags:
| Tag | Meaning |
|---|---|
latest |
multi-arch manifest of the newest release |
YYYY-MM-DD |
multi-arch manifest of that day's release |
amd64, arm64 |
per-arch images behind the newest release |
The full --progress=plain build log for each arch of each release is
committed under logs/YYYY-MM-DD/.
Every unpinned version is resolved at build time, so a rebuild picks up current packages and tool releases. Rebuild and retag periodically; child images get everything new on their next build.
The registry sits behind Traefik. The large RUN layer (about 650MB
compressed, over 2GB uncompressed) needs the entrypoint
respondingTimeouts raised from Traefik's 60 second default, or the final
blob commit fails with a 499 or 500 and docker re-uploads the layer.
Interactive shells (login shells, or anything that sources
/etc/profile.d) also get the nvm and pyenv shell functions from
rootfs/etc/profile.d/baseimage.sh.
Layout
Dockerfile— base, environment, oneCOPY, oneADD, oneRUN.build.sh— everything theRUNdoes, in order: apt, Go, runsvinit, golangci-lint, Hugo, nvm/Node/corepack/wrangler, pyenv/Python, cleanup, sanity checks. Removes itself when done.rootfs/— files copied verbatim onto/beforebuild.shruns.logs/YYYY-MM-DD/— build logs for each released image, per arch.