Skip to content

Portfolio technical elevation: real CI verification, hardening, verified backups - #1

Merged
whybothercoding merged 17 commits into
mainfrom
feat/portfolio-technical-elevation
Aug 23, 2026
Merged

Portfolio technical elevation: real CI verification, hardening, verified backups#1
whybothercoding merged 17 commits into
mainfrom
feat/portfolio-technical-elevation

Conversation

@whybothercoding

Copy link
Copy Markdown
Owner

Summary

Elevates the stack from "validates in CI" to "provably works" — real runtime smoke-testing, closed hardening gaps, automated version currency, and a proven (not just documented) backup/restore path. Full plan: docs/superpowers/plans/2026-08-23-portfolio-technical-elevation.md (gitignored, local only).

  • Pin image versions (n8n 2.35.7, Baserow 2.3.3, Postgres 15.15-alpine3.24, Caddy 2.11.4-alpine) instead of :latest
  • Baserow healthcheck added (postgres/n8n already had one)
  • Resource limits + rotated logging on every service
  • Container hardening: no-new-privileges everywhere; cap_drop: ALL + minimal re-adds on Caddy/n8n/Postgres (Baserow deliberately left uncapped pending live verification — see CLAUDE.md)
  • Dependabot tracking pinned image tags + github-actions
  • Real CI smoke test: boots the dev stack and curls both health endpoints externally, not just docker compose config
  • Weekly Trivy scan of the four pinned images, results to the Security tab
  • Backup drill: writes a marker row, backs up, destroys the volumes, restores, fails if the marker isn't back — weekly in CI and via make backup-test locally
  • Third-party GitHub Actions pinned by commit SHA (response to the aquasecurity/trivy-action supply-chain compromise, March 2026)
  • Mermaid architecture diagram, 3 ADRs, README "Security & Reliability" section

