Architecture decision records
Why this site is built the way it is
Every choice here gave something up. These records say what I chose, what I rejected, and what it costs me, so the next person (or me in a year) doesn't have to guess.
Host the portfolio on my homelab, not a hosting platform
- Context
- A static site could live on a free hosting platform in five minutes. But the site is also evidence: I want it to run on infrastructure I operate myself.
- Decision
- Serve the static build from my homelab, behind Cloudflare, deployed by my own pipeline.
+ Gains
- Real operations practice: deploys, TLS, headers, monitoring.
- Near-zero marginal cost on a host that is on anyway.
− Costs
- I own the uptime. A power or internet cut at home takes the site down.
- More moving parts than a hosting platform.
Considered instead
- Free static hosting (simplest, and the fallback if the lab is ever down for long).
- A small VPS (about US$5 a month).
Expose the site through Cloudflare Tunnel, not port forwarding
- Context
- The site needs to be public; the home network must not be.
- Decision
- cloudflared dials out to Cloudflare. No inbound ports are opened on the router, and the origin has no public IP.
+ Gains
- Nothing at home to scan or attack directly.
- TLS, caching and DDoS protection at the edge.
− Costs
- A dependency on Cloudflare for every request.
- The hop from cloudflared to Caddy is plain HTTP, acceptable only because it stays on localhost.
Considered instead
- Port forwarding with a dynamic-DNS name (rejected: exposes the home IP).
- A VPS as a reverse proxy over a VPN.
Deploy with rsync over Tailscale into immutable release folders
- Context
- GitHub Actions has to reach a machine that has no public IP.
- Decision
- The runner joins my tailnet as an ephemeral, tagged node, rsyncs the build into a new release folder, and switches a `current` symlink atomically.
+ Gains
- Rollback is one symlink switch.
- Visitors never see a half-copied release.
- No self-hosted runner to patch.
− Costs
- An ephemeral node joins the tailnet on every deploy, so the ACL has to be tight.
- Old releases need pruning.
Considered instead
- A self-hosted runner in the lab (more to maintain, and it runs untrusted CI code at home).
- Pull-based deploys from the host.
A static site with no backend
- Context
- Recruiters need to read a page, not log in or submit forms.
- Decision
- Astro builds plain HTML. Interactivity is client-side; contact goes through email.
+ Gains
- Almost no attack surface: no server code, database or cookies.
- Fast, cacheable, and cheap to run.
− Costs
- No contact form or analytics without adding a service.
- Live data (such as the status page) has to come from somewhere else.
Considered instead
- A small backend for a contact form (rejected: attack surface for little value).
A CSP generated at build time with script hashes
- Context
- The framework's built-in CSP blocked the site's inline styles (see PM-002).
- Decision
- A post-build script hashes every inline script into a CSP meta tag on every page. Styles allow inline; headers that a meta tag can't set live in Caddy.
+ Gains
- Injected scripts, inline handlers and eval are blocked.
- The policy always matches the exact build that ships.
− Costs
- A meta CSP can't set frame-ancestors, so that needs a server header.
- Inline styles are allowed.
Considered instead
- Nonces (need a server per request, which a static site does not have).
- No CSP (rejected).
Monitor the site from outside the homelab
- Context
- A monitor that runs on the same host as the site cannot report that host being down.
- Decision
- A GitHub Action checks the public site from outside and publishes the results to the status page.
+ Gains
- An independent vantage point, and free for a public repository.
- Nothing in the lab has to be exposed.
− Costs
- GitHub can delay scheduled runs, so the resolution is coarse.
- Checks run from GitHub's network, not from Indonesia.
Considered instead
- Uptime Kuma in the lab (useful for internal checks, blind to its own outage).
- A paid external monitor.