Portfolio infrastructure — public-root isolation & deployment contract
Problem
The original deployment served the whole repository root as the nginx webroot.
An audit against the live site confirmed that docker-compose.yml,
Dockerfile, nginx.conf, and DEPLOYMENT.md
were each reachable at their own public URLs. The same audit found unknown paths
returning HTTP 200, because the server fell back to /index.html on a
multi-page site — leaving the custom 404 page unreachable.
Constraints
The site is static HTML with no runtime dependency, so the fix could not add a server-side routing layer or a framework. It had to hold as a build-time contract: whatever is not in the built image cannot be fetched, by construction, rather than by a list of paths someone has to remember to block.
Approach
Served files were moved into a dedicated public/ directory, and the
image was rebuilt to COPY only that directory plus the nginx config.
The bind mount was removed, which removes the entire class of infrastructure-file
leaks rather than blocking individual paths one by one. Routing changed to
try_files $uri $uri/ =404 with a real error_page 404.
Cache headers were split by content type — HTML, CSS, and JS revalidate, while content-addressed images are served immutable. Images were re-encoded from archived originals to WebP at their actual rendered dimensions.
Verification
A static analysis gate (tools/check_site.mjs, Node.js standard
library only) checks every page for exactly one <h1>,
duplicate attributes, inline style or on* handlers,
unresolved internal links, missing or orphaned assets, page metadata, unverified
claims, and the served-tree boundary. It is deterministic — two runs produce
byte-identical output — and returns a non-zero exit code on failure, so it can
fail a build instead of only reporting.
Deployment verification is HTTP-level and documented in the repository: site root, an unknown path, a known infrastructure file, the 404 page, and cache headers for CSS and for an image. Each is a command with an expected status code.
Lessons
Treating the container image as the security boundary is more durable than filtering paths. And a check that fails on the state it was written against is what makes it trustworthy — a gate that has never been red has not been shown to detect anything.