# baseimage A fat Docker base image that trades size for build speed. Everything a typical Go, Node, Python, or Hugo project needs is preinstalled. Child images only copy their source and build, without spending the first 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`](https://github.com/peterbourgon/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`: ```dockerfile 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: ```sh #!/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 installs Valkey, PostgreSQL and nginx but never runs them; a child image that wants one runs it as a service. 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 ```sh 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.