.agents/validate.sh is the commit gate: full E2E suite, container security-header check, and repo-hygiene checks must all pass before a phase is committed. AGENTS.md now documents the audit-driven conventions (M-1 innerHTML/terminal input, M-2 simulated curl/wget, M-4 security-header snippet, L-2 server_tokens) and the test-locked featured-projects grid. LLM-Generated: true
Developer Homepage
A static portfolio site (vanilla HTML/CSS/JS + a fake terminal easter egg) packaged as an nginx container and deployed with Podman Quadlet.
Build & Run with Podman
Build the image
podman build -t homepage:latest .
Run the container
The image runs build.sh itself and copies the hashed dist/ output into the
nginx html root, so the built site is already inside the image:
podman run --rm -p 0.0.0.0:8080:8080 homepage:latest
To iterate on src/ without rebuilding the image, build locally and bind-mount
the built output over the html root — mount dist/, never src/, because
index.html requests content-hashed filenames:
./build.sh
podman run --rm -p 0.0.0.0:8080:8080 -v ./dist:/usr/share/nginx/html:z homepage:latest
Visit http://localhost:8080 in your browser.
Serving & configuration
nginx.conf is copied into the image as the site config. It listens on
8080 over plain HTTP, serves the hashed build, serves index.html with
no-cache, and caches the hashed assets as immutable for a year.
The four hardening headers (X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, Content-Security-Policy) live in
nginx-security-headers.inc, which the Dockerfile copies to
/etc/nginx/snippets/security-headers.inc. nginx does not merge
add_header across configuration levels, so every location block includes
that snippet instead of inheriting from the server block — otherwise the
locations that set their own cache headers would silently drop the security
headers (audit finding M-4, Phase 03). server_tokens off; hides the nginx
version from the Server header (audit finding L-2). When adding a new
location, include the snippet there too and keep the security headers out of
nginx.conf itself.
TLS and HSTS are the job of the reverse proxy in front of the container
(reeseapps.com terminates at the Caddy edge). Do not add
Strict-Transport-Security or a listen 443 block to nginx.conf.
Verify the headers
./scripts/check-headers.sh
This is the integration test for the header fix: it builds the real image
with podman (or docker), runs it on the scratch address 127.0.0.1:18099, and
asserts all four security headers on each response class — /, /index.html
and the first hashed asset found in index.html. On success it prints one
OK: line per path and HEADER CHECK PASSED (exit 0). A missing header prints
FAIL: <header> missing on <path>; all three paths are checked before the
script prints HEADER CHECK FAILED and exits non-zero, so one run reports every
violation (once a header has failed somewhere, the later OK: lines are
suppressed). No local ./build.sh is needed — the image builds itself. The
container is removed by the script; .agents/validate.sh removes the leftover
homepage-headercheck-* image tag. Run it after any change to
nginx.conf, nginx-security-headers.inc or the Dockerfile.
Deploy with Quadlet
The homepage.container Quadlet unit is not tracked in this repository — it
lives with the deployment host's config. Install it under
~/.config/containers/systemd/ (copy it from wherever the host's unit files are
kept):
mkdir -p ~/.config/containers/systemd
cp <deploy-config>/homepage.container ~/.config/containers/systemd/
It must publish host port 8080 and use the image CI publishes:
gitea.reeseapps.com/services/homepage (main on every push to main, plus
one tag per release).
Quadlet is a systemd generator: it turns homepage.container into a
homepage.service unit when systemd regenerates units, so manage it under the
.service name:
systemctl --user daemon-reload
systemctl --user start homepage.service
systemctl --user status homepage.service
Auto-start on boot comes from the unit's [Install] section (e.g.
WantedBy=default.target), which the generator applies while generating — the
Quadlet services are transient, so systemctl enable has no effect on them;
re-run systemctl --user daemon-reload after editing the unit. For a user-mode
unit that must come up without a login session, enable lingering once:
loginctl enable-linger $USER.
Development
Source files live in src/: index.html, style.css, script.js and the
flat terminal modules (terminal.js, terminal-commands.js,
terminal-vim.js, terminal-achievements.js). Keep every .js file flat in
src/ — build.sh only hashes root-level files.
npm install # installs the pinned @playwright/test dev dependency
./build.sh # src/ -> dist/ (content-hashed asset names)
npm test # Playwright E2E suite against dist/ on 127.0.0.1:8123
npm run test:headed # same suite, visible browser
playwright.config.js points baseURL at http://127.0.0.1:8123 and starts
the static server with python3 -m http.server 8123 --bind 0.0.0.0 --directory dist — the port is bound on all interfaces while the suite runs, so use it on a
trusted network.
The fake terminal performs no real network I/O. curl and wget render
deterministic simulated output built by simulatedCurlOutput() /
simulatedWgetOutput() in src/terminal-commands.js, which echo the requested
URL and say they are simulated (audit finding M-2, Phase 02) — add new command
output the same canned way. The E2E suite guards this: one test records every
request outside the test origin while running curl and wget, so a real
network call reintroduced into src/ fails the suite. That test derives the
allowed origin from baseURL in playwright.config.js, so changing the port
there needs no test edit.
The suite (tests/e2e/01-page.spec.js … 05-menu.spec.js) covers page
structure — sections, nav targets, the experience timeline and the six featured
project cards in their fixed order — the fake terminal's commands, history,
tab-completion and pranks, the vim simulator, the achievement unlocks and their
localStorage persistence, the mobile menu, and the fact that no request ever
leaves the test origin. Content changes to src/index.html (e.g. the featured
projects) are asserted there, so update the matching test with the content.
Always build before testing: the suite exercises the built artifact in dist/,
never src/. npm test starts (or reuses) its own static server, so no manual
server is needed. Rebuild the image (podman build) after changing src/.
Requires Node.js 20+ (the @playwright/test engine floor) and the Chromium
revision cached for the pinned Playwright version — run
npx playwright install chromium if it is missing, and see AGENTS.md before
bumping the pin.
The validation gate the phased pipeline runs per task is .agents/validate.sh
(full E2E suite, the container security-header check from
scripts/check-headers.sh, plus repo-hygiene checks): bash .agents/validate.sh. The header check is skipped with a loud WARN: line on a
host with no container runtime; everything else still runs.