Skip to content

[bugfix] Frontend: fix Caddy EPERM under hardened securityContext#476

Merged
els0r merged 5 commits into
mainfrom
fix/chart
Jun 17, 2026
Merged

[bugfix] Frontend: fix Caddy EPERM under hardened securityContext#476
els0r merged 5 commits into
mainfrom
fix/chart

Conversation

@els0r

@els0r els0r commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Problem

Deploying the goquery-ui Helm chart fails at container startup:

env.js written with GQ_API_BASE_URL=<empty/proxy>
/docker-entrypoint.sh: exec: line 27: caddy: Operation not permitted

The chart runs the pod with a hardened securityContext (allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities.drop: [ALL], non-root). Two image-level assumptions broke under it.

Root causes & fixes

  1. Caddy file capability → EPERM. The caddy binary copied from caddy:2-alpine carries cap_net_bind_service=+ep (so upstream Caddy can bind :80/:443 as non-root). BuildKit's COPY preserves that file capability. With allowPrivilegeEscalation: false, the kernel's no_new_privs flag makes exec() of a file bearing file caps return EPERM — exactly the error above, at the entrypoint's exec "$@". We listen on :5137 (non-privileged), so the capability is unnecessary → strip it with setcap -r.

  2. Caddy storage on read-only rootfs. The alpine base never sets XDG_DATA_HOME/XDG_CONFIG_HOME, so Caddy fell back to $HOME/.local/share & $HOME/.config on the read-only rootfs — producing startup errors and leaving the chart's /data & /config mounts inert. Point both env vars at /data and /config.

Reproduce-locally harness

docker-compose.yml is now a single default service that runs the production image hardened 1:1 with the chart's securityContext (no-new-privileges, read_only, cap_drop ALL, user 1000, tmpfs-backed writable paths). No profiles — docker compose up just works. This is what surfaced both bugs and would have caught them before the cluster:

docker compose up --build    # or: make docker-up  → http://localhost:8080

Verified end-to-end: container Up (healthy), / returns 200, /env.js generated, Caddy writes to /data/caddy & /config/caddy with no read-only errors.

Local hot-reload development is npm run dev (:5173) — the old container-wrapped dev path only ran that same command, so it and its Dockerfile.dev / .env.development were removed.

Versioning — no Chart.yaml change here

This is an image fix (Dockerfile) with no chart-template change, so it deliberately leaves Chart.yaml untouched. Chart releases follow two avenues:

  1. version: bump — chart-only changes (templates/values), independent of the app's v*.*.* tags; published by publish-chart.yml on merge to main.
  2. Tagged release — bumps appVersion (and version:) to the new goprobe/frontend image and ships a new chart.

This fix belongs to avenue 2: it reaches chart users when v4.2.12 is cut — build-docker.yml rebuilds goprobe/frontend:4.2.12 and the release bumps the chart's appVersion.

Note

Avenue 2 isn't automated yet — appVersion is bumped by hand today (as it was for 4.2.11 in #474). Wiring the appVersion bump into the release process is a worthwhile follow-up, but out of scope for this fix.

Files

  • frontend/goquery-ui/Dockerfilesetcap -r + XDG_DATA_HOME/XDG_CONFIG_HOME
  • frontend/goquery-ui/docker-compose.yml — single hardened prod-parity service (no profiles)
  • frontend/goquery-ui/Makefiledocker-up/-stop/-logs on the default service; drop docker-dev
  • frontend/goquery-ui/README.mdnpm run dev for dev, docker compose up for prod-like
  • removed Dockerfile.dev / .env.development (+ its .gitignore exception) — orphaned by the profile collapse

🤖 Generated with Claude Code

The goquery-ui Helm chart runs the frontend pod with a hardened
securityContext (allowPrivilegeEscalation: false, readOnlyRootFilesystem,
cap drop ALL, non-root). Two image-level assumptions broke under it:

1. The Caddy binary copied from caddy:2-alpine carries the
   cap_net_bind_service file capability. Under no_new_privs the kernel
   refuses to exec() a file with file caps and returns EPERM
   ("caddy: Operation not permitted"). We listen on :5137, so the cap is
   unneeded — strip it with `setcap -r`.

2. The alpine base never sets XDG_DATA_HOME/XDG_CONFIG_HOME, so Caddy
   wrote storage to $HOME/.local & $HOME/.config on the read-only rootfs,
   erroring on startup and leaving the chart's /data & /config mounts
   inert. Point both at /data and /config.

Also add a hardened `prod` profile to docker-compose that mirrors the
chart's securityContext 1:1, so these deployment-time failures surface
locally (`make docker-prod`) instead of in the cluster.

This is an image fix: it ships to chart users via the v4.2.12 release,
which rebuilds goprobe/frontend and bumps the chart's appVersion. No
Chart.yaml change here — appVersion is owned by the release process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@fako1024 fako1024 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, this is completely overboard, using another profile will just overcomplicate the use. We have to address this properly in the main profile.

Address review: drop the dev/prod compose profiles — they overcomplicate
use. docker-compose now has one default service: the production Caddy
image, hardened 1:1 with the Helm chart's securityContext. Local
hot-reload development is `npm run dev` directly (the old `dev` profile
only wrapped that same command in a container on the same :5173).