Test plan

  • Validate workflow green — compose config, shellcheck, and the new smoke-test job (boots dev stack, checks /healthz + /api/_health/)
  • Security Scan workflow runs cleanly (results land in the Security tab regardless of findings — it's non-blocking)
  • Backup Drill workflow green (can trigger manually via workflow_dispatch rather than waiting for the weekly schedule)
  • Spot-check that the capability-hardening changes (cap_drop/cap_add) don't break Caddy TLS binding or Postgres first-init in the smoke-test logs

No local Docker daemon was available while writing this — everything above needs verification against real CI runs, and the hardening task in particular may need a follow-up commit if a capability list needs adjusting.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nn1ukWMFbhPZHHGSTLu7AQ

…s, and container hardening

- Replace :latest tags with pinned versions (n8n 2.35.7, Baserow 2.3.3,
  Postgres 15.15-alpine3.24, Caddy 2.11.4-alpine) so upgrades are deliberate,
  not silent on the next pull.
- Add a healthcheck to Baserow (postgres and n8n already had one) using its
  /api/_health/ endpoint.
- Add per-service memory limits and rotated/capped JSON logging so a runaway
  execution or import can't OOM or fill the disk on a small VPS.
- Harden every service with no-new-privileges; drop all Linux capabilities on
  Caddy/n8n/Postgres and re-add only what each specifically needs. Baserow is
  deliberately left uncapped for now — its process supervisor's root
  requirements aren't confirmed safe to guess at without live testing.
Tracks the version pins just added to docker-compose.yml (docker ecosystem,
GA support for docker-compose.yml since Feb 2025) and third-party Action
versions (github-actions ecosystem) weekly.
validate.yml now has a second job that boots the dev stack for real
(docker compose up --wait) and curls both health endpoints externally,
instead of only checking that the compose files parse. Also pins
actions/checkout by commit SHA rather than a floating tag.

Makefile gains test, restore, backup-test, and security-scan targets so the
new verification tooling has a local entry point, not just a CI-only one.
Scans images, not the compose file — Trivy has no native docker-compose
misconfiguration scanner (confirmed against its own issue tracker), so this
intentionally doesn't claim IaC coverage it doesn't have. Non-blocking
(visibility via the Security tab, not a merge gate) since some CVEs in
upstream images won't be fixable from this repo.

aquasecurity/trivy-action is pinned by commit SHA, not a tag — 75 of 76 of
its version tags were force-pushed with malware in March 2026.
scripts/restore.sh restores all three volumes from a backup.sh snapshot
(mirrors its dynamic project-name detection). scripts/backup-drill.sh proves
the cycle actually recovers data: writes a marker row, backs up, destroys
the volumes, restores, and fails if the marker isn't back. Shared between
`make backup-test` (local) and the new backup-drill.yml workflow (weekly +
on-demand in CI) so the two can't drift apart.

docs/backup-and-restore.md updated to document both.
- Replace the ASCII architecture diagram with a Mermaid graph (renders
  natively on GitHub, no added tooling).
- Add three ADRs: why Caddy, why one shared Postgres instance, why two
  networks instead of one.
- New README "Security & Reliability" section summarizing the hardening,
  scanning, and verified-backup work in one place for a reviewer.
- Update docs/updating-services.md and docs/troubleshooting.md for the
  version-pinning/Dependabot change and the new capability-hardening
  failure mode respectively.
- Update project CLAUDE.md with the new make targets, workflows, the
  Baserow capability-hardening caveat, and the blank-secrets-in-.env.example
  gotcha for anything that boots the stack directly from the template.
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Will be reverted before merge — see comment in the workflow file.
…an failure

postgres:15.15-alpine3.24 was never a published tag — 15.15 only ever paired
up to alpine3.23; alpine3.24 variants start at 15.18. Verified the real
current tag against the Docker Hub API directly rather than trusting a
summarized fetch (which is exactly how the wrong tag got in here). Correct
tag is postgres:15.19-alpine3.24, confirmed to exist.

Also guard the Trivy SARIF upload step on the output file actually existing
— it was crashing the whole job with a bare "Path does not exist" whenever
the scan step failed for any reason (this bug, or a future transient one),
instead of failing informatively or being skipped cleanly.

Caught by the smoke-test and backup-drill jobs actually trying to boot the
stack — exactly the kind of failure `docker compose config --quiet` alone
would never catch.
…xisted

Real bug the smoke-test job caught on its first actual boot of the stack:
POSTGRES_DB only creates one database (the default, "automation") at initdb
time. Neither n8n's pg client nor Baserow's Django backend creates its own
database on connect — both just fail hard with "database does not exist".
This means N8N_DB_NAME/BASEROW_DB_NAME never worked; the stack had
apparently never been booted for real before this CI job existed.

Fix: postgres-init/init-app-databases.sh, mounted into Postgres's
docker-entrypoint-initdb.d/, creates both databases explicitly using the
official image's first-init hook. Only runs once, against an empty
postgres_data volume — documented in docs/troubleshooting.md and CLAUDE.md,
including the manual remediation for anyone already running an
already-initialized volume from before this fix.

Corrected the same false "auto-created by each app" claim in .env.example
and CLAUDE.md, which had been asserting this incorrectly since the original
plan.
…ot migrations

Both n8n and Baserow actually came up correctly after the database fix —
confirmed from the raw logs: Baserow logged a real 200 on /api/_health/ and
finished all Django migrations. It still got marked unhealthy because the
old healthcheck window (start_period 45s + 5 retries * 30s interval ≈ 195s)
was too tight: first-boot on an empty volume also runs Baserow's built-in
template sync (importing dozens of bundled templates via a background
worker), which took ~150s before the health endpoint reliably responded on
a GitHub Actions runner.

Widened to start_period 150s / interval 15s / retries 10 (~300s ceiling,
checks more often once past start_period so it doesn't overshoot success by
much). Bumped --wait-timeout from 180 to 320 everywhere docker compose up
--wait is used, so compose's own wait doesn't time out before the
healthcheck's retry budget is exhausted.
--wait fails permanently on the first "unhealthy" transition, even if the
container recovers seconds later. Confirmed from raw logs: Baserow's
first-boot migration + template sync briefly reported unhealthy under CI
resource contention, then answered 200 on every single subsequent check for
over two minutes — but --wait had already killed the job on the first blip.

scripts/wait-for-healthy.sh polls `docker inspect` directly in a loop and
only fails on a genuine timeout with nothing ever reporting healthy, which
is what a smoke test should actually care about. Used by validate.yml's
smoke-test job, backup-drill.yml's boot step, backup-drill.sh's post-restore
wait, and `make test`.
…irectly

Still failing after the previous fix, with a stranger symptom: Baserow's
/api/_health/ endpoint returned 200 on every single logged request for over
two minutes straight, yet docker inspect .State.Health.Status never once
reported "healthy" — confirmed from raw logs (six consecutive 200s, script
still timed out at 400s). Docker's own HEALTHCHECK state tracking appears to
get stuck under GitHub Actions runner resource contention independent of the
actual endpoint's behavior.

wait-for-healthy.sh now runs each service's check command directly via
`docker compose exec` and retries until THAT succeeds, never consulting
Docker's Health.Status at all. This is the same command the container's own
HEALTHCHECK uses, just executed and retried outside of Docker's internal
bookkeeping — immune to whatever was causing that bookkeeping to get stuck.

docker-compose.yml's Baserow healthcheck config is left in place (still
useful for `docker compose ps` / operator visibility on a real deploy) but
its comment now says plainly that it isn't load-bearing for anything in this
repo anymore.
…rst boot

