diff --git a/.gitignore b/.gitignore index 2975eefa..d9d02085 100644 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,17 @@ # dependencies /node_modules -/.pnp -.pnp.js -# next.js -/.next/ +# astro /out/ - -# production -/build - -# testing -/coverage +/.astro/ # misc .DS_Store .env -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* +.env.* # typescript *.tsbuildinfo -next-env.d.ts # docs /docs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..27343388 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Project:** [PERSONAL PORTFOLIO] +**Stack:** [Astro 5 + TypeScript + Tailwind v4] +**Deployed to:** [lerkolabs.com — self-hosted via Gitea Actions] + +## Branch Strategy + +This repo uses two branches: +- **`master`** — built output only; reserved for future GitHub mirror +- **`dev`** — source code; all development happens here + +**Always work on the `dev` branch.** Never manually edit files on `master`. + +## Commands + +```bash +npm run dev # Dev server at localhost:4321 +npm run build # Production build into out/ (static export) +npm run preview # Preview production build locally +``` + +## Architecture + +Astro 5 static-export portfolio site with TypeScript. Zero client-side framework — all interactivity via inline ` diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx deleted file mode 100644 index 388bf99d..00000000 --- a/src/components/Nav.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useTheme } from "@/context/ThemeContext"; - -const links = [ - { href: "/", label: "tyler" }, - { href: "/homelab/", label: "homelab" }, - { href: "/projects/", label: "projects" }, -]; - -export default function Nav() { - const pathname = usePathname(); - const { isDark, toggle } = useTheme(); - return ( -
- -
- ); -} diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro new file mode 100644 index 00000000..8aa30a62 --- /dev/null +++ b/src/components/ProjectCard.astro @@ -0,0 +1,67 @@ +--- +import type { Project } from "@/data/projects"; + +interface Props { + project: Project; +} + +const { project } = Astro.props; +--- + +
+
+ + {project.title} + +
+ {project.stats && ( + + {project.stats} + + )} + {project.externalUrl && ( + + ↗ + + )} + + ↗ + +
+
+ + {project.statusBadge && ( + + {project.statusBadge} + + )} + +

+ {project.description} +

+ +
+ {project.tags.map((tag) => ( + + {tag} + + ))} +
+
diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx deleted file mode 100644 index f1d17579..00000000 --- a/src/components/ProjectCard.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { Project } from "@/data/projects"; - -type Props = { - project: Project; -}; - -export default function ProjectCard({ project }: Props) { - return ( -
-
- - {project.title} - -
- {project.stats && ( - - {project.stats} - - )} - {project.externalUrl && ( - - ↗ - - )} - - ↗ - -
-
- - {project.statusBadge && ( - - {project.statusBadge} - - )} - -

- {project.description} -

- -
- {project.tags.map((tag) => ( - - {tag} - - ))} -
-
- ); -} diff --git a/src/components/Skills.astro b/src/components/Skills.astro new file mode 100644 index 00000000..a07167b5 --- /dev/null +++ b/src/components/Skills.astro @@ -0,0 +1,43 @@ +--- +import Widget from "./Widget.astro"; + +const skillGroups = [ + { + label: "Infrastructure", + skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"], + }, + { + label: "Desktop & Tools", + skills: ["Git", "Docker", "TDD", "Node.js", "REST APIs"], + }, + { + label: "Practices", + skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"], + }, + { + label: "Languages", + skills: ["Go", "JavaScript", "TypeScript", "HTML", "CSS"], + }, + { + label: "Frontend", + skills: ["React", "React Native", "Expo", "Next.js", "Three.js"], + }, +]; + +const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0); +--- + + +
+ {skillGroups.map(({ label, skills }) => ( +
+ + {label} + + + {skills.join(" · ")} + +
+ ))} +
+
diff --git a/src/components/Skills.tsx b/src/components/Skills.tsx deleted file mode 100644 index 97bae133..00000000 --- a/src/components/Skills.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import Widget from "@/components/Widget"; - -const skillGroups = [ - { - label: "Infrastructure", - skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"], - }, - { - label: "Desktop & Tools", - skills: ["Git", "Docker", "TDD", "Node.js", "REST APIs", ], - }, - { - label: "Practices", - skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"], - }, - { - label: "Languages", - skills: ["Go", "JavaScript", "TypeScript", "HTML", "CSS"], - }, - { - label: "Frontend", - skills: ["React", "React Native", "Expo", "Next.js", "Three.js"], - }, -]; - -const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0); - -export default function Skills() { - return ( - -
- {skillGroups.map(({ label, skills }) => ( -
- - {label} - - - {skills.join(" · ")} - -
- ))} -
-
- ); -} diff --git a/src/components/ThemeScript.tsx b/src/components/ThemeScript.tsx deleted file mode 100644 index 8bd738b2..00000000 --- a/src/components/ThemeScript.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// Server component — renders a blocking inline script that sets the dark class -// on before React hydrates, preventing flash of wrong theme. -export default function ThemeScript() { - const script = ` - (function() { - var stored = localStorage.getItem('lerko96-dark-mode'); - var dark = stored === null ? true : stored === 'true'; - if (dark) document.documentElement.classList.add('dark'); - })(); - `; - return diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx deleted file mode 100644 index 3fa1292d..00000000 --- a/src/components/Timeline.tsx +++ /dev/null @@ -1,107 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import Widget from '@/components/Widget' -import { timeline, type TimelineType } from '@/data/timeline' - -const typeColor: Record = { - career: 'var(--color-timeline-career)', - education: 'var(--color-timeline-education)', - cert: 'var(--color-timeline-cert)', - project: 'var(--color-timeline-project)', - homelab: 'var(--color-timeline-homelab)', -} - -const typeLabel: Record = { - career: 'career', - education: 'education', - cert: 'cert', - project: 'project', - homelab: 'homelab', -} - -export default function Timeline() { - const listRef = useRef(null) - - useEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return - - const entries = listRef.current?.querySelectorAll('[data-tl-entry]') - if (!entries) return - - const observer = new IntersectionObserver( - (observed) => { - observed.forEach((entry) => { - if (entry.isIntersecting) { - ;(entry.target as HTMLElement).style.opacity = '1' - ;(entry.target as HTMLElement).style.transform = 'translateY(0)' - observer.unobserve(entry.target) - } - }) - }, - { threshold: 0.15 }, - ) - - entries.forEach((el) => { - el.style.opacity = '0' - el.style.transform = 'translateY(8px)' - el.style.transition = 'opacity 240ms linear, transform 240ms linear' - observer.observe(el) - }) - - return () => observer.disconnect() - }, []) - - return ( - -
    - {timeline.map((entry, i) => ( -
  1. - {/* Spine dot */} -
  2. - ))} -
