Skip to content

Publish a nonroot image alongside the root one - #374

Merged
schronck merged 5 commits into
mainfrom
fix/docker/nonroot-runtime-image
Sep 1, 2026
Merged

Publish a nonroot image alongside the root one#374
schronck merged 5 commits into
mainfrom
fix/docker/nonroot-runtime-image

Conversation

@schronck

@schronck schronck commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

The runtime image had no USER, so the container ran as uid 0. The binary never needed it: it binds 8080 and 9000, both above 1024, and writes only under its data directory.

Rather than move the published image to nonroot and break every deployment that assumes uid 0 or DECPM_DIR=/, this publishes both. The existing tag keeps its base, its user and its defaults. A second tag, -nonroot, is the same binary running as uid 65532.

One Dockerfile, three build args

arg root (default) nonroot
BASE_TAG latest nonroot
RUNTIME_UID 0 65532
DECPM_DIR_DEFAULT / /home/nonroot

I read both base configs from the registry rather than trusting the tag names:

latest   User: '0'      WorkingDir: '/'
nonroot  User: '65532'  WorkingDir: '/home/nonroot'

DECPM_DIR has to move with the user. It is the root dir and the app writes $DECPM_DIR/data, so leaving the nonroot image on / would put the database at /data, which uid 65532 cannot create, and a plain docker run would fail on its first write. Deployments are unaffected either way: the guide mounts a volume and passes -d /app, and development/docker-compose.yml builds from a different Dockerfile and sets DECPM_DIR: /app itself.

release.yml and build.yml each push both tags from one binary, one Dockerfile and one build stamp, so a dev image can be deployed under the same pod security context as a release.

The deployment guide

New "Root or nonroot" section: what the two tags are, and that -nonroot is the one to prefer. A bare runAsNonRoot: true with no runAsUser rejects the plain tag, because the image declares uid 0.

The Deployment example gains a pod-level context:

securityContext:
  runAsNonRoot: true
  runAsUser: 65532
  runAsGroup: 65532
  fsGroup: 65532

plus allowPrivilegeEscalation: false and capabilities: drop: ["ALL"] on the container. Because it pins runAsUser itself, that example runs either tag as 65532.

Two details worth a reviewer's eye:

  • The init container matters. busybox defaults to root, and with runAsNonRoot: true at pod level the pod refuses to start unless that container also runs nonroot. It inherits the pod context, and fsGroup is what then lets both it and the app write to the mounted PVC. Without fsGroup this breaks the deploy.
  • readOnlyRootFilesystem is left commented, not set. It should hold, since every /tmp and tempfile use I found is test code or the gen-types binary, not the server, but I cannot prove it without a live deploy and a dependency writing to /tmp would surface only at runtime. The guide says to enable it and watch one restart.

Migrating an existing volume

Copilot flagged that chown 65532:65532 ./data only touches the directory entry. It is right, and the failure is worse than it looks: from_file re-asserts mode 0600 on data/noise.key before every read, and uid 65532 cannot chmod a file root owns, so a half-chowned volume fails startup with Failed to chmod key file.

fsGroup does not cover this either. It re-owns volume contents to the group and adds group write, but the user owner stays root, so the chmod is still denied. I had claimed the opposite in an earlier revision of the guide. The guide now has a "Moving an existing node to uid 65532" section: a one-time root init container that chowns the PVC, dropped again after the first successful start. Both docker quick-starts use chown -R.

Also documented: the v1.6.2-and-earlier workaround, which is the same one-time chown plus runAsUser: 65532 and fsGroup on the existing image.

Drive-by

README and USER_GUIDE told you to build the image with docker build --ssh ... -t dec-party-manager ., which cannot work: the root Dockerfile copies in a CI-built binary and compiles nothing, so a fresh clone fails on the COPY. Both now point at development/Dockerfile, which is what the README already says further down.

Verification

No docker daemon in this environment, so no local build. What I did check:

  • Both base image configs, pulled from gcr.io (above).
  • The guide's Deployment manifest parses as YAML, and the resulting security contexts and command are the intended ones.
  • CI builds both variants on every push, so the Build & Push Private Image job on this PR exercises the root and the nonroot build.

Neither image is runtime-verified. The remaining risk is the nonroot variant's first write under a real volume.

Follow-up the issue asks for and this does not do

Cut a new release so the rebuilt image picks up the current Debian 12 package versions.

That is a tag push, not a code change, so it stays with whoever cuts releases. The OS-level CVEs the scanners report come from the base image and clear on a rebuild.

Closes #372

