Skip to content

build(docker): switch runtime image to distroless static - #4452

Open
stevenvegt wants to merge 5 commits into
masterfrom
build/distroless-runtime-image
Open

build(docker): switch runtime image to distroless static#4452
stevenvegt wants to merge 5 commits into
masterfrom
build/distroless-runtime-image

Conversation

@stevenvegt

@stevenvegt stevenvegt commented Aug 12, 2026

Copy link
Copy Markdown
Member

Problem

Our runtime image is based on a Linux distribution (alpine). A distro ships packages, and packages become outdated: our images are only rebuilt on release, so anything installed in the image slowly drifts behind and can accumulate known problems until the next release. This is what image scanners (Docker Hub among them) periodically flag.

We don't actually need those packages. The nuts binary is a pure static Go build (no cgo, pure-Go sqlite). The distro is only in the image for two things: tzdata (the auth module renders login contracts in the Europe/Amsterdam timezone) and curl (the Docker HEALTHCHECK).

Proposal: go distroless

Base the runtime image on gcr.io/distroless/static-debian13. It contains CA certificates and tzdata out of the box, so both remaining needs are covered, but no shell, package manager, or other userland.

Distroless publishes no version tags (only latest and variant tags), so the base is pinned by its multi-arch index digest. Dependabot's docker ecosystem (already configured for the Dockerfile) bumps the digest in weekly PRs, making base image updates explicit and reviewable instead of implicit at build time.

The one thing that needs a new solution is the HEALTHCHECK, since there is no curl or shell to run it with. This PR uses the existing nuts status client command in exec form: CMD ["/usr/bin/nuts", "status"]. It performs an HTTP GET on the internal API and exits non-zero on failure. This surfaced a bug: main() logged command errors but always exited 0, so any failure was invisible to Docker (and to scripts). The first commit fixes that.

Perspective: distroless vs upgrading packages

The alternative fix is to keep alpine and add apk -U upgrade --no-cache to the runtime stage, so every build picks up current packages.

What distroless buys us:

  • Smaller attack surface: no OS packages to exploit, nothing in the image but the binary and its data files.
  • Smaller image: 91MB instead of 110MB.
  • Less maintenance risk: no package set that goes stale between releases, nothing for scanners to flag, no base-image patch bumps to track.

What we sacrifice:

  • No shell: docker exec <container> sh no longer works for live debugging. Alternatives: docker debug, Kubernetes ephemeral debug containers.
  • No package manager: derived images (FROM nutsfoundation/nuts-node + RUN apk add ...), for example to add a private CA, will break. Alternatives: bind-mount over /etc/ssl/certs/ca-certificates.crt, or set SSL_CERT_FILE/SSL_CERT_DIR (honored by Go).
  • Operational habits change, so this belongs in a regular release with clear release notes, not in a patch.

For the supported 5.x and 6.x release branches the apk upgrade variant remains the right fix: it is a rebuild-only change with no behavioral impact, suitable for patch releases.

What's in this PR

  • fix(cli): exit with status 1 when a command fails. The server command is unaffected; it reports startup errors via logrus.Fatal and graceful shutdown still exits 0.
  • build(docker): switch the runtime stage to distroless static with the nuts status healthcheck. Numeric USER 18081:18081 is kept, so volume ownership semantics do not change.
  • build(docker): fix the long-standing CopyIgnoredFile build check warning: the .* pattern in .dockerignore also matches the literal path ., so BuildKit flagged COPY . . as copying an excluded file. .?* excludes the same dotfiles without matching the context root. docker build --check . is now clean.
  • build(docker): pin the distroless base image by digest (see above).
  • test(e2e): a few tests exec'd curl or rm inside the node container, which no longer works. File deletion moved to the host (the data dirs are bind mounts), and HTTP calls that need the compose network now run in a dedicated curlimages/curl helper service, gated behind a tools profile so up does not start it. All affected tests (openid4vp, rfc002, rfc021, private-transactions, network-issuance) pass locally against the distroless image.

Verification

  • Full container boot with sqlite: Docker health status transitions starting -> healthy once the node is up; probes exit 1 while it is down; docker stop exits 0.
  • Outbound public HTTPS works: the container downloaded the IRMA schemes during the smoke test, proving Go finds the CA bundle at /etc/ssl/certs/ca-certificates.crt. Europe/Amsterdam zoneinfo is present.
  • The e2e compose files only override the healthcheck interval and inherit the image's healthcheck command, so they work unchanged.
  • The base image is multi-arch (amd64, arm64 and more), matching our build matrix.
  • go test ./cmd/... ./core/status/... passes.

Notes

  • The healthcheck now probes /status/diagnostics (via nuts status) instead of /status, and its output is stored in the Docker health log. A dedicated lighter nuts healthcheck subcommand probing /status is a possible follow-up.
  • CLI exit codes: failing commands now exit 1 instead of 0. This is a bugfix, but worth a release notes entry.

Assisted-by: AI

main() logged errors returned by cmd.Execute but always exited with
status 0, so failures of client commands (e.g. 'nuts status' against a
node that is down) were invisible to scripts and to the Docker
healthcheck. The server command is unaffected: it reports startup
errors via logrus.Fatal, which already exits 1.

Assisted-by: AI
The alpine-based runtime image accumulated known-fixed CVEs between
releases (openssl, musl, zlib) because pre-installed packages were
never upgraded and the image is only rebuilt on release. The nuts
binary is pure static Go and only needed alpine for tzdata and curl:
distroless static ships CA certificates and tzdata, and the curl-based
healthcheck is replaced by the existing 'nuts status' client command,
which probes the internal API without needing a shell.

Assisted-by: AI
@qltysh

qltysh Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

❌ 1 blocking issue (1 total)

Tool Category Rule Count
shellcheck Lint Double quote to prevent globbing and word splitting. 1

@qltysh

qltysh Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (1)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: C
main.go0.0%39
Total0.0%
🤖 Increase coverage with AI coding...
In the `build/distroless-runtime-image` branch, add test coverage for this new code:

- `main.go` -- Line 39

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

The '.*' pattern in .dockerignore also matches the literal path '.',
so BuildKit's CopyIgnoredFile check flagged 'COPY . .' as copying an
excluded file. '.?*' requires at least one character after the dot,
which excludes the same set of dotfiles without matching the context
root.

Assisted-by: AI
Distroless publishes no version tags, only 'latest' and variant tags,
so pinning means pinning the multi-arch index digest. Dependabot's
docker ecosystem (already configured) keeps the digest updated via
weekly PRs, so base image updates become explicit and reviewable
instead of implicit at build time.

Assisted-by: AI
The distroless runtime image contains no shell or userland, so tests
can no longer exec curl or rm inside the node container. Deleting
connections.db now happens on the host (the data dirs are bind mounts
and the services run as the host user), and HTTP calls that need the
compose network run in a dedicated curlimages/curl helper service,
gated behind the 'tools' profile so 'up' does not start it. The rfc002
helper mounts nodeB's client certificate to keep acting as nodeB.

Assisted-by: AI
@reinkrul

Copy link
Copy Markdown
Member

What operational habits change? If things are really breaking, we might have to schedule this for v7.

We also have a development image; if we stop supporting derived images, we also need to formally retire that.

T.b.h., I use sh and curl a lot on my Nuts nodes, so I understand the change from security perspective, but I dislike it from "manual-ops" perspective. What would we risk if we keep baking curl into the image?

@reinkrul reinkrul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See questions/commecnts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants