All checks were successful
Build and Deploy / deploy (push) Successful in 51s
Reorder homepage sections (journey above projects), refine component styles, update copy and data across projects, timeline, and homelab. Reviewed-on: #5
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import type { Metadata } from "next";
|
||
import Widget from "@/components/Widget";
|
||
import { services, categoryOrder, categoryLabels } from "@/data/services";
|
||
|
||
export const metadata: Metadata = {
|
||
title: "Homelab | Tyler Koenig",
|
||
description:
|
||
"Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services.",
|
||
};
|
||
|
||
const glanceStats = [
|
||
{ label: "Hypervisor", value: "Proxmox VE" },
|
||
{ label: "Firewall", value: "pfSense (Intel N100)" },
|
||
{ 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: "1000",
|
||
name: "MGMT",
|
||
subnet: "10.0.0.0/24",
|
||
purpose: "Network equipment only",
|
||
},
|
||
{
|
||
id: "1010",
|
||
name: "LAN",
|
||
subnet: "10.1.0.0/24",
|
||
purpose: "Trusted personal devices",
|
||
},
|
||
{
|
||
id: "1020",
|
||
name: "Homelab",
|
||
subnet: "10.2.0.0/24",
|
||
purpose: "All self-hosted services",
|
||
},
|
||
{
|
||
id: "1030",
|
||
name: "Guests",
|
||
subnet: "10.3.0.0/24",
|
||
purpose: "Internet only, RFC1918 blocked",
|
||
},
|
||
{
|
||
id: "1040",
|
||
name: "IoT",
|
||
subnet: "10.4.0.0/24",
|
||
purpose: "Smart home, isolated",
|
||
},
|
||
{
|
||
id: "1050",
|
||
name: "WFH",
|
||
subnet: "10.5.0.0/24",
|
||
purpose: "Work devices, no personal access",
|
||
},
|
||
{
|
||
id: "1099",
|
||
name: "DMZ",
|
||
subnet: "10.99.0.0/24",
|
||
purpose: "Public-facing, hard-blocked internally",
|
||
},
|
||
{
|
||
id: "VPN",
|
||
name: "VPN",
|
||
subnet: "10.200.0.0/24",
|
||
purpose: "WireGuard clients = LAN access",
|
||
},
|
||
];
|
||
|
||
const adrs = [
|
||
{
|
||
title: "AT&T Gateway: IP Passthrough over EAP bypass",
|
||
decision:
|
||
"BGW320 stays in-line with IP Passthrough mode. pfSense gets the public IP directly. Gateway WiFi disabled.",
|
||
why: "AT&T locks 802.1X auth to their gateway hardware. EAP proxy bypass is brittle — breaks on firmware updates and only saves 1–2ms. True bridge mode isn't supported.",
|
||
},
|
||
{
|
||
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 SSL and proxies to backends.",
|
||
why: "Single Caddyfile, auto-cert without exposing port 80/443 to the internet. NPM has more UI overhead for the same outcome. Traefik is more complex for no benefit here.",
|
||
},
|
||
{
|
||
title: "WireGuard over OpenVPN",
|
||
decision:
|
||
"WireGuard on pfSense, UDP 51820, VPN subnet 10.200.0.0/24. Clients get LAN + MGMT access, blocked from Guest/IoT/WFH.",
|
||
why: "Faster, simpler config, better battery life on mobile. ~600–900 Mbps on an N100. OpenVPN has no advantage here. Tailscale adds an external relay dependency.",
|
||
},
|
||
{
|
||
title: "Pi-hole in Homelab VLAN, not MGMT",
|
||
decision:
|
||
"Pi-hole at 10.2.0.11 (VLAN 1020). Firewall allows port 53 inbound from all VLANs. MGMT uses pfSense Unbound as its primary DNS.",
|
||
why: "Putting Pi-hole in MGMT would require opening MGMT to all VLANs — a larger attack surface. DNS traffic crossing into Homelab VLAN is the lesser risk.",
|
||
},
|
||
{
|
||
title: "N100 for pfSense",
|
||
decision:
|
||
"Intel N100 mini PC: 4-core 3.4 GHz, ~6W idle. Handles 2–3 Gbps routing and 600–900 Mbps WireGuard.",
|
||
why: "Right-sized for 1 Gbps fiber with headroom. Raspberry Pi can't handle 1 Gbps + VPN. A full rack server wastes power for this role.",
|
||
},
|
||
{
|
||
title: "Shared Postgres + Redis in apps LXC",
|
||
decision:
|
||
"One Postgres instance, multiple databases. One Redis instance. A single init script provisions all schemas on first run.",
|
||
why: "Avoids 15 separate DB containers. Reduces RAM overhead significantly. All productivity apps share the same LXC (10.2.0.60).",
|
||
},
|
||
{
|
||
title:
|
||
"Gitea CI/CD: Self-hosted runner with container build + SSH rsync deploy",
|
||
decision:
|
||
"act_runner v0.3.1 on Gitea LXC (10.99.0.22). Push to dev → node:22-alpine container builds Next.js → rsync out/ to Portfolio LXC → SSH docker rebuild.",
|
||
why: "Keeps the full pipeline internal — no GitHub Actions, no external runners. Build runs in an isolated Alpine container so the Gitea LXC isn't polluted. Portfolio LXC (10.99.0.23) just serves pre-built static files via nginx.",
|
||
},
|
||
{
|
||
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.",
|
||
},
|
||
];
|
||
|
||
export default function HomelabPage() {
|
||
return (
|
||
<>
|
||
{/* Header */}
|
||
<div className="mb-4lh">
|
||
<p className="font-mono text-sm font-bold text-[var(--color-text)] mb-1lh">
|
||
<span
|
||
className="text-[var(--color-accent-green)] select-none mr-1ch"
|
||
aria-hidden="true"
|
||
>
|
||
❯
|
||
</span>
|
||
homelab
|
||
</p>
|
||
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed max-w-2xl opacity-80">
|
||
Personal infrastructure environment for learning, self-hosting, and
|
||
operational practice. Running 24/7 on production-grade hardware with
|
||
real network segmentation, SSO, monitoring, and IaC-style
|
||
documentation.
|
||
</p>
|
||
</div>
|
||
|
||
{/* At a Glance */}
|
||
<Widget title="homelab/overview" badge={glanceStats.length} as="section">
|
||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
|
||
{glanceStats.map(({ label, value }) => (
|
||
<div key={label} className="bg-[var(--color-surface)] px-2ch py-half-lh">
|
||
<p className="font-mono text-sm text-[var(--color-text-dim)] mb-half-lh">
|
||
{label}
|
||
</p>
|
||
<p className="font-mono text-sm text-[var(--color-text)]">
|
||
{value}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Widget>
|
||
|
||
{/* VLAN table */}
|
||
<Widget
|
||
title="homelab/network"
|
||
meta="8 isolated vlans · default deny inter-vlan"
|
||
as="section"
|
||
>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead>
|
||
<tr className="border-b border-[var(--color-border)]">
|
||
<th className="font-mono text-[var(--color-text-dim)] text-left py-qtr-lh pr-[3ch] uppercase">
|
||
VLAN
|
||
</th>
|
||
<th className="font-mono text-[var(--color-text-dim)] text-left py-qtr-lh pr-[3ch] uppercase">
|
||
Name
|
||
</th>
|
||
<th className="font-mono text-[var(--color-text-dim)] text-left py-qtr-lh pr-[3ch] uppercase">
|
||
Subnet
|
||
</th>
|
||
<th className="font-mono text-[var(--color-text-dim)] text-left py-qtr-lh uppercase">
|
||
Purpose
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{vlans.map((v) => (
|
||
<tr
|
||
key={v.id}
|
||
className="border-b border-[var(--color-border)] hover:bg-[var(--color-surface)]"
|
||
>
|
||
<td className="font-mono text-[var(--color-accent-green)] py-half-lh pr-[3ch]">
|
||
{v.id}
|
||
</td>
|
||
<td className="font-mono text-[var(--color-text)] py-half-lh pr-[3ch]">
|
||
{v.name}
|
||
</td>
|
||
<td className="font-mono text-[var(--color-text-label)] py-half-lh pr-[3ch]">
|
||
{v.subnet}
|
||
</td>
|
||
<td className="font-mono text-sm text-[var(--color-text)] py-2.5 opacity-80">
|
||
{v.purpose}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Widget>
|
||
|
||
{/* Services */}
|
||
<Widget title="homelab/services" badge={services.length} as="section">
|
||
<div className="flex flex-col gap-3ch">
|
||
{categoryOrder.map((cat) => {
|
||
const catServices = services.filter((s) => s.category === cat);
|
||
return (
|
||
<div key={cat}>
|
||
<p className="font-mono text-sm text-[var(--color-text-dim)] mb-1lh">
|
||
{categoryLabels[cat]}
|
||
</p>
|
||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
|
||
{catServices.map((svc) => (
|
||
<div
|
||
key={svc.name}
|
||
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start gap-1ch px-2ch py-half-lh"
|
||
>
|
||
<i
|
||
className={`${svc.icon} text-[var(--color-text-label)] text-xs mt-0.5 w-3.5 shrink-0`}
|
||
aria-hidden="true"
|
||
/>
|
||
<div>
|
||
<p className="font-mono text-sm text-[var(--color-text)] mb-0.5">
|
||
{svc.name}
|
||
</p>
|
||
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
|
||
{svc.description}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Widget>
|
||
|
||
{/* ADRs */}
|
||
<Widget
|
||
title="homelab/ADRs"
|
||
meta="why things are configured the way they are"
|
||
badge={adrs.length}
|
||
as="section"
|
||
>
|
||
<div className="flex flex-col gap-px bg-[var(--color-border)]">
|
||
{adrs.map((adr) => (
|
||
<div
|
||
key={adr.title}
|
||
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-2ch py-1lh"
|
||
>
|
||
<p className="font-mono text-sm text-[var(--color-text)] mb-1lh">
|
||
{adr.title}
|
||
</p>
|
||
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed mb-half-lh opacity-75">
|
||
<span className="text-[var(--color-text-label)] opacity-100">
|
||
decision:{" "}
|
||
</span>
|
||
{adr.decision}
|
||
</p>
|
||
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
|
||
<span className="text-[var(--color-text-label)] opacity-100">
|
||
why:{" "}
|
||
</span>
|
||
{adr.why}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Widget>
|
||
|
||
{/* Gitea CTA */}
|
||
<section className="pt-qtr-lh">
|
||
<p className="font-mono text-sm text-[var(--color-text-dim)] mb-half-lh">
|
||
homelab/docs
|
||
</p>
|
||
<p className="font-mono text-sm text-[var(--color-text)] mb-1lh opacity-75">
|
||
VLAN maps, runbooks, service registry, config exports, and setup
|
||
guides.
|
||
</p>
|
||
<a
|
||
href="https://gitea.lerkolabs.com/lerko/homelab"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
|
||
>
|
||
↗ gitea.lerkolabs.com/lerko/homelab
|
||
</a>
|
||
</section>
|
||
</>
|
||
);
|
||
}
|