The runtime image was built on gcr.io/distroless/cc-debian12 with no USER,
so the container ran as uid 0. The binary never needed it: it binds 8080
and 9000, both above 1024, and writes only under its data directory.

Switches the base to the :nonroot tag, which carries uid/gid 65532 and a
home directory it owns, and states USER 65532:65532 explicitly so the
runtime identity is visible in this file rather than inherited.

DECPM_DIR moves from / to /home/nonroot. It is the ROOT dir and the app
writes $DECPM_DIR/data, so the old default put the database at /data, which
uid 65532 cannot create. Deployments override it anyway, but a plain
docker run now works instead of failing on the first write.

The deployment guide gains a pod securityContext with runAsNonRoot,
runAsUser/runAsGroup 65532 and fsGroup 65532 on the data volume, plus
allowPrivilegeEscalation false and all capabilities dropped on the
container. The init container inherits the pod context: busybox defaults to
root, and with runAsNonRoot set the pod would refuse to start otherwise.
fsGroup is what lets both it and the app write to the mounted PVC.

readOnlyRootFilesystem is left commented rather than set. It should hold,
but it has not been proven against a live deploy and a dependency writing
to /tmp would surface only at runtime.

Closes #372
@schronck
schronck requested review from a team and sosaucily August 25, 2026 09:38
@schronck schronck self-assigned this Aug 25, 2026
@schronck
schronck requested review from scolear and a lite review from Copilot August 25, 2026 09:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the runtime container by running the release image as a non-root user (uid/gid 65532) and updates the Kubernetes deployment documentation to match the tightened security posture and filesystem ownership requirements.

Changes:

  • Switch the runtime base image to gcr.io/distroless/cc-debian12:nonroot and explicitly set USER 65532:65532.
  • Change the runtime default DECPM_DIR to /home/nonroot so the non-root process can create its data/ directory by default.
  • Update docs/DEPLOYMENT_GUIDE.md to add pod/container securityContext settings (runAsNonRoot, runAsUser, fsGroup, drop caps, etc.) and document the init-container interaction.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
docs/DEPLOYMENT_GUIDE.md Documents non-root runtime behavior and updates the Deployment example to use pod/container security contexts appropriate for uid 65532.
Dockerfile Moves the runtime image to distroless :nonroot, sets USER 65532:65532, and updates DECPM_DIR defaults for non-root writes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Dockerfile Outdated
The example mounted ./data at /data, which worked only because DECPM_DIR
defaulted to / and the app writes $DECPM_DIR/data. With the nonroot default
at /home/nonroot the container would write inside its own filesystem and
the mount would sit unused, so the data disappeared with the container
without any error.

Also adds the chown the nonroot image needs: a bind mount keeps the host's
ownership, so uid 65532 cannot write to it otherwise.

Review finding from Copilot on #372.
Same problem as the USER_GUIDE example: ./data mounted at /data worked only
under the old DECPM_DIR=/ default. Copilot flagged the USER_GUIDE one;
grepping for the pattern found this one too.

Review finding from Copilot on #372.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread USER_GUIDE.md Outdated
Comment thread README.md Outdated
The root image keeps its uid 0 base and `DECPM_DIR=/`, so existing pins and
deployments are untouched. The same Dockerfile now also builds a `-nonroot`
variant off `gcr.io/distroless/cc-debian12:nonroot`, running as uid 65532
with `DECPM_DIR=/home/nonroot` — uid 65532 cannot create `/data`, so the
default has to move with the user.

Three build args select the variant: BASE_TAG, RUNTIME_UID and
DECPM_DIR_DEFAULT. Both release.yml and build.yml push both tags from one
binary and one build stamp.

The quick-start build command in README and USER_GUIDE pointed at the root
Dockerfile, which only wraps a CI-built binary and cannot build from source;
it now points at development/Dockerfile like the rest of the README says.
@schronck schronck changed the title Run the release image as uid 65532 Publish a nonroot image alongside the root one Sep 1, 2026
A `chown` on the directory entry alone leaves the files inside owned by root.
The node re-asserts mode 0600 on `data/noise.key` at every start, and uid
65532 cannot chmod a file it does not own, so a half-migrated volume fails on
startup rather than on first write.

fsGroup does not cover this: it re-owns volume contents to the group and adds
group write, but the user owner stays root. The deployment guide now documents
the one-time root init container that fixes an existing PVC, and the two
docker quick-starts use `chown -R`.
@schronck
schronck merged commit 8dbc8c2 into main Sep 1, 2026
10 checks passed
@schronck
schronck deleted the fix/docker/nonroot-runtime-image branch September 1, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish a hardened non-root runtime image

3 participants