Skip to content

refactor(docker): replace the init-perms container with a gosu privilege-drop in the app entrypoints#127

Merged
Valtora merged 1 commit into
mainfrom
refactor/replace-init-perms-with-entrypoint-drop
Jul 24, 2026
Merged

refactor(docker): replace the init-perms container with a gosu privilege-drop in the app entrypoints#127
Valtora merged 1 commit into
mainfrom
refactor/replace-init-perms-with-entrypoint-drop

Conversation

@Valtora

@Valtora Valtora commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Description

Removes the one-shot nojoin-init-perms container, which existed only to chown the root-created ./data bind mount before the non-root app services could write to it. Because it gated the app services via depends_on: condition: service_completed_successfully, Compose had to keep it around as an exited (orphaned) container after every successful start.

Its single privileged operation now lives inside the app images' entrypoints: they enter as root only to repair ./data ownership, then immediately drop to appuser (uid 1000) via gosu before the long-running process starts. This is the standard postgres/redis official-image pattern.

Runtime behaviour is preserved — the api, worker, and worker-io processes still run as uid 1000. What changed is the image contract: the api and worker images now default to a root USER (dropping at runtime) instead of declaring USER appuser, so the release non-root gate was reworked to assert the dropped runtime uid rather than the static image-config USER.

New dependency: gosu (from the distro package — Debian trixie 1.17-3+b4, Ubuntu 24.04 1.17-1ubuntu0.24.04.3), chosen over a hand-copied static binary so it stays within Trivy's OS-package scanner coverage and the distro security auto-patch stream.

Key changes

  • backend/entrypoint.sh, backend/worker_entrypoint.sh: root preamble (mkdir + guarded recursive chown) then exec gosu appuser "$0" "$@". The stat -c %u guard skips the recursive walk when ownership is already correct — cheaper than the old container, which ran chown -R unconditionally on every boot.
  • docker/Dockerfile.api, docker/Dockerfile.worker: install gosu; drop USER appuser so the entrypoint starts as root. Worker also gains an explicit chmod +x on its entrypoint.
  • docker/Dockerfile.worker-io: trailing USER appuser -> USER root so the inherited worker entrypoint can perform the drop.
  • docker-compose.example.yml: removed the init-perms service and its two depends_on gates.
  • .github/workflows/release.yml: the non-root smoke now asserts runtime uid — frontend via id -u (unchanged image), api via PID 1's real uid in /proc/1/status, worker by running the image through its entrypoint (docker run ... id -u). This is strictly stronger than the old image-config USER check, at the cost of pulling the worker image.

Type of change

  • Breaking change (fix or feature that changes existing behaviour)
  • Documentation update

Note: "breaking" refers to the image/compose contract (image default USER is now root-with-runtime-drop; the init-perms service is gone). End-user runtime behaviour is unchanged.

Checks run

  • Backend tests
  • Python quality
  • Frontend lint
  • Frontend unit tests
  • Frontend build
  • Docs validation: python3 scripts/validate_docs.py
  • Alembic validation: python3 scripts/validate_alembic.py

The diff touches only Docker build files, entrypoint shell scripts, the release workflow, and docs — no Python, frontend, capture, or migration code — so the backend/frontend gates have nothing in scope to exercise. CI runs the full required set regardless. Additionally ran: bash -n on both entrypoints, YAML parse of release.yml, and docker compose config on the example compose.

Migration impact

  • No database migration in this PR.

Documentation impact

  • Updated the relevant guide(s) in the same PR (docs/DEPLOYMENT.md pre-publication verification, docs/SECURITY.md worker-io non-root wording).

Security impact

  • Touches capture ownership/exposure (container privilege model). docs/SECURITY.md boundaries preserved and updated. The runtime non-root property is unchanged (process is uid 1000); the images now enter root briefly by design solely to repair mount ownership, then drop via gosu. The release gate was updated to keep asserting the non-root property at runtime.

Manual verification

  • Confirmed gosu is apt-installable on both real bases (versions above).
  • Built a minimal image on the exact pinned Debian digest and exercised three scenarios: (1) fresh root-owned mount -> entrypoint chowns and drops to 1000 (host dir ends uid 1000); (2) restart with mount already uid 1000 -> recursive chown skipped, still drops; (3) explicit --user 1000 -> no root phase, runs directly as 1000. All correct.
  • docker compose config parses cleanly with no dangling init-perms references.

Pending before deploy (not blocking review): rebuild the real nojoin-api/nojoin-worker/nojoin-worker-io images and boot them live on the homelab. The release pipeline's health + non-root smoke validates the drop on the real built images before any tag is published.

@Valtora
Valtora merged commit 253c883 into main Jul 24, 2026
16 checks passed
@Valtora
Valtora deleted the refactor/replace-init-perms-with-entrypoint-drop branch July 24, 2026 15:56
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.

1 participant