Root cause of the persistent boot failure, finally isolated from the raw
logs: "Worker exited prematurely: signal 9 (SIGKILL)" in Baserow's celery
export worker — a classic OOM kill, not a healthcheck or timing issue at
all. Baserow runs gunicorn + a celery worker + celery beat concurrently, and
first boot on an empty volume adds Django migrations and template-sync
import on top of that. 1g covers steady-state but not that burst.

Raised to 2g. Also corrected docs/initial-setup.md's VPS sizing guidance —
"2GB RAM, 4GB recommended" understated it; the real worst-case ceiling
across all four services' limits is ~3.6GB, and Baserow specifically needs
its full 2g during first boot, not just at steady state.
…g it

Still timing out on Baserow after fixing the OOM (1g -> 2g) — but this time
the app log showed 28 consecutive 200s on /api/_health/ spanning the full
4.5 minutes the script was polling, while the script itself reported "not
ready yet" on every single attempt. Those 200s are Docker's own internal
HEALTHCHECK directive (still defined in docker-compose.yml, running on its
own schedule) — a completely separate code path from this script's own
`docker compose exec` calls, which apparently were failing for a reason the
script was actively discarding (>/dev/null 2>&1, exit code unchecked beyond
zero/nonzero).

No new fix yet — this commit only makes the next failure diagnosable instead
of guessing again from indirect evidence.
Root cause, finally confirmed from the debug instrumentation added last
commit: "sh: 1: wget: not found" on every single attempt. Baserow's own
CMD-array healthcheck (["CMD", "wget", ...], no shell involved) resolves
wget fine — whatever sets up Baserow's PATH for its actual entrypoint
process isn't present in a fresh `docker compose exec ... sh` session.

n8n and postgres both worked correctly through sh -c the whole time (n8n
had one transient "connection refused" that self-resolved) — this was
specific to Baserow. Fixed by execing wget directly for Baserow, matching
how the CMD-array form resolves it, while leaving postgres/n8n on sh -c
since they need it (env var expansion) and it already works for them.
Confirmed definitively: baserow/baserow:2.3.3 has no wget binary anywhere
in $PATH — OCI runtime itself reports "exec: wget: executable file not
found in \$PATH" trying to run it directly (no shell involved at all this
time). docker-compose.yml's own wget-based HEALTHCHECK for Baserow has
apparently never actually been able to run in this image; the 200s seen in
earlier debugging were Baserow's own internal startup watcher process
hitting its own endpoint, unrelated to Docker's HEALTHCHECK or this script.

Stopped trying to find something inside each app's container that can make
the HTTP call. Both current call sites already run with the dev compose
override active (host ports 5678/8080 mapped), so this now just curls those
directly from the caller's machine — sidesteps every container-image
assumption entirely. Postgres keeps `docker compose exec` + pg_isready,
which has worked correctly in every run so far and has no host-reachable
alternative (no port mapping, by design).
Was only there to validate the workflow inside this PR before merge, since
workflow_dispatch requires the file to already exist on the default branch.
Confirmed working (backup-restore-verify passed). Back to schedule +
workflow_dispatch only, as designed — it will run for real once merged to
main and can be triggered on-demand via gh workflow run after that.
@whybothercoding
whybothercoding merged commit 5f7c46a into main Aug 23, 2026
7 checks passed
@whybothercoding
whybothercoding deleted the feat/portfolio-technical-elevation branch August 23, 2026 17:05
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