Skip to content

relay: Dockerfile + base hardening — portable deploy artifact (#32)#45

Merged
ilmoniemi merged 3 commits into
mainfrom
feature/32
May 11, 2026
Merged

relay: Dockerfile + base hardening — portable deploy artifact (#32)#45
ilmoniemi merged 3 commits into
mainfrom
feature/32

Conversation

@ilmoniemi
Copy link
Copy Markdown
Contributor

What

Adds a multi-stage Dockerfile and minimal .dockerignore at the repo root, plus a short Docker subsection in the README. No Go code changes.

  • Build stage: golang:1.26-bookworm pinned by digest. CGO_ENABLED=0, -trimpath, -s -w, -X main.Version=${VERSION} — mirrors the Makefile LDFLAGS. VERSION arrives via ARG and defaults to dev, leaving room for release tooling in relay: host-specific deploy manifest — blocked on hosting + TLS decisions #38 to pass a real version through without touching this file.
  • Runtime stage: gcr.io/distroless/static-debian12:nonroot pinned by digest. Belt-and-suspenders USER nonroot:nonroot so a base swap cannot silently regress non-root execution. EXPOSE 80 443; VOLUME /var/lib/relay/autocert as the documented autocert-cache mount point. ENTRYPOINT ["/pyrycode-relay"].
  • Both base digests carry # Tracks: <tag> comments so a human reviewer (or Renovate) can sanity-check what the digest is supposed to track when proposing a bump.
  • .dockerignore excludes .git, bin/, dist/, coverage.txt, *.test, and the Dockerfile/.dockerignore themselves. docs/ is intentionally not excluded — COPY . . pulls it in for the build stage and the multi-stage boundary drops it from the runtime image.

Issue

Closes #32.

Testing

This worktree has no Docker daemon available, so the spec's image-level verification steps (docker build, docker run --version, docker inspect for User / ExposedPorts / Volumes) cannot be exercised here and should be run by reviewer/CI. To de-risk that, the build invocation the Dockerfile uses was exercised natively:

  • go vet ./... — clean.
  • go test -race ./... — pass.
  • CGO_ENABLED=0 go build -trimpath -ldflags=\"-s -w -X main.Version=dev\" -o /tmp/relay ./cmd/pyrycode-relay && /tmp/relay --version — prints dev, exits 0. This validates the exact flag combination used inside the build stage; the only difference vs. the Dockerfile is GOOS=linux (cross-compile target).

Reviewer / CI to run on a Docker-capable host:

  1. docker build -t pyrycode-relay:dev .
  2. docker run --rm pyrycode-relay:dev --version → prints dev, exit 0.
  3. docker inspect --format '{{.Config.User}}' pyrycode-relay:devnonroot:nonroot (or 65532:65532).
  4. docker inspect --format '{{.Config.ExposedPorts}}' pyrycode-relay:dev80/tcp and 443/tcp.
  5. docker inspect --format '{{.Config.Volumes}}' pyrycode-relay:dev/var/lib/relay/autocert.

Architecture compliance

Implements docs/specs/architecture/32-dockerfile-base-hardening.md as written:

The base-image digests were fetched at implementation time via the Docker Hub and gcr.io registry APIs (no local Docker daemon needed):

  • golang:1.26-bookwormsha256:252599aeb51ad60b83e4d8821802068127c528c707cb7dd7afd93be057c6011c
  • gcr.io/distroless/static-debian12:nonrootsha256:a9329520abc449e3b14d5bc3a6ffae065bdde0f02667fa10880c49b35c109fd1

🤖 Generated with Claude Code

ilmoniemi and others added 2 commits May 11, 2026 10:04
Multi-stage Dockerfile producing a host-agnostic, hardened image:

- Build stage: golang:1.26-bookworm pinned by digest. Builds with
  CGO_ENABLED=0, -trimpath, -s -w, and -X main.Version=${VERSION} so the
  container build mirrors `make build` semantics. VERSION defaults to
  "dev" via ARG; release tooling (#38) can pass a real version through.
- Runtime stage: gcr.io/distroless/static-debian12:nonroot pinned by
  digest. Belt-and-suspenders `USER nonroot:nonroot` so a base swap
  cannot silently regress non-root execution. EXPOSE 80 443; VOLUME
  /var/lib/relay/autocert as the documented cache mount point. The host
  manifest (#38) wires actual port publishing, volume backing, and TLS
  termination policy.

Both base-image digests carry `# Tracks: <tag>` comments so a reviewer
(or Renovate) can sanity-check what the digest is supposed to track.

.dockerignore trims the build context: `.git`, build outputs, and the
Dockerfile itself. README gains a short Docker subsection under Build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ilmoniemi
Copy link
Copy Markdown
Contributor Author

Code Review: #32

Decision: PASS

Findings

None. No MUST FIX, no SHOULD FIX, no NIT worth raising.

Summary

Implementation matches the architecture spec verbatim. All five AC bullets satisfied:

  • Multi-stage Dockerfile with both bases pinned by @sha256:… and accompanied by # Tracks: <tag> comments (Dockerfile:3,7,31,33).
  • Build stage: golang:1.26-bookworm (digest-pinned), CGO_ENABLED=0 GOOS=linux, -trimpath -ldflags="-s -w -X main.Version=${VERSION}" mirroring Makefile:6-13, ARG VERSION=dev.
  • Runtime stage: gcr.io/distroless/static-debian12:nonroot (digest-pinned), belt-and-suspenders USER nonroot:nonroot, COPY --chown=nonroot:nonroot, VOLUME ["/var/lib/relay/autocert"], EXPOSE 80 443, ENTRYPOINT ["/pyrycode-relay"].
  • Layer ordering is cache-friendly: go.mod/go.sum copied before the rest of the source so source-only changes don't bust go mod download. ARG VERSION is positioned so it only invalidates the final go build RUN.
  • .dockerignore content matches the spec's § On .dockerignore exactly (.git, bin/, dist/, coverage.txt, *.test, Dockerfile, .dockerignore); docs/ is intentionally left in scope per the spec.
  • README subsection lands under Build; the wording around /var/lib/relay/autocert is actually slightly more accurate than the spec draft ("declares a volume mount point" vs "mounts the autocert cache at") because VOLUME doesn't itself mount anything.

Security review (security-sensitive label)

  • Architect's spec contains ## Security review with explicit **PASS.** verdict (categories: trust boundaries, adversarial inputs, privilege, supply chain, data at rest, logging, failure modes). The required pre-review obligation is met.
  • Diff inspected for security regressions: no secrets, no ADD from URLs, no RUN curl | sh, no package installs, no // #nosec annotations, no logging changes. .git is excluded from the build context. Binary stripped (-s -w) and -trimpath'd. Non-root execution defended at two layers (base + explicit USER).
  • CI green (test and security workflows both SUCCESS).

Notes

The developer disclosed that the worktree lacks a Docker daemon, so the spec's image-level verification (docker build, docker run --version, docker inspect) was deferred to reviewer / Docker-capable CI. They de-risked this by running the same go build invocation natively — sensible scoping and the right call to surface transparently rather than fake the verification.

🤖 Generated with Claude Code

- Feature doc (docs/knowledge/features/docker-image.md) — evergreen description of the portable OCI artifact: multi-stage build, digest-pinned bases with # Tracks: comments, static binary into distroless/static-debian12:nonroot, :80/:443 exposure, /var/lib/relay/autocert volume.
- Per-ticket codebase notes (docs/knowledge/codebase/32.md) — what landed and the judgement calls (VERSION as build arg, .dockerignore minimal, USER nonroot belt-and-suspenders).
- INDEX update — top entry under Features.
- PROJECT-MEMORY patterns — digest-pin + # Tracks: sibling comment; belt-and-suspenders invariants across base layers.
- Lessons — flag.Parse() exits 2 on --help (only --version is zero-exit no-network); distroless/static has no shell so pre-creation belongs in the host manifest.
@ilmoniemi ilmoniemi merged commit 131c328 into main May 11, 2026
2 checks passed
@ilmoniemi ilmoniemi deleted the feature/32 branch May 11, 2026 07:25
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.

relay: Dockerfile + base hardening — portable deploy artifact

1 participant