Homelab-infra session anchored here (Traefik ACME cert renewal); no site code changed, still v0.2.0. Records the DNS-01 to HTTP-01 revert and the cert topology (Cloudflare edge wildcard vs Traefik origin/duckdns certs), and archives the shipped portfolio-refresh notes to a dated file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNEDPqEwRrr1xEuFxarWCF |
||
|---|---|---|
| design_handoff | ||
| docs | ||
| e2e | ||
| public | ||
| src | ||
| tests/components | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| astro.config.mjs | ||
| CLAUDE.md | ||
| DESIGN.md | ||
| docker-compose.yml | ||
| GEMINI.md | ||
| nginx.conf | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.ts | ||
| README.md | ||
| TODOS.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
alangaudet.dev
Personal portfolio site for Alan Gaudet. Astro 5.x static site with React 19 islands, served by Nginx Alpine, fronted by Traefik v3 with Let's Encrypt HTTPS, Cloudflare DNS proxy, and off-box Uptime Kuma monitoring (on the RTX 3080).
Live: https://alangaudet.dev Status: https://status.alangaudet.dev
For design rationale and the full architecture diagram, read DESIGN.md.
For the deferred backlog, read TODOS.md. For the change history of recent
sessions, read docs/SESSION_NOTES.md and the dated archives next to it.
Quick Reference
The four commands you'll run most:
cd /mnt/TempNVME/projects/alangaudet-dev
# Local dev with HMR
npm run dev # http://localhost:4321
# Build + deploy to live (the only deploy step)
npm run build && docker compose restart nginx
# Run tests before deploying anything risky
npm test # Vitest unit tests
npm run test:e2e # Playwright E2E
# Check what's actually live
docker compose ps
The site has no CI/CD pipeline yet (deferred in TODOS.md). Deploys are
manual: run npm run build to update dist/, then docker compose restart nginx to make Nginx pick up the new files. Nginx bind-mounts ./dist into
the container as read-only, so a fresh build is visible after a restart.
Project Layout
/mnt/TempNVME/projects/alangaudet-dev/
README.md (this file)
DESIGN.md (architecture + design source-of-truth)
TODOS.md (deferred backlog)
CLAUDE.md (scope rules for AI agents working here)
docker-compose.yml (nginx + ddns services)
nginx.conf (security headers + cache rules)
.env (CF_API_TOKEN, chmod 600, gitignored)
.gitignore
package.json
astro.config.mjs
tsconfig.json
playwright.config.ts
vitest.config.ts
docs/
SESSION_NOTES.md (current handoff for next session)
SESSION_NOTES_*.md (dated archives of prior sessions)
superpowers/ (specs/plans for AI agents)
src/
layouts/Layout.astro (global head, fonts, nav, footer)
pages/ (route files)
index.astro (home)
projects/index.astro (project list)
projects/[id].astro (case study template)
resume.astro
contact.astro
support.astro (crypto donations w/ QR codes)
404.astro
components/ (Astro static + React islands)
diagrams/ (case study architecture SVGs)
content/projects.ts (project data; CATEGORICAL stats only,
no precise counts. See CLAUDE.md.)
styles/
tokens.css (canonical type scale + colour tokens)
project-card.css
print.css (resume page)
assets/fonts/ (Inter + Liberation Mono, WOFF2)
test/ (Vitest setup)
tests/
components/ (Vitest unit tests for React islands)
e2e/ (Playwright E2E specs)
public/qr/ (BTC + Doge donation QR SVGs)
dist/ (Astro build output, bind-mounted to Nginx)
design_handoff/ (historical design prototype, reference only)
dist/ is the only directory Nginx serves. Everything outside dist/ is
source. The path /mnt/TempNVME/projects/alangaudet-dev/ is also reachable
via the symlink ~/docker-containers/alangaudet-dev/ if you prefer the
docker-services tree.
Architecture in One Glance
Browser
|
v
Cloudflare Edge (DNS proxy, CDN, DDoS, "orange cloud" enabled)
|
v (A record alangaudet.dev kept current by `ddns` container)
Home router (port-forwards 80/443 to the host)
|
v
Traefik v3 (runs in a SEPARATE compose at ~/docker-containers/alanbot/.
Joins the `alanbot_default` external network. Owns Let's
Encrypt HTTP challenge. NOT managed from this repo.)
|
|---> nginx container (this compose) alangaudet.dev
|---> Uptime Kuma on RTX 3080 (file provider) status.alangaudet.dev
|---> Seerr, Tautulli, Wizarr, ... (other docker-containers/)
Two containers are defined in this repo's docker-compose.yml:
- nginx — serves
dist/on port 80 inside the network. Traefik labels routealangaudet.devto it over HTTPS. - ddns —
favonia/cloudflare-ddns:1, hardened (read_only, no caps, no-new-privileges). Updates the A record when the home IP changes. ReadsCLOUDFLARE_API_TOKENfrom.env.PROXIED=truekeeps the Cloudflare orange cloud on.status.alangaudet.devis no longer served from this compose. Uptime Kuma moved to the always-on RTX 3080 (10.10.10.206:3001) on 2026-06-19 so monitoring doesn't share fate with the box it watches. Traefik here proxies the hostname to it via the alanbot file provider (~/docker-containers/alanbot/dynamic/rtx3080.yml). Manage Kuma on the 3080.
The alanbot_default network is external: true in the compose file, which
means it's owned by the separate alanbot compose. If that network goes
away, none of these containers can route. See "Site won't load" below.
Deploy Workflow
The site has no CI. Deploys are manual and idempotent.
Standard content change
cd /mnt/TempNVME/projects/alangaudet-dev
# 1. Make your edits in src/
# 2. Build (compiles TS, bundles React islands, writes to dist/)
npm run build
# 3. Restart nginx to pick up the new dist/
docker compose restart nginx
# 4. Verify
curl -sI https://alangaudet.dev | head -5
npm run build is the only step that touches dist/. The Nginx container
restart is what makes the change visible (Nginx caches metadata at startup;
a SIGHUP would also work, but docker compose restart is simpler and
honest).
Total deploy time: typically 3-5 seconds for the build, 1-2 seconds for the restart.
Pre-deploy checks (recommended for non-trivial changes)
# Type check + bundle dry run
npm run build
# Unit tests for React islands
npm test
# E2E across all routes (slower, ~30s)
npm run test:e2e
The Vitest suite covers the four React islands (TrajectoryBand, ProjectCard, CaseStudyTOC, EmailCopyButton). Playwright covers all 7 routes on desktop + mobile (Pixel 7 profile, not iPhone, since only Chromium is installed locally).
Local preview without deploying
npm run dev # http://localhost:4321 with HMR
# OR
npm run build && npm run preview # static preview of dist/
npm run dev is the right tool for iterating. Don't use it for production
parity checks — Nginx + dist/ is what visitors see.
Rolling back a bad deploy
There is no automatic rollback. Two options:
# Option A: revert the source change, rebuild, restart
git revert HEAD
npm run build && docker compose restart nginx
# Option B: rebuild from a known-good commit
git checkout <good-sha> -- src/
npm run build && docker compose restart nginx
git checkout HEAD -- src/ # restore your working state
Option A is safer because it leaves a forward audit trail in git. Option B is faster if you need to mitigate first and investigate later.
Maintenance
Daily
Nothing required. Containers are set to restart: always, so a host reboot
brings them back automatically once the Docker daemon is up. They were
previously restart: unless-stopped, which does NOT survive a full reboot
(only a daemon restart) — that left the site down for ~17h on 2026-06-28.
See docs/SESSION_NOTES.md.
Weekly
Open https://status.alangaudet.dev and confirm the HTTPS monitor is green and the cert expiry monitor still shows >14 days. If it shows <14 days, the renewal probably failed; see "SSL cert problems" below.
Monthly
# Container image freshness
docker compose pull # pulls newer tags for nginx:alpine, ddns:1
docker compose up -d # recreates only the changed images
# Astro/React/dep freshness (review before applying)
npm outdated # shows minor/major drift
For dep updates, prefer npm update for patch/minor and stage majors as
their own commits with a build + test run. React 19 + Astro 5 is the
current floor; downgrading either is not supported.
Quarterly
Review TODOS.md. Items there are deliberately deferred, but the
"Cloudflare Tunnel migration" and "External uptime monitoring (uptimerobot
backup)" are good candidates for the first quarterly slot you have.
Logs and Observability
# Live tail of both containers
docker compose logs -f
# Just nginx (most useful for HTTP issues)
docker compose logs -f nginx
# Last 200 lines of ddns (for "is the IP being updated" questions)
docker compose logs --tail 200 ddns
# Uptime Kuma (status page) now runs on the RTX 3080, not here:
# ssh 10.10.10.206 'docker logs --tail 100 uptime-kuma'
# Container health, ports, restart counts
docker compose ps
docker stats --no-stream # CPU + memory snapshot
Telegram alerts route through @jarvisthethirteenth_bot (configured inside
Uptime Kuma's web UI on the RTX 3080). If the HTTPS monitor fails for
60s with 3 retries, you'll get a Telegram message in Alan's DMs.
The Cloudflare DDNS container will print this warning on every cycle:
WARN: API token appears invalid
This is benign. The favonia container probes a general API endpoint
that the scoped Zone:DNS:Edit token can't read; the actual DNS update
operations succeed. Confirm with dig alangaudet.dev returning the home
IP. Documented in the v0.1.0 launch session notes.
Troubleshooting
Each section starts with the symptom, then the first check, then root causes ordered most-to-least likely.
Site won't load (browser shows nothing or "can't connect")
First check:
docker compose ps # both containers Up?
dig +short alangaudet.dev # DNS resolves to the home IP?
curl -vI https://alangaudet.dev 2>&1 | head -20
Root causes (most to least likely):
- Nginx container not running.
docker compose psshowsExitor nothing. Fix:docker compose up -d nginx. If it crashes immediately, checkdocker compose logs nginxfor an Nginx config syntax error (usually a typo innginx.conf). alanbot_defaultnetwork gone. Compose error like "network alanbot_default declared as external, but could not be found". This happens if the alanbot compose was torn down. Fix: bringalanbotback up first (cd ~/docker-containers/alanbot && docker compose up -d), thendocker compose up -dhere.- Traefik routing broken. This compose's labels look correct
(
Host(\alangaudet.dev`),tls.certresolver=letsencrypt), but Traefik itself runs from a separate compose. Check Traefik's dashboard or logs (cd ~/docker-containers/alanbot && docker compose logs -f traefik`) for routing errors. - Home IP changed and DDNS lagged. Verify with
dig +short alangaudet.devvscurl -s ifconfig.me. If they differ, check ddns logs:docker compose logs --tail 50 ddns. The container updates on each cycle (5 min default). - Router port forward broken. Test from outside the network (phone on cellular). If it works inside-LAN but not outside, the router's 80/443 forward is gone or pointing to the wrong host IP.
- Cloudflare proxy issue. Cloudflare dashboard → DNS → confirm
alangaudet.devA record is proxied (orange cloud). Try toggling to DNS-only (grey cloud) for a minute to bypass; if it works grey-only, Cloudflare's edge is the issue.
Site loads but shows stale content
# What's the timestamp on the deployed HTML?
docker exec alangaudet-dev-nginx-1 stat -c '%y %n' /usr/share/nginx/html/index.html
# Compare to your local dist/
stat -c '%y %n' dist/index.html
If the two timestamps match but the browser shows old content, it's
browser cache. Force a hard reload (Ctrl+Shift+R / Cmd+Shift+R). Note
that nginx.conf sets Cache-Control: no-cache on *.html and
max-age=31536000, immutable on /_astro/*, so HTML should never stick
in cache, but compiled JS/CSS bundles do (by design — they have content
hashes in their filenames).
If the timestamps differ, you forgot one of these:
npm run build # writes new dist/
docker compose restart nginx # makes Nginx see it
SSL cert problems
Symptom: Browser shows "Your connection is not private" or curl returns "SSL certificate problem".
First check:
echo | openssl s_client -connect alangaudet.dev:443 -servername alangaudet.dev 2>/dev/null \
| openssl x509 -noout -dates -issuer
Root causes:
- Traefik couldn't complete the HTTP challenge. Let's Encrypt needs
port 80 reachable from the internet to issue/renew. Check the router
port forward and
docker compose logs traefikin the alanbot compose. Rate limits: 5 failures per hour, 50 issuances per week per registered domain. - Cloudflare SSL/TLS mode is "Strict" but the origin doesn't have a valid cert. This is the chicken-and-egg case during initial issuance. Set Cloudflare SSL/TLS to "Full" temporarily (not Strict, not Flexible) while the cert provisions, then back to Full once it's valid.
- Cert expired (>90 days, didn't renew). Should never happen because
Traefik renews at 30-day mark, but verify with
openssl s_client. If expired, the renewal failed silently — fix the cause (port 80, rate limit, etc.) and force a renewal by deleting Traefik'sacme.jsonentry for this domain.
Cloudflare DDNS isn't updating
First check:
docker compose logs --tail 100 ddns | grep -iE "updated|error|fail"
dig +short alangaudet.dev
curl -s ifconfig.me # actual home IP
Root causes:
.envmissing or has a stale token.cat .envshould showCLOUDFLARE_API_TOKEN=.... If empty or wrong, regenerate the token in Cloudflare → My Profile → API Tokens (scope: Zone:DNS:Edit foralangaudet.devonly) and update.env. Thendocker compose restart ddns.- Token revoked or expired. Cloudflare → API Tokens → check the token's status. Re-create if needed (don't reuse, scope it freshly).
- The "token appears invalid" warning is NOT this case. That warning
prints on every cycle and is benign; the actual update operations work.
Look for an
updatedlog line as confirmation.
Status page (status.alangaudet.dev) won't load
Uptime Kuma runs on the RTX 3080 (10.10.10.206:3001), proxied here by
Traefik's file provider — so checks span two boxes.
First check:
dig +short status.alangaudet.dev
ssh 10.10.10.206 'docker ps --filter name=uptime-kuma' # Kuma up on the 3080?
curl -sI https://status.alangaudet.dev | head -3 # Traefik -> 3080?
Root causes:
- Wildcard CNAME
*missing in Cloudflare DNS. Without the wildcard,status.alangaudet.devresolves NXDOMAIN. Cloudflare → DNS → addCNAME * alangaudet.dev(proxied). - Kuma not running on the 3080. Bring it up over SSH:
ssh 10.10.10.206 'docker compose -f ~/docker-containers/kuma/docker-compose.yml up -d'. - File-provider route missing or returning 502. The route lives in
~/docker-containers/alanbot/dynamic/rtx3080.yml(status-alangaudetrouter → servicekuma→http://10.10.10.206:3001). A 502 means Traefik can't reach the 3080 — check the direct link and that Kuma is up.
Build fails (npm run build errors out)
First check: read the error. Astro errors usually point to a specific file:line. The common shapes:
- TypeScript type error. Fix the type. Common: missing
interface Propsdeclaration on an Astro component, or stale React 18 types trying to call into React 19. Runnpx tsc --noEmitfor a clean type check. - Missing dependency. "Cannot find module X". Fix:
npm installto sync. Ifnpm installfails on a peer-dep conflict, deletenode_modules/andpackage-lock.jsonand reinstall fresh. - Astro config error (rarer).
astro.config.mjssyntax issue or a removed integration. Check the Astro 5 changelog if you recently upgraded. - Out-of-memory during bundling. On a constrained box,
NODE_OPTIONS=--max-old-space-size=4096 npm run buildis the workaround. We have not hit this here yet.
Tests fail
Vitest: npm test
The Vitest tests live in tests/components/. Common failures:
- JSDOM mismatch. Vitest runs against jsdom; if a React island uses
IntersectionObserverormatchMedia, the test must mock them. Seesrc/test/setup.tsfor the canonical mock setup. - React 19 strict-mode double render. If a test depends on render count, switch to behavioural assertions (text appearance) instead of counting.
Playwright: npm run test:e2e
Common failures:
- Chromium not installed. Run
npx playwright install chromium. Don't install other browsers — the test config only targets Chromium (and a Chromium-based Pixel 7 profile for mobile). iPhone profiles would need webkit, which isn't installed. - Site is down. Playwright tests run against
npm run dev(HMR server), not the live site. Ifdevdoesn't start cleanly, fix the build first. - Print stylesheet test flake. The
/resumeprint test triggerswindow.print(); in headless mode the dialog never appears, so the test asserts state changes around the call, not the print preview itself.
Disk pressure (docker compose restart slow or OOM)
docker system df # how much Docker is using
df -h /mnt/TempNVME # the disk this project lives on
This project's footprint is modest (~50 MB for dist/ + a few hundred MB
for node_modules/). If df shows the partition >90% full, the cause is
elsewhere. Common offenders on this host:
- Old Docker containers/images.
docker system prune(interactive) to clean up stopped containers and dangling images. Don't use-aunless you're sure no other compose project needs the cached images. - Plex transcode cache if this is the plex-server box.
- Snapper/btrfs snapshots on the root disk. See your global
feedback_root_drive_full_triage.mdfor the order of triage.
Known Quirks
These bit somebody once and will bite again. Documented so you don't re-debug from scratch:
- Nginx security headers must be repeated in
*.htmllocation blocks.add_headerin alocationblock overrides ALL server-leveladd_headerdirectives. The currentnginx.confrepeatsX-Content-Type-Options,Referrer-Policy, andPermissions-Policyinside the*.htmlblock for this reason. If you add a new location block, repeat the headers there too. (Fix from commitf84b860.) - DDNS "token appears invalid" warning is benign. The favonia
container validates against a general API endpoint the scoped token
can't read. Actual DNS update operations succeed. Confirm by checking
for
updatedlog lines and a correctdigresult. - Wildcard CNAME
*is required forstatus.alangaudet.dev. Created manually via Cloudflare API during initial deploy. If you tear down DNS and recreate, remember to recreateCNAME * alangaudet.dev(proxied). - Playwright mobile profile is Pixel 7, not iPhone. Chromium-only environment; iPhone profiles need webkit which isn't installed.
- Liberation Mono TTF in
design_handoff/is zero-filled. Build uses the system/usr/share/fonts/liberation/source, converted to WOFF2. Don't try to use the design_handoff TTF as a font source. - Stat phrasing on project cards is intentionally categorical, not
numeric. The CLAUDE.md in this repo enforces this — precise test
counts and LoC numbers go stale at commit cadence and invite expensive
verification (one agent OOM-froze the box trying to verify a count).
Edit
src/content/projects.tswith phrases, not numbers. tokens.cssis the canonical type scale. The homepage section h2s override--ag-text-h2inline (32px) instead of changing the token (still 24px). This is deliberate; case study prose<h2>s elsewhere stay 24px. SeeDESIGN.md→ "Typography Hierarchy".- Remote is Codeberg (public).
origin→https://codeberg.org/alan090/alangaudet-dev(wired up 2026-06-28). Daily push is justgit push; auth uses the stored token in~/.git-credentials. See "Pushing to a Remote" below for how it was created.
Backup and Recovery
This repo has no automated backup. The recovery posture is:
- Source code: rebuild from any git commit. If the local clone is
lost, re-clone from Codeberg (
origin, see below) or restore from filesystem backup. - Container state:
nginxis stateless.ddnsis stateless. No stateful container remains in this compose — Uptime Kuma (the only one with state) runs on the RTX 3080 now, and its volume on this box was removed after the migration. Back Kuma up on the 3080 (below). .env: the Cloudflare API token. Reproducible from the source-of- truth~/.config/cloudflare/credentials(chmod 600). If lost, copy from there andchmod 600 .env.
Uptime Kuma's data now lives on the RTX 3080 (its
alangaudet-dev_uptime-kuma-data volume on this box was deleted after the
2026-06-19 migration). Back it up there:
ssh 10.10.10.206
KUMA_VOL=$(docker volume ls -q | grep kuma) # confirm the 3080 volume name
docker run --rm -v "$KUMA_VOL":/source:ro -v "$PWD":/backup alpine \
tar czf /backup/uptime-kuma-$(date +%F).tar.gz -C /source .
Pushing to a Remote (how it was set up)
origin is configured to Codeberg (public), wired up 2026-06-28 with the
commands below — kept for reference and for re-creating the remote if needed:
# Codeberg (preferred per memory: GitHub keeps flagging Alan's repos)
curl -s -X POST "https://codeberg.org/api/v1/user/repos" \
-H "Content-Type: application/json" \
-H "Authorization: token $(grep codeberg ~/.git-credentials | sed 's|.*://alan090:\(.*\)@.*|\1|')" \
-d '{"name":"alangaudet-dev","description":"Personal portfolio site","private":false}'
git remote add origin https://codeberg.org/alan090/alangaudet-dev.git
git push -u origin main
# Optional: GitHub mirror
gh repo create alan09086/alangaudet-dev --public --description "Personal portfolio site"
git remote add github https://github.com/alan09086/alangaudet-dev.git
git push -u github main
The day-to-day push is now just git push. No GitHub mirror was set up —
Codeberg only (GitHub keeps flagging Alan's repos); the optional gh/github
lines above are how you'd add one.
.env is gitignored. Verify with git ls-files | grep -i env returning
nothing before the first push.
Emergency Procedures
Whole site is down, you have 5 minutes to get it back
# 1. Bring everything back to a known state
cd /mnt/TempNVME/projects/alangaudet-dev
docker compose down
docker compose up -d
docker compose ps # all three Up?
# 2. If alanbot_default network is missing
cd ~/docker-containers/alanbot
docker compose up -d
cd /mnt/TempNVME/projects/alangaudet-dev
docker compose up -d
# 3. If still broken, last-resort: serve dist/ directly on a known port
docker run --rm -d -p 8080:80 -v "$PWD/dist":/usr/share/nginx/html:ro \
--name alangaudet-emergency nginx:alpine
# then point an upstream LB / DNS at the host:8080 until you can debug
You pushed a broken build and visitors are seeing it
# Roll forward by reverting the last commit and rebuilding
git revert HEAD --no-edit
npm run build && docker compose restart nginx
# Verify
curl -sI https://alangaudet.dev | head -3
You committed .env by accident
The token is now in git history. Treat the token as compromised:
- Revoke it immediately in Cloudflare → API Tokens.
- Generate a new scoped token (Zone:DNS:Edit, alangaudet.dev only).
- Write the new token into
.env,chmod 600 .env. docker compose restart ddns.- Rewrite history with
git filter-repo(or BFG) to scrub.env. If you've already pushed to a remote, force-push and consider the leak as already-public.
Versioning
Currently v0.1.0 (initial release). The package.json version field is
the source of truth. There's no CHANGELOG.md yet; commit messages serve
that purpose until the site sees enough churn to warrant one.
When you ship a new version:
# Bump package.json version manually
# Then commit:
git commit -am "release v0.X.Y: <one-line summary>"
git tag v0.X.Y
git push origin main --tags
Major version bump (1.0.0) is reserved for the next significant restructure (e.g. new sections, blog/writing surface, design system v2).
Where to Get More Detail
- Architecture, design tokens, scope decisions:
DESIGN.md - Deferred backlog:
TODOS.md - What changed in the last session and why:
docs/SESSION_NOTES.md - Earlier sessions (launch, planning):
docs/SESSION_NOTES_*.md - Scope rules for AI agents (don't compile sibling Rust projects to
verify stats, don't reintroduce precise counts, etc.):
CLAUDE.md - Astro docs: https://docs.astro.build
- Traefik docs: https://doc.traefik.io/traefik/
- favonia/cloudflare-ddns: https://github.com/favonia/cloudflare-ddns
- Uptime Kuma: https://github.com/louislam/uptime-kuma