Files
sneak 8f976e38e0 Add nginx (closes #11)
Install nginx from apt and check nginx -t in the smoke test.

Model: deepseek-v4-pro
2026-11-05 15:45:26 +00:00

12 KiB

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
shellcheck apt /usr/bin/shellcheck
govulncheck go install (latest) /usr/local/bin/govulncheck
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
prettier npm install -g prettier nvm LTS global bin
Playwright npm install -g playwright nvm LTS global bin
Playwright browsers playwright install (chromium, firefox) /ms-playwright
Firefox Mozilla apt repo /usr/bin/firefox
geckodriver GitHub release /usr/local/bin/geckodriver
headless Chrome Playwright Chromium chromium on PATH
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
tzdata apt /usr/share/zoneinfo
zstd apt /usr/bin/zstd
sqlite3 (CLI) apt /usr/bin/sqlite3
libvips apt libvips-dev, libvips-tools vips on PATH
libheif apt libheif-dev, libheif-plugin-aomenc HEIF/AVIF codec
ImageMagick apt magick
Ghostscript apt gs
Ruby apt ruby, ruby-dev /usr/bin/ruby
Bundler apt bundler bundle on PATH
Valkey apt valkey-server, valkey-tools valkey-server, valkey-cli
PostgreSQL apt postgresql psql, postgres, initdb, pg_ctl on PATH
nginx apt /usr/sbin/nginx

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, PLAYWRIGHT_BROWSERS_PATH.

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, and Python
  • Hugo and golangci-lint
  • npm, yarn, pnpm, wrangler, and prettier
  • Playwright and its browsers
  • Firefox and geckodriver

Playwright installs its browsers for the image's Playwright version only. A child image that pins a different Playwright version downloads its own browsers.

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 dpkg is configured (see rootfs/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/go is removed; it is recreated on first use.

Development headers (-dev packages) are deliberately kept: they are what child images need to build things.

Building and releasing

make release        # build, test, push both arches, tag, commit logs
make -j2 release    # same, with the two arches in parallel
make build          # build for this machine's arch only
make test           # smoke-test this machine's build

Each architecture is built natively on a daemon of that architecture: arm64 on the local daemon, amd64 on fsn1app1 via DOCKER_HOST. The daemon per arch is set by DOCKER_HOST_amd64 and DOCKER_HOST_arm64 in the Makefile and can be overridden on the command line. The release date defaults to today and can be overridden with DATE=YYYY-MM-DD.

make release runs, per arch, bin/build, bin/test, and bin/push, then bin/manifest to join the per-arch tags into latest and the dated tag, then bin/commit-logs to commit logs/YYYY-MM-DD/ and push the repo. bin/test checks that the toolchains resolve, yarn and pnpm work offline, nothing is left in /tmp, and a runit service is stopped cleanly by docker stop with exit code 0.

Registry login is done once on the local docker client with docker login git.eeqj.de; the client forwards the credentials to the remote daemon on push.

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 1.63 GB compressed, 4.3 GB on disk) 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

  • Makefile — target wiring and configuration only; no logic.
  • bin/ — one script per step: build, test, push, manifest, commit-logs, clean, plus lib.sh (shared setup) and native-arch.
  • Dockerfile — base, environment, one COPY, one ADD, one RUN.
  • build.sh — everything the RUN does, in order: apt, Go, runsvinit, golangci-lint, govulncheck, Hugo, nvm/Node/corepack/wrangler, browsers, pyenv/Python, cleanup, sanity checks. Removes itself when done.
  • rootfs/ — files copied verbatim onto / before build.sh runs.
  • logs/YYYY-MM-DD/ — build logs for each released image, per arch.