-
- ) -} diff --git a/src/components/Widget.astro b/src/components/Widget.astro new file mode 100644 index 00000000..0e66c652 --- /dev/null +++ b/src/components/Widget.astro @@ -0,0 +1,30 @@ +--- +interface Props { + title: string; + badge?: string | number; + meta?: string; + as?: "section" | "div" | "article"; + class?: string; +} + +const { title, badge, meta, as: Tag = "section", class: className } = Astro.props; +const slashIdx = title.lastIndexOf("/"); +const prefix = slashIdx >= 0 ? title.slice(0, slashIdx + 1) : null; +const name = slashIdx >= 0 ? title.slice(slashIdx + 1) : title; +--- + + +
+ {prefix && ( + {prefix} + )} + {name} + {badge !== undefined && ( + [{badge}] + )} + {meta && ( + — {meta} + )} +
+ +
diff --git a/src/components/Widget.tsx b/src/components/Widget.tsx deleted file mode 100644 index 5d22d89c..00000000 --- a/src/components/Widget.tsx +++ /dev/null @@ -1,39 +0,0 @@ -type WidgetProps = { - title: string; - badge?: string | number; - meta?: string; - as?: "section" | "div" | "article"; - className?: string; - children: React.ReactNode; -}; - -export default function Widget({ - title, - badge, - meta, - as: Tag = "section", - className, - children, -}: WidgetProps) { - const slashIdx = title.lastIndexOf("/"); - const prefix = slashIdx >= 0 ? title.slice(0, slashIdx + 1) : null; - const name = slashIdx >= 0 ? title.slice(slashIdx + 1) : title; - - return ( - -
- {prefix && ( - {prefix} - )} - {name} - {badge !== undefined && ( - [{badge}] - )} - {meta && ( - — {meta} - )} -
- {children} -
- ); -} diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx deleted file mode 100644 index 9a3a6957..00000000 --- a/src/context/ThemeContext.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { createContext, useContext, useEffect, useState } from "react"; - -type ThemeContextType = { - isDark: boolean; - toggle: () => void; -}; - -const ThemeContext = createContext({ - isDark: true, - toggle: () => {}, -}); - -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [isDark, setIsDark] = useState(true); - - useEffect(() => { - const stored = localStorage.getItem("lerko96-dark-mode"); - const dark = stored === null ? true : stored === "true"; - setIsDark(dark); - document.documentElement.classList.toggle("dark", dark); - }, []); - - function toggle() { - const next = !isDark; - setIsDark(next); - localStorage.setItem("lerko96-dark-mode", String(next)); - document.documentElement.classList.toggle("dark", next); - } - - return ( - - {children} - - ); -} - -export function useTheme() { - return useContext(ThemeContext); -} diff --git a/src/data/projects.ts b/src/data/projects.ts index de31304d..bcfbcdd4 100644 --- a/src/data/projects.ts +++ b/src/data/projects.ts @@ -27,8 +27,8 @@ export const projects: Project[] = [ slug: "portfolio", title: "portfolio", description: - "Next.js 16 static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.", - tags: ["Next.js", "Dockerfile", "Tailwind", "nginx", "Caddy"], + "Astro static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.", + tags: ["Astro", "Dockerfile", "Tailwind", "nginx", "Caddy"], githubUrl: "https://gitea.lerkolabs.com/lerko/portfolio", tier: "featured", year: 2021, diff --git a/src/data/services.ts b/src/data/services.ts index d3c206f6..9e8d9677 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -2,56 +2,55 @@ export type Service = { name: string; description: string; category: "infrastructure" | "security" | "monitoring" | "productivity" | "media"; - icon: string; // Font Awesome class }; export const services: Service[] = [ // Infrastructure - { name: "pfSense", description: "Firewall, DHCP, routing gateway on N100", category: "infrastructure", icon: "fas fa-shield-halved" }, - { name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure", icon: "fas fa-globe" }, - { name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure", icon: "fas fa-filter" }, - { name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure", icon: "fas fa-lock" }, - { name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure", icon: "fas fa-envelope" }, - { name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure", icon: "fas fa-shield" }, - { name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure", icon: "fas fa-house" }, + { name: "pfSense", description: "Firewall, DHCP, routing gateway on Netgate 1100", category: "infrastructure" }, + { name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure" }, + { name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure" }, + { name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure" }, + { name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure" }, + { name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure" }, + { name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure" }, // Security / Auth - { name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security", icon: "fas fa-id-badge" }, - { name: "Vaultwarden", description: "Self-hosted password manager, isolated in its own LXC", category: "security", icon: "fas fa-vault" }, + { name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security" }, + { name: "Vaultwarden", description: "Self-hosted password manager, isolated in its own LXC", category: "security" }, // Monitoring - { name: "Victoria Metrics", description: "Long-term metrics storage and querying", category: "monitoring", icon: "fas fa-chart-line" }, - { name: "Grafana", description: "Dashboards and alerting across all hosts and services", category: "monitoring", icon: "fas fa-chart-bar" }, - { name: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring", icon: "fas fa-server" }, - { name: "ntfy", description: "Self-hosted push notifications", category: "monitoring", icon: "fas fa-bell" }, + { name: "Victoria Metrics", description: "Long-term metrics storage and querying", category: "monitoring" }, + { name: "Grafana", description: "Dashboards and alerting across all hosts and services", category: "monitoring" }, + { name: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring" }, + { name: "ntfy", description: "Self-hosted push notifications", category: "monitoring" }, // Productivity - { name: "Gitea", description: "Personal Git server", category: "productivity", icon: "fas fa-code-branch" }, - { name: "Outline", description: "Team wiki and knowledge base", category: "productivity", icon: "fas fa-book" }, - { name: "Vikunja", description: "Task management", category: "productivity", icon: "fas fa-list-check" }, - { name: "Actual Budget", description: "Personal budgeting", category: "productivity", icon: "fas fa-wallet" }, - { name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity", icon: "fas fa-coins" }, - { name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity", icon: "fas fa-bookmark" }, - { name: "FreshRSS", description: "RSS reader", category: "productivity", icon: "fas fa-rss" }, - { name: "Memos", description: "Quick notes and journal", category: "productivity", icon: "fas fa-note-sticky" }, - { name: "Traggo", description: "Time tracking", category: "productivity", icon: "fas fa-clock" }, - { name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity", icon: "fas fa-calendar" }, - { name: "Grist", description: "Spreadsheets and structured data", category: "productivity", icon: "fas fa-table" }, - { name: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity", icon: "fas fa-gauge" }, - { name: "Filebrowser", description: "Web-based file manager", category: "productivity", icon: "fas fa-folder-open" }, + { name: "Gitea", description: "Personal Git server", category: "productivity" }, + { name: "Outline", description: "Team wiki and knowledge base", category: "productivity" }, + { name: "Vikunja", description: "Task management", category: "productivity" }, + { name: "Actual Budget", description: "Personal budgeting", category: "productivity" }, + { name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity" }, + { name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity" }, + { name: "FreshRSS", description: "RSS reader", category: "productivity" }, + { name: "Memos", description: "Quick notes and journal", category: "productivity" }, + { name: "Traggo", description: "Time tracking", category: "productivity" }, + { name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity" }, + { name: "Grist", description: "Spreadsheets and structured data", category: "productivity" }, + { name: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity" }, + { name: "Filebrowser", description: "Web-based file manager", category: "productivity" }, // Media - { name: "Plex", description: "Media streaming — movies, TV, music", category: "media", icon: "fas fa-film" }, - { name: "Jellyfin", description: "Open-source media streaming", category: "media", icon: "fas fa-play" }, - { name: "Sonarr", description: "Automated TV show management", category: "media", icon: "fas fa-tv" }, - { name: "Radarr", description: "Automated movie management", category: "media", icon: "fas fa-video" }, - { name: "Lidarr", description: "Automated music management", category: "media", icon: "fas fa-music" }, - { name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media", icon: "fas fa-magnifying-glass" }, - { name: "Bazarr", description: "Automatic subtitle download and management", category: "media", icon: "fas fa-closed-captioning" }, - { name: "nzbget", description: "Usenet downloader", category: "media", icon: "fas fa-download" }, - { name: "qBittorrent", description: "Torrent client with web UI", category: "media", icon: "fas fa-magnet" }, - { name: "Kavita", description: "Self-hosted manga and book reader", category: "media", icon: "fas fa-book-open" }, - { name: "Openshelf", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" }, + { name: "Plex", description: "Media streaming — movies, TV, music", category: "media" }, + { name: "Jellyfin", description: "Open-source media streaming", category: "media" }, + { name: "Sonarr", description: "Automated TV show management", category: "media" }, + { name: "Radarr", description: "Automated movie management", category: "media" }, + { name: "Lidarr", description: "Automated music management", category: "media" }, + { name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media" }, + { name: "Bazarr", description: "Automatic subtitle download and management", category: "media" }, + { name: "nzbget", description: "Usenet downloader", category: "media" }, + { name: "qBittorrent", description: "Torrent client with web UI", category: "media" }, + { name: "Kavita", description: "Self-hosted manga and book reader", category: "media" }, + { name: "Openshelf", description: "Book library with auto-ingest", category: "media" }, ]; export const categoryOrder: Service["category"][] = [ diff --git a/src/data/timeline.ts b/src/data/timeline.ts index d79a26de..a6d1914b 100644 --- a/src/data/timeline.ts +++ b/src/data/timeline.ts @@ -27,8 +27,8 @@ export const timeline: TimelineEntry[] = [ title: "Portfolio Site v2", type: "project", description: - "Next.js 16 portfolio site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.", - tags: ["next.js", "tailwind", "self-hosted"], + "Astro portfolio site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.", + tags: ["astro", "tailwind", "self-hosted"], }, { date: "2026-04", @@ -74,7 +74,7 @@ export const timeline: TimelineEntry[] = [ title: "pfSense", type: "homelab", description: - "Netgate 1100 picked up on ebay to experience hands on networking configuration and troubleshooting.", + "Netgate 1100 (Marvell ARMADA 3720) picked up on eBay — hands-on networking configuration, VLANs, firewall rules, and troubleshooting.", tags: ["network", "firewall", "vlan", "dhcp"], }, { diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro new file mode 100644 index 00000000..39d32349 --- /dev/null +++ b/src/layouts/Base.astro @@ -0,0 +1,69 @@ +--- +interface Props { + title?: string; + description?: string; +} + +const { + title = "Tyler Koenig", + description = "SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.", +} = Astro.props; +--- + + + + + + + + {title} + + + + + +
+
+ +
+ +
+ + + + diff --git a/src/pages/archive.astro b/src/pages/archive.astro new file mode 100644 index 00000000..987bbc6c --- /dev/null +++ b/src/pages/archive.astro @@ -0,0 +1,12 @@ + + + + + + + Redirecting... + + +

This page moved. /projects/

+ + diff --git a/src/pages/homelab.astro b/src/pages/homelab.astro new file mode 100644 index 00000000..72784006 --- /dev/null +++ b/src/pages/homelab.astro @@ -0,0 +1,237 @@ +--- +import Base from "@/layouts/Base.astro"; +import Nav from "@/components/Nav.astro"; +import Footer from "@/components/Footer.astro"; +import Widget from "@/components/Widget.astro"; +import { services, categoryOrder, categoryLabels } from "@/data/services"; + +const glanceStats = [ + { label: "Hypervisor", value: "Proxmox VE" }, + { label: "Firewall", value: "pfSense (Netgate 1100)" }, + { label: "Switching", value: "TP-Link Omada (managed)" }, + { label: "ISP", value: "AT&T Fiber 1 Gbps" }, + { label: "VPN", value: "WireGuard (pfSense)" }, + { label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" }, + { label: "Auth", value: "Authentik SSO" }, + { label: "DNS", value: "Pi-hole → Unbound → Cloudflare" }, + { label: "Containers", value: "9 LXC + 2 VMs" }, +]; + +const vlans = [ + { id: "MGMT", name: "MGMT", purpose: "Network equipment only" }, + { id: "LAN", name: "LAN", purpose: "Trusted personal devices" }, + { id: "Lab", name: "Homelab", purpose: "All self-hosted services" }, + { id: "Guest", name: "Guests", purpose: "Internet only, RFC1918 blocked" }, + { id: "IoT", name: "IoT", purpose: "Smart home, isolated" }, + { id: "WFH", name: "WFH", purpose: "Work devices, no personal access" }, + { id: "DMZ", name: "DMZ", purpose: "Public-facing, hard-blocked internally" }, + { id: "VPN", name: "VPN", purpose: "WireGuard clients, LAN-equivalent access" }, +]; + +const adrs = [ + { + title: "ISP gateway: passthrough mode", + decision: + "ISP gateway stays in-line in passthrough mode, pfSense gets the public IP directly. Gateway WiFi disabled.", + why: "Carrier locks 802.1X auth to their own gateway hardware, and bypassing it is brittle — breaks on firmware updates and only saves a millisecond or two. True bridge mode isn't supported. Passthrough is the cleanest option that keeps pfSense as the actual perimeter.", + }, + { + title: "Caddy over NGINX Proxy Manager", + decision: + "Caddy with DNS-01 challenge via Cloudflare API. All subdomains resolve to Caddy internally via Pi-hole. Caddy terminates TLS and proxies to backends.", + why: "Single Caddyfile, automatic certs without ever needing to expose internal services to the internet for an HTTP-01 challenge. NPM has more UI overhead for the same outcome. Traefik is more complex for no benefit at this scale.", + }, + { + title: "WireGuard over OpenVPN", + decision: + "WireGuard on pfSense as the only remote-access path. Clients get the access tier documented in the access model — same as LAN, plus the admin surfaces that aren't reachable any other way.", + why: "Faster, simpler config, better battery life on mobile. Throughput on the firewall hardware comfortably exceeds the WAN link. OpenVPN has no advantage here. Tailscale would add an external relay dependency for a problem WireGuard already solves.", + }, + { + title: "Pi-hole in Homelab VLAN, not MGMT", + decision: + "Pi-hole runs in the Homelab VLAN. Firewall allows port 53 inbound from VLANs that need local resolution. MGMT uses pfSense Unbound as its primary resolver instead.", + why: "Putting Pi-hole in MGMT would mean opening MGMT to all the VLANs that need DNS — much bigger attack surface for the most sensitive tier. DNS traffic crossing into the Homelab VLAN is the lesser risk, and Homelab is already where service traffic terminates anyway.", + }, + { + title: "Netgate 1100 for pfSense", + decision: + "Netgate 1100 (Marvell ARMADA 3720, dual-core ARM) as the firewall appliance. ~6W idle, line-rate NAT at 1 Gbps, WireGuard at ~100–150 Mbps.", + why: "Purpose-built for pfSense. Right-sized for 1 Gbps fiber — NAT saturates the link, WireGuard is fast enough for remote access. A full rack server wastes power for this role. Configs and version tracked in private repo.", + }, + { + title: "Shared Postgres + Redis in apps LXC", + decision: + "One Postgres instance hosting multiple databases. One Redis instance. A single init script provisions schemas on first run.", + why: "Avoids ~15 separate DB containers. Big RAM savings. Productivity apps colocate in one LXC anyway, so a shared backing store there is the natural shape.", + }, + { + title: "Gitea CI/CD: self-hosted runner, internal pipeline, static deploy", + decision: + "Self-hosted Gitea Actions runner builds the portfolio on push, then deploys pre-built static files to the public-facing host. Build runs in an isolated container so the runner host stays clean. Public host serves static files only — no build toolchain on it.", + why: "Keeps the whole pipeline internal. No external runners, no GitHub Actions. The build/serve split means the public-facing host has the smallest possible footprint — static file server, nothing more.", + }, + { + title: "Authentik over Authelia", + decision: "Authentik as the SSO provider across all self-hosted services.", + why: "Full OIDC provider + forward auth in one. Lets services like Outline, Gitea, and Vikunja use real SSO rather than just a login gate. Authelia is forward-auth only — no OIDC provider capability.", + }, +]; +--- + + +