Update Makefile (docker-prod/-stop/-logs use the default service; drop
docker-dev) and README accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@els0r

els0r commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

Addressed in a451ad3. Dropped the dev/prod profiles entirely — docker-compose.yml now has a single default service (goquery-ui) that is the hardened prod image, so docker compose up just works with no profile flags. Local hot-reload dev stays as npm run dev (the old dev profile only wrapped that same command in a container on the same :5173, so nothing is lost). Makefile and README updated to match. Re-verified the single service: Up (healthy), / → 200, zero read-only/permission errors.

Dockerfile.dev and .env.development were only used by the docker-compose
dev profile, which was dropped in favour of `npm run dev`. Remove them
and the now-moot .env.development gitignore exception (.env stays ignored
for the prod compose's host-specific runtime overrides).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@fako1024 fako1024 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor wording feedback, fix it!

Comment thread frontend/goquery-ui/docker-compose.yml Outdated
#
# Usage:
# docker compose up (or: make docker-dev)
# docker compose up --build (or: make docker-prod)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If there's only one profile, no need to suffix with -prod.

Comment thread frontend/goquery-ui/Makefile Outdated
@echo " make docker-dev - docker compose dev server (hot reload)"
@echo " make docker-stop - stop docker compose dev server"
@echo " make docker-logs - view docker compose dev logs"
@echo " make docker-prod - run hardened prod-like image via docker compose"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If there's only one profile, no need to suffix with -prod.

Comment thread frontend/goquery-ui/Makefile Outdated
docker-dev:
@echo "Starting Docker development server with hot reload..."
@echo "Access the application at http://localhost:5173"
.PHONY: docker-prod

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If there's only one profile, no need to suffix with -prod.

Comment thread frontend/goquery-ui/Makefile Outdated
@echo "Starting Docker development server with hot reload..."
@echo "Access the application at http://localhost:5173"
.PHONY: docker-prod
docker-prod:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If there's only one profile, no need to suffix with -prod.

Comment thread frontend/goquery-ui/README.md Outdated
```bash
make docker-dev # same as compose dev profile
make docker-prod # same as compose prod profile
make docker-prod # run hardened prod-like image (docker compose up --build)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If there's only one profile, no need to suffix with -prod.

Address review: with a single compose service there's no prod/dev split,
so the `-prod` suffix is redundant. Rename `make docker-prod` -> `docker-up`
(consistent with docker-stop/-logs/-build) and update the compose/README
references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@els0r

els0r commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

Addressed in ad550f9 — dropped the redundant -prod suffix now that there's a single compose service: make docker-prodmake docker-up (pairs with docker-stop/-logs/-build), with the compose-header and README references updated to match.

@els0r els0r merged commit 1320822 into main Jun 17, 2026
10 checks passed
els0r added a commit that referenced this pull request Jun 17, 2026
…477)

## What

Automates **avenue 2** of the goquery-ui chart release model (PR #476):
a tagged app release now retargets the chart's `appVersion`, bumps its
`version:`, ships the chart, and records the bump on `main` — no manual
edit.

Adds a `release-chart` job to
[build-docker.yml](.github/workflows/build-docker.yml):

- `needs: [build-docker]` — runs only after the `goprobe/frontend` image
is pushed, so the chart never pins an image that doesn't exist yet.
- `if: !contains(github.ref_name, '-')` — final tags only; prereleases
(`-rc*`) build images but never ship a chart.
- Reads `appVersion` from `main`; **idempotency guard**: if it already
equals the tag, the job is a no-op (re-runs and partial-failure retries
converge).
- Otherwise patch-bumps `version:`, sets `appVersion` to the tag's
`X.Y.Z` (surgical `sed`, preserving the chart's comments/spacing), `helm
package`, GHCR existence check, `helm push`, then commits both fields
back to `main` with `GITHUB_TOKEN` under a `[trivial]` message.

## Why this shape

Publishing straight from the tag via `helm package --app-version`
sidesteps the well-known rule that a `GITHUB_TOKEN` push does **not**
re-trigger another workflow — so we need no PAT, no
`repository_dispatch`, and no second publish path. The "won't
re-trigger" behaviour becomes a feature (no double publish).
Alternatives weighed (always-`latest`, commit-back-with-PAT) and the
rationale are in [ADR
0002](docs/adr/0002-tagged-releases-publish-goquery-ui-chart-pinned-to-appversion.md);
shared vocabulary is in [CONTEXT.md](CONTEXT.md).

`publish-chart.yml` is unchanged and remains the path for avenue 1
(chart-only changes merged to `main`).

## Test plan

Verified locally:
- Workflow YAML parses; both jobs recognized.
- Version logic: `0.1.1 → 0.1.2`, `appVersion → "4.3.0"`.
- `sed` edit touches only the two value lines; `apiVersion` untouched;
comments/spacing preserved.

Exercised live only on the first real `v*.*.*` tag (helm GHCR push +
`GITHUB_TOKEN` push to `main` need a runner).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Lennart Elsen <lelsen@open-systems.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants