Publish a nonroot image alongside the root one - #374
Merged
Conversation
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
Contributor
There was a problem hiding this comment.
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:nonrootand explicitly setUSER 65532:65532. - Change the runtime default
DECPM_DIRto/home/nonrootso the non-root process can create itsdata/directory by default. - Update
docs/DEPLOYMENT_GUIDE.mdto 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.
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.
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.
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`.
scolear
approved these changes
Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
BASE_TAGlatestnonrootRUNTIME_UID065532DECPM_DIR_DEFAULT//home/nonrootI read both base configs from the registry rather than trusting the tag names:
DECPM_DIRhas 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 plaindocker runwould fail on its first write. Deployments are unaffected either way: the guide mounts a volume and passes-d /app, anddevelopment/docker-compose.ymlbuilds from a different Dockerfile and setsDECPM_DIR: /appitself.release.ymlandbuild.ymleach 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
-nonrootis the one to prefer. A barerunAsNonRoot: truewith norunAsUserrejects the plain tag, because the image declares uid 0.The Deployment example gains a pod-level context:
plus
allowPrivilegeEscalation: falseandcapabilities: drop: ["ALL"]on the container. Because it pinsrunAsUseritself, that example runs either tag as 65532.Two details worth a reviewer's eye:
busyboxdefaults to root, and withrunAsNonRoot: trueat pod level the pod refuses to start unless that container also runs nonroot. It inherits the pod context, andfsGroupis what then lets both it and the app write to the mounted PVC. WithoutfsGroupthis breaks the deploy.readOnlyRootFilesystemis left commented, not set. It should hold, since every/tmpandtempfileuse I found is test code or thegen-typesbinary, not the server, but I cannot prove it without a live deploy and a dependency writing to/tmpwould surface only at runtime. The guide says to enable it and watch one restart.Migrating an existing volume
Copilot flagged that
chown 65532:65532 ./dataonly touches the directory entry. It is right, and the failure is worse than it looks:from_filere-asserts mode 0600 ondata/noise.keybefore every read, and uid 65532 cannot chmod a file root owns, so a half-chowned volume fails startup withFailed to chmod key file.fsGroupdoes 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 usechown -R.Also documented: the v1.6.2-and-earlier workaround, which is the same one-time chown plus
runAsUser: 65532andfsGroupon 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 theCOPY. Both now point atdevelopment/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:
gcr.io(above).Build & Push Private Imagejob 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
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