From bfa8cfd3578b697119e1f2142ef22afc6d059aa2 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 4 Jul 2026 11:23:09 +0200 Subject: [PATCH] feat: homelab case study from live infra; Traefik-based deploy - rewrote homelab (content + topology diagram + stack) from a live inspection of the host: Ubuntu 24.04, ~30 Docker services behind Traefik (Cloudflare-fronted, TLS, HTTP/3), Authentik SSO forward-auth, CrowdSec, Pi-hole, self-hosted Gitea + CI runner, socket-proxy, per-app network isolation; WordPress framed as being decommissioned (not future arch) - deploy: docker-compose now uses Traefik labels + traefik_proxy network (was assumed nginx edge); .env.example adds SITE_HOST/TRAEFIK_ENTRYPOINT; colophon + ARCHITECTURE/DOCKER/ DEPLOYMENT specs corrected nginx-edge -> Traefik (site container still serves via nginx) - PROJECT_STATUS: pre-launch checklist updated; infra section added Co-Authored-By: Claude Fable 5 --- PROJECT_STATUS.md | 30 ++-- deploy/.env.example | 6 +- deploy/docker-compose.yml | 31 ++++- docs/spec/ARCHITECTURE.md | 2 +- docs/spec/DEPLOYMENT_SPEC.md | 2 +- docs/spec/DOCKER_SPEC.md | 2 +- site/src/components/pages/ColophonPage.astro | 4 +- site/src/data/projects/homelab.ts | 136 +++++++++++-------- 8 files changed, 129 insertions(+), 84 deletions(-) diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 12aeaf2..5e64858 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -59,19 +59,27 @@ 6. **Theme-init is an external script** (`/theme-init.js`) so the CSP can use `script-src 'self'` with no inline hash. -## Pre-launch checklist (carried from Phase 1/2 — owner action) +## Pre-launch checklist (owner action) -- [ ] **Replace the `/Linkedin` redirect target** in `site/nginx.conf` with the real LinkedIn - profile URL before cutover (currently a placeholder slug). -- [ ] Screenshot capture pass on **seeded demo data** for JobTrack + InboxIntel; drop real - captures into the `placeholder:` media refs (`src/data/projects/*`). -- [ ] Drop the real **outdoor headshot** into the portrait ref (replaces `placeholder:portrait`). -- [ ] **ATS-safe CV rework** (EN/NO) — replace the PDFs at `site/public/cv/` (same URLs). -- [ ] **Native-Norwegian review** of all NO content (launch gate). +- [x] ~~`/Linkedin` redirect target~~ — set to `https://www.linkedin.com/in/connor-babbington`. +- [x] ~~JobTrack seed-data screenshots~~ — real captures integrated (`/assets/projects/jobtrack/`). +- [x] ~~Real headshot~~ — integrated (`/assets/portrait.webp`, hero + about). +- [x] ~~ATS-safe CV rework (EN/NO)~~ — done; generator in `tools/cv/`. +- [ ] **InboxIntel screenshots** — still placeholders (no captures provided; it is "in development"). +- [ ] **Native-Norwegian review** of all NO content, incl. the NO CV (launch gate). - [ ] **Legacy WordPress URL inventory** → confirm the 301/410 map in `nginx.conf`. - [ ] Configure CI secrets (`REGISTRY_USER`, `REGISTRY_TOKEN`) + the relay `.env` on the server. +- [ ] Confirm the Traefik entrypoint / cert-resolver names in `deploy/.env.example` match the host. -## Open questions / blocked-on-user +## Infrastructure (verified live, July 2026) -- Are the JobTrack / InboxIntel repos public? If so, add `repo` links in `src/data/projects/*` - (currently omitted to avoid dead links). +Deploy target is the `mediaserver` host (Ubuntu 24.04): ~30 Docker services behind **Traefik** +(Cloudflare-fronted, TLS, HTTP/3), with **Authentik** SSO, **CrowdSec**, **Pi-hole**, and +self-hosted **Gitea + Actions runner** (the CI/CD target for this site). The homelab case study +and `deploy/` now reflect this (Traefik labels, `traefik_proxy` network). WordPress still runs +but is being decommissioned as this site replaces it — not documented as future architecture. + +## Resolved + +- JobTrack / InboxIntel repos are **private** → repo links intentionally omitted; case studies + describe the architecture without linking code. diff --git a/deploy/.env.example b/deploy/.env.example index 8342322..a07c79f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -14,5 +14,7 @@ RELAY_WINDOW_SECONDS=600 # --- Site build --- PUBLIC_SITE_URL=https://cesnimda.co.uk -# --- Infra --- -PROXY_NETWORK=web # name of the existing external reverse-proxy docker network +# --- Infra (Traefik host reverse proxy) --- +PROXY_NETWORK=traefik_proxy # existing external Traefik docker network +SITE_HOST=cesnimda.co.uk # Host rule for the Traefik routers +TRAEFIK_ENTRYPOINT=websecure-external # Traefik entrypoint name on the host diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 7d9043b..84ff6d3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,6 +1,8 @@ -# Production stack (DOCKER_SPEC §2). Publishes no host ports — the existing host -# reverse proxy routes cesnimda.co.uk -> site:8080 and /api/contact -> relay:8081 -# over the shared external proxy network. +# Production stack (DOCKER_SPEC §2). Publishes no host ports — Traefik (the host's +# existing reverse proxy) discovers these containers on the shared traefik_proxy +# network via the labels below and routes cesnimda.co.uk to the site, and +# cesnimda.co.uk/api/contact to the relay. Entrypoint / cert-resolver names match +# the host Traefik convention; override via the .env values if they differ. services: site: @@ -14,7 +16,14 @@ services: - /tmp - /var/cache/nginx - /var/run - networks: [web] + networks: [proxy] + labels: + - traefik.enable=true + - traefik.docker.network=${PROXY_NETWORK:-traefik_proxy} + - traefik.http.routers.resumesite.rule=Host(`${SITE_HOST:-cesnimda.co.uk}`) + - traefik.http.routers.resumesite.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure-external} + - traefik.http.routers.resumesite.tls=true + - traefik.http.services.resumesite.loadbalancer.server.port=8080 logging: driver: json-file options: { max-size: '10m', max-file: '3' } @@ -36,12 +45,20 @@ services: - Relay__AllowedOrigin=${RELAY_ALLOWED_ORIGIN:-https://cesnimda.co.uk} - Relay__RateLimitPerWindow=${RELAY_RATE_LIMIT:-5} - Relay__WindowSeconds=${RELAY_WINDOW_SECONDS:-600} - networks: [web] + networks: [proxy] + labels: + - traefik.enable=true + - traefik.docker.network=${PROXY_NETWORK:-traefik_proxy} + # More specific rule than the site router, so /api/contact wins. + - traefik.http.routers.resumerelay.rule=Host(`${SITE_HOST:-cesnimda.co.uk}`) && PathPrefix(`/api/contact`) + - traefik.http.routers.resumerelay.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure-external} + - traefik.http.routers.resumerelay.tls=true + - traefik.http.services.resumerelay.loadbalancer.server.port=8081 logging: driver: json-file options: { max-size: '10m', max-file: '3' } networks: - web: + proxy: external: true - name: ${PROXY_NETWORK:-web} + name: ${PROXY_NETWORK:-traefik_proxy} diff --git a/docs/spec/ARCHITECTURE.md b/docs/spec/ARCHITECTURE.md index 829b401..f73fa4b 100644 --- a/docs/spec/ARCHITECTURE.md +++ b/docs/spec/ARCHITECTURE.md @@ -7,7 +7,7 @@ System architecture for the bilingual portfolio. Companion detail specs: ROUTING ``` ┌──────────────────────────── Connor's server ───────────────────────────┐ - Browser ── HTTPS ──► │ nginx (existing reverse proxy, TLS) │ + Browser ── HTTPS ──► │ Traefik (existing host reverse proxy, TLS; Cloudflare-fronted) │ │ ├── cesnimda.co.uk/ → [site] nginx container (static) │ │ ├── cesnimda.co.uk/api/contact → [contact-relay] .NET 9 container │ │ └── git.cesnimda.uk → Gitea (existing) │ diff --git a/docs/spec/DEPLOYMENT_SPEC.md b/docs/spec/DEPLOYMENT_SPEC.md index 3d1de13..d5bd647 100644 --- a/docs/spec/DEPLOYMENT_SPEC.md +++ b/docs/spec/DEPLOYMENT_SPEC.md @@ -33,7 +33,7 @@ push / PR to main 1. Pre-launch: staging fully verified (both locales, CV downloads, form → real mailbox, redirects incl. `/Linkedin`, 410 map, OG previews via LinkedIn/Slack debuggers, Search Console fetch). 2. URL inventory + redirect/410 map finalised (SEO_SPEC §6). -3. Cutover = repoint host nginx server-block from WP to the `site` container (single reload; WP left running dark for 2 weeks as instant rollback). +3. Cutover = move the `cesnimda.co.uk` Traefik Host router from the WordPress container to the `site` container (a label change + `compose up -d`; WP left running dark for 2 weeks as instant rollback). 4. Post-launch checklist: submit sitemaps, watch Search Console coverage + relay logs for form spam patterns, then decommission WP + its PHP/MySQL surface (attack-surface win worth noting on the colophon). ## 4. Operational guardrails diff --git a/docs/spec/DOCKER_SPEC.md b/docs/spec/DOCKER_SPEC.md index 2d07d36..53d6dca 100644 --- a/docs/spec/DOCKER_SPEC.md +++ b/docs/spec/DOCKER_SPEC.md @@ -31,7 +31,7 @@ deploy/ .env.example # documented variables, no real values ``` -- Prod compose joins the server's existing reverse-proxy network; the host nginx (existing, TLS-terminating) routes `cesnimda.co.uk` → `site:8080` and `cesnimda.co.uk/api/contact` → `contact-relay:8081`. The stack publishes **no host ports** itself. +- Prod compose joins the server's existing Traefik network (`traefik_proxy`); the host Traefik (existing, TLS-terminating, Cloudflare-fronted) discovers the containers via labels and routes `cesnimda.co.uk` → `site:8080` and `cesnimda.co.uk/api/contact` → `relay:8081` (the relay's `PathPrefix` rule outranks the site's `Host` rule). The stack publishes **no host ports** itself. - `restart: unless-stopped`, log rotation via compose logging options, images referenced by registry tag + digest. - Optional third service slot (`analytics`, self-hosted Umami/Plausible) reserved in the compose file as a commented profile — decision deferred. diff --git a/site/src/components/pages/ColophonPage.astro b/site/src/components/pages/ColophonPage.astro index 62e54bb..b8b8a49 100644 --- a/site/src/components/pages/ColophonPage.astro +++ b/site/src/components/pages/ColophonPage.astro @@ -34,7 +34,7 @@ const copy = { ], [ 'Self-hosted', - 'Built and deployed from my own Gitea instance to Docker behind nginx. The contact form is a small stateless .NET service — even that is part of my own stack.', + 'Built and deployed from my own Gitea instance to Docker behind Traefik. The contact form is a small stateless .NET service — even that is part of my own stack.', ], [ 'No tracking', @@ -59,7 +59,7 @@ const copy = { ], [ 'Egendriftet', - 'Bygget og rullet ut fra min egen Gitea-instans til Docker bak nginx. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.', + 'Bygget og rullet ut fra min egen Gitea-instans til Docker bak Traefik. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.', ], [ 'Ingen sporing', diff --git a/site/src/data/projects/homelab.ts b/site/src/data/projects/homelab.ts index ddcdf75..8e286d6 100644 --- a/site/src/data/projects/homelab.ts +++ b/site/src/data/projects/homelab.ts @@ -1,6 +1,10 @@ import type { Project } from '@lib/schema'; -/* Homelab — capability page (lighter template): no TL;DR/decisions/gallery rendered. */ +/* + Homelab — capability page (lighter template): no TL;DR/decisions/gallery rendered. + Content reflects the real setup inspected on the host (Ubuntu 24.04, ~30 Docker + services behind Traefik with Authentik SSO, CrowdSec, Pi-hole, Gitea CI). +*/ export const homelab: Project = { id: 'homelab', name: 'Self-hosted infrastructure lab', @@ -10,88 +14,100 @@ export const homelab: Project = { stack: [ { name: 'Ubuntu' }, { name: 'Docker' }, - { name: 'nginx' }, - { name: 'Gitea' }, - { name: 'Monitoring' }, - { name: 'Backups' }, + { name: 'Traefik' }, + { name: 'Authentik' }, + { name: 'CrowdSec' }, + { name: 'Gitea CI' }, ], links: [], diagram: { - viewBox: '0 0 1120 300', + viewBox: '0 0 1120 310', title: { en: 'Homelab topology', no: 'Hjemmelab-topologi' }, desc: { - en: 'Inbound traffic reaches an nginx reverse proxy terminating TLS, which routes to self-hosted services: Gitea, application containers and a monitoring stack.', - no: 'Innkommende trafikk når en nginx reverse proxy som terminerer TLS, og rutes videre til egendriftede tjenester: Gitea, applikasjonscontainere og en overvåkingsstack.', + en: 'Traffic arrives through Cloudflare to a Traefik reverse proxy that terminates TLS and applies Authentik single sign-on, then routes to self-hosted services: Gitea and its CI runner, application containers, and a media stack.', + no: 'Trafikk kommer via Cloudflare til en Traefik reverse proxy som terminerer TLS og bruker Authentik single sign-on, og rutes videre til egendriftede tjenester: Gitea med CI-runner, applikasjonscontainere og en mediastack.', }, nodes: [ { - id: 'internet', + id: 'cloudflare', x: 40, - y: 116, - w: 160, - h: 72, + y: 124, + w: 170, + h: 68, kind: 'external', - label: 'Internet', - sub: { en: 'HTTPS', no: 'HTTPS' }, + label: 'Cloudflare', + sub: { en: 'DNS · edge · WAF', no: 'DNS · edge · WAF' }, }, { - id: 'proxy', - x: 280, - y: 116, - w: 190, - h: 72, + id: 'traefik', + x: 290, + y: 124, + w: 180, + h: 68, kind: 'primary', - label: 'nginx', + label: 'Traefik', sub: { en: 'reverse proxy · TLS', no: 'reverse proxy · TLS' }, }, + { + id: 'authentik', + x: 290, + y: 232, + w: 180, + h: 56, + kind: 'internal', + label: 'Authentik', + sub: { en: 'SSO · forward-auth', no: 'SSO · forward-auth' }, + }, { id: 'gitea', x: 560, - y: 40, - w: 190, + y: 44, + w: 210, h: 60, kind: 'internal', - label: 'Gitea', - sub: { en: 'git.cesnimda.uk', no: 'git.cesnimda.uk' }, + label: 'Gitea + runner', + sub: { en: 'git.cesnimda.uk · CI/CD', no: 'git.cesnimda.uk · CI/CD' }, }, { id: 'apps', x: 560, - y: 120, - w: 190, + y: 128, + w: 210, h: 60, kind: 'internal', - label: 'App services', - sub: { en: 'Docker containers', no: 'Docker-containere' }, + label: 'App containers', + sub: { en: 'JobTrack · this site', no: 'JobTrack · denne siden' }, }, { - id: 'monitor', + id: 'media', x: 560, - y: 200, - w: 190, + y: 212, + w: 210, h: 60, kind: 'internal', - label: 'Monitoring', - sub: { en: 'uptime · logs', no: 'oppetid · logger' }, + label: 'Media stack', + sub: { en: 'Jellyfin · *arr', no: 'Jellyfin · *arr' }, }, ], edges: [ - { d: 'M200 152 H280', kind: 'flow' }, - { d: 'M470 140 L560 70', kind: 'flow' }, - { d: 'M470 152 H560', kind: 'flow' }, - { d: 'M470 164 L560 230', kind: 'flow' }, + { d: 'M210 158 H290', kind: 'flow' }, + { d: 'M380 192 V232', kind: 'flow', label: 'auth', labelX: 400, labelY: 218 }, + { d: 'M470 150 L560 74', kind: 'flow' }, + { d: 'M470 158 H560', kind: 'flow' }, + { d: 'M470 166 L560 242', kind: 'flow' }, ], }, media: [], content: { en: { - valueProp: 'Where I run, break and fix real infrastructure — not just deploy it.', + valueProp: + 'Where I run, break and fix real infrastructure — around thirty containerised services behind a single reverse proxy.', cardTeaser: - 'Ubuntu · Docker services · reverse proxy · auth · monitoring · Gitea (this site’s own git remote).', + 'Ubuntu + Docker · Traefik · Authentik SSO · CrowdSec · Gitea CI — ~30 self-hosted services, and the deploy target for this site.', tldr: { - what: 'A self-hosted home lab running production-style services.', - why: 'To keep operations skills hands-on.', - stack: 'Ubuntu · Docker · nginx · Gitea · monitoring.', + what: 'A self-hosted home server running ~30 production-style services.', + why: 'To keep operations and security skills hands-on.', + stack: 'Ubuntu · Docker · Traefik · Authentik · CrowdSec · Gitea.', role: 'Operator.', }, sections: [ @@ -102,7 +118,7 @@ export const homelab: Project = { blocks: [ { type: 'p', - text: 'A home lab running Ubuntu and Docker-based services behind an nginx reverse proxy, with authentication, monitoring and backups. It’s where I get ongoing, hands-on practice with deployment, troubleshooting and real-world operations — including the self-hosted Gitea instance that this very site is built and deployed from.', + text: 'An Ubuntu 24.04 server running around thirty Docker services behind a Traefik reverse proxy, with single sign-on, an intrusion-prevention layer, DNS filtering and a full CI/CD setup. It’s where I get ongoing, hands-on practice with deployment, security and real-world operations — including the self-hosted Gitea instance and runner that build and deploy this very portfolio.', }, ], }, @@ -114,10 +130,11 @@ export const homelab: Project = { { type: 'ul', items: [ - 'nginx reverse proxy terminating TLS in front of every service.', - 'Docker Compose stacks for applications, each isolated on its own network.', - 'Authentication in front of internal tools; monitoring for uptime and logs.', - 'Self-hosted Gitea with CI runners — the deployment target for this portfolio.', + 'Traefik terminates TLS and routes every service, fronted by Cloudflare, with separate internal and external entrypoints and HTTP/3.', + 'Authentik provides single sign-on as a Traefik forward-auth middleware in front of internal tools; CrowdSec adds intrusion prevention and Pi-hole handles DNS filtering.', + 'Gitea and a Gitea Actions runner give me self-hosted git and CI/CD — the build-and-deploy target for this site.', + 'The Docker socket is exposed to Traefik only through a hardened socket-proxy, and each application runs on its own isolated Docker network.', + 'Portainer and Dozzle handle container management and logs; alongside these run application, media and utility stacks.', ], }, ], @@ -129,7 +146,7 @@ export const homelab: Project = { blocks: [ { type: 'p', - text: 'Tightening backup verification and adding more automated health checks and alerting across the stack.', + text: 'Retiring the legacy WordPress site now that this portfolio replaces it, then tightening backup verification and expanding automated health checks and alerting across the stack.', }, ], }, @@ -137,13 +154,13 @@ export const homelab: Project = { }, no: { valueProp: - 'Der jeg drifter, ødelegger og fikser ekte infrastruktur — ikke bare ruller den ut.', + 'Der jeg drifter, ødelegger og fikser ekte infrastruktur — rundt tretti containeriserte tjenester bak én reverse proxy.', cardTeaser: - 'Ubuntu · Docker-tjenester · reverse proxy · auth · overvåking · Gitea (git-remoten til denne siden).', + 'Ubuntu + Docker · Traefik · Authentik SSO · CrowdSec · Gitea CI — ~30 egendriftede tjenester, og utrullingsmålet for denne siden.', tldr: { - what: 'En egendriftet hjemmelab som kjører produksjonslignende tjenester.', - why: 'For å holde driftsferdighetene praktiske.', - stack: 'Ubuntu · Docker · nginx · Gitea · overvåking.', + what: 'En egendriftet hjemmeserver med ~30 produksjonslignende tjenester.', + why: 'For å holde drifts- og sikkerhetsferdighetene praktiske.', + stack: 'Ubuntu · Docker · Traefik · Authentik · CrowdSec · Gitea.', role: 'Operatør.', }, sections: [ @@ -154,7 +171,7 @@ export const homelab: Project = { blocks: [ { type: 'p', - text: 'En hjemmelab som kjører Ubuntu og Docker-baserte tjenester bak en nginx reverse proxy, med autentisering, overvåking og sikkerhetskopier. Det er her jeg får løpende, praktisk øving på utrulling, feilsøking og ekte drift — inkludert den egendriftede Gitea-instansen som denne siden bygges og rulles ut fra.', + text: 'En Ubuntu 24.04-server som kjører rundt tretti Docker-tjenester bak en Traefik reverse proxy, med single sign-on, et lag for inntrengingsvern, DNS-filtrering og full CI/CD. Det er her jeg får løpende, praktisk øving på utrulling, sikkerhet og ekte drift — inkludert den egendriftede Gitea-instansen og runneren som bygger og ruller ut denne porteføljen.', }, ], }, @@ -166,10 +183,11 @@ export const homelab: Project = { { type: 'ul', items: [ - 'nginx reverse proxy som terminerer TLS foran hver tjeneste.', - 'Docker Compose-stacker for applikasjoner, hver isolert på eget nettverk.', - 'Autentisering foran interne verktøy; overvåking av oppetid og logger.', - 'Egendriftet Gitea med CI-runnere — utrullingsmålet for denne porteføljen.', + 'Traefik terminerer TLS og ruter alle tjenester, med Cloudflare foran, egne interne og eksterne entrypoints og HTTP/3.', + 'Authentik gir single sign-on som en Traefik forward-auth-middleware foran interne verktøy; CrowdSec legger til inntrengingsvern og Pi-hole håndterer DNS-filtrering.', + 'Gitea og en Gitea Actions-runner gir egendriftet git og CI/CD — bygge- og utrullingsmålet for denne siden.', + 'Docker-socketen eksponeres kun til Traefik gjennom en herdet socket-proxy, og hver applikasjon kjører på sitt eget isolerte Docker-nettverk.', + 'Portainer og Dozzle håndterer containere og logger; ved siden av kjører applikasjons-, media- og verktøystacker.', ], }, ], @@ -181,7 +199,7 @@ export const homelab: Project = { blocks: [ { type: 'p', - text: 'Strammere verifisering av sikkerhetskopier og flere automatiske helsesjekker og varsling på tvers av stacken.', + text: 'Legge ned den gamle WordPress-siden nå som denne porteføljen erstatter den, og deretter stramme inn verifisering av sikkerhetskopier og utvide automatiske helsesjekker og varsling på tvers av stacken.', }, ], },