diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 0307d475..9da927b2 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -3,45 +3,40 @@ const year = new Date().getFullYear(); --- diff --git a/src/components/Hero.astro b/src/components/Hero.astro index c5373360..b5b8acc5 100644 --- a/src/components/Hero.astro +++ b/src/components/Hero.astro @@ -1,71 +1,50 @@ -
-
-
-

- - tyler koenig -

-

- Security Operations · Self-Hosted Infrastructure -

-
+--- +import { services } from "@/data/services"; +--- -

- Homelab runs 30+ - services across segmented VLANs — pfSense, Authentik SSO, full - observability stack. Write software too: mobile apps, Go backends, - open protocols. Daily drivers, all of it.{" "} - -

+
+

Tyler Koenig

+

+ Security Operations · Self-Hosted Infrastructure +

- -
+

+ Homelab runs {services.length} services across segmented VLANs — pfSense, Authentik SSO, + full observability stack. Write software too: mobile apps, Go backends, open + protocols. +

+ +
diff --git a/src/components/Nav.astro b/src/components/Nav.astro index e4350b4b..fb1eb5dd 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -1,73 +1,89 @@ ---- -const pathname = Astro.url.pathname; - -const links = [ - { href: "/", label: "tyler" }, - { href: "/homelab/", label: "homelab" }, - { href: "/projects/", label: "projects" }, -]; ---- - -
+
diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro index 8aa30a62..9bda1e67 100644 --- a/src/components/ProjectCard.astro +++ b/src/components/ProjectCard.astro @@ -8,60 +8,28 @@ interface Props { const { project } = Astro.props; --- -
-
- - {project.title} - -
- {project.stats && ( - - {project.stats} - - )} - {project.externalUrl && ( - - ↗ - - )} +
- {project.statusBadge && ( - - {project.statusBadge} - - )} - -

+

{project.description}

-
- {project.tags.map((tag) => ( - - {tag} - - ))} -
+

+ {project.tags.join(" · ")} +

diff --git a/src/components/Skills.astro b/src/components/Skills.astro index a07167b5..10be9f57 100644 --- a/src/components/Skills.astro +++ b/src/components/Skills.astro @@ -27,14 +27,14 @@ const skillGroups = [ const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0); --- - +
{skillGroups.map(({ label, skills }) => (
- + {label} - + {skills.join(" · ")}
diff --git a/src/components/Timeline.astro b/src/components/Timeline.astro index a8c43395..ba663309 100644 --- a/src/components/Timeline.astro +++ b/src/components/Timeline.astro @@ -2,13 +2,7 @@ import Widget from "./Widget.astro"; 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 isDate = (d: string) => /^\d{4}/.test(d); const typeLabel: Record = { career: "career", @@ -19,70 +13,26 @@ const typeLabel: Record = { }; --- - -
    + +
      {timeline.map((entry) => ( -
    1. -
    2. +
      + {isDate(entry.date) + ? + : {entry.date} + } +
      +
      +

      + {entry.title} + · {typeLabel[entry.type]} +

      +

      + {entry.description} +

      - -

      - {entry.title} -

      - -

      - {entry.description} -

      - - {entry.tags && entry.tags.length > 0 && ( -
      - {entry.tags.map((tag) => ( - - {tag} - - ))} -
      - )}
    3. ))}
    - - diff --git a/src/components/Widget.astro b/src/components/Widget.astro index 0e66c652..cd2a6e44 100644 --- a/src/components/Widget.astro +++ b/src/components/Widget.astro @@ -8,22 +8,18 @@ interface Props { } 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}] - )} +
    +

    + {title} + {badge !== undefined && ( + ({badge}) + )} +

    {meta && ( - — {meta} +

    {meta}

    )}
    diff --git a/src/data/projects.ts b/src/data/projects.ts index bcfbcdd4..bd40ebc9 100644 --- a/src/data/projects.ts +++ b/src/data/projects.ts @@ -13,6 +13,25 @@ export type Project = { export const projects: Project[] = [ // --- Featured --- + { + slug: "uptop", + title: "uptop", + description: "Live uptime monitoring dashboard for your terminal. SSH-accessible. HTTP, ping, TCP, DNS, push checks with alerts, clustering, and Prometheus metrics.", + tags: ["Go", "Bubbletea", "Monitoring", "Uptime"], + githubUrl: "https://github.com/lerkolabs/uptop", + tier: "featured", + year: 2026, + }, + { + slug: "nib", + title: "nib", + description: + "Capture-first personal journal built with Go + SQLite. Currently developing in private when I have spare time.", + tags: ["Go", "JavaScript", "SQLite", "Stream-of-Thought"], + githubUrl: "https://gitea.lerkolabs.com/lerko/nib-v1", + tier: "featured", + year: 2026, + }, { slug: "homelab", title: "homelab", @@ -27,33 +46,22 @@ export const projects: Project[] = [ slug: "portfolio", title: "portfolio", description: - "Astro static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.", - tags: ["Astro", "Dockerfile", "Tailwind", "nginx", "Caddy"], + "Astro static site, self-hosted in a DMZ LXC behind Caddy, deployed via Gitea Actions CI.", + tags: ["Astro", "Typescript", "Dockerfile", "Caddy"], githubUrl: "https://gitea.lerkolabs.com/lerko/portfolio", tier: "featured", year: 2021, }, - { - slug: "nib", - title: "nib", - description: - "Capture-first personal journal built with Go + React + SQLite. Currently developing in private when I have spare time.", - tags: ["Go", "React", "SQLite", "Journal", "Stream-of-Thought"], - githubUrl: "https://github.com/lerko96/nib", - tier: "featured", - year: 2026, - }, + // --- Archive --- { slug: "open-pact", title: "open-pact", - description: - "Open protocol for AI agent identity, delegation, and portable memory. Ed25519 keypair identity, signed delegation warrants, portable signed memory facts. No central registry.", + description: "Open protocol for AI agent identity, delegation, and portable memory. Ed25519 keypair identity, signed delegation", tags: ["TypeScript", "Ed25519", "DID", "npm", "CC0"], githubUrl: "https://github.com/lerko96/open-pact", - tier: "featured", + tier: "archive", year: 2026, }, - // --- Archive --- { slug: "helm", title: "helm", diff --git a/src/data/services.ts b/src/data/services.ts index 9e8d9677..59a140aa 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -2,6 +2,7 @@ export type Service = { name: string; description: string; category: "infrastructure" | "security" | "monitoring" | "productivity" | "media"; + hidden?: boolean; }; export const services: Service[] = [ @@ -11,7 +12,7 @@ export const services: Service[] = [ { 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: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure", hidden: true }, { name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure" }, // Security / Auth @@ -23,34 +24,36 @@ export const services: Service[] = [ { 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" }, + { name: "Uptime Kuma", description: "Self-hosted monitoring tool (GUI)", category: "monitoring" }, // Productivity { 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" }, - + { name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity", hidden: true }, + { name: "FreshRSS", description: "RSS reader", category: "productivity", hidden: true }, + { name: "Memos", description: "Quick notes and journal", category: "productivity", hidden: true }, + { name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity", hidden: true }, + { name: "Vikunja", description: "Task management", category: "productivity", hidden: true }, + { name: "Traggo", description: "Time tracking", category: "productivity", hidden: true }, + { name: "Filebrowser", description: "Web-based file manager", category: "productivity", hidden: true }, + // Media - { 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: "Immich", description: "Open-source photo library", category: "media"}, { name: "Kavita", description: "Self-hosted manga and book reader", category: "media" }, - { name: "Openshelf", description: "Book library with auto-ingest", category: "media" }, + { name: "Jellyfin", description: "Open-source media streaming", category: "media" }, + { name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media" }, + { name: "*Arr stack", description: "Automated media management for streaming", category: "media" }, + { name: "Bazarr", description: "Automatic subtitle download and management", category: "media" }, + { name: "Lidarr", description: "Automated music management", category: "media", hidden: true }, + { name: "Radarr", description: "Automated movie management", category: "media", hidden: true }, + { name: "Plex", description: "Media streaming — movies, TV, music", category: "media", hidden: true }, + { name: "nzbget", description: "Usenet downloader", category: "media", hidden: true }, + { name: "qBittorrent", description: "Torrent client with web UI", category: "media", hidden: true }, + { name: "Openshelf", description: "Book library with auto-ingest", category: "media", hidden: true }, ]; export const categoryOrder: Service["category"][] = [ diff --git a/src/data/timeline.ts b/src/data/timeline.ts index a6d1914b..97df8ca2 100644 --- a/src/data/timeline.ts +++ b/src/data/timeline.ts @@ -22,30 +22,6 @@ export const timeline: TimelineEntry[] = [ "Studying for Network+ to formalize networking knowledge built through the homelab.", tags: ["networking", "certification"], }, - { - date: "2026-04", - title: "Portfolio Site v2", - type: "project", - description: - "Astro portfolio site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.", - tags: ["astro", "tailwind", "self-hosted"], - }, - { - date: "2026-04", - title: "lerkolabs.com", - type: "homelab", - description: - "Self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.", - tags: ["LXC", "DMZ", "self-hosted"], - }, - { - date: "2026-03", - title: "Helm", - type: "project", - description: - "Full-stack task and project management tool built in Go + React.", - tags: ["go", "react", "typescript"], - }, { date: "2025", title: "Proxmox Backup Server", @@ -93,13 +69,6 @@ export const timeline: TimelineEntry[] = [ "Promoted to Config Tech II. Led imaging workflows and expanded into scripting for endpoint provisioning.", tags: ["sysadmin", "scripting"], }, - { - date: "2022-11", - title: "PC Build", - type: "homelab", - description: "Sourced parts online and built a personal computer.", - tags: ["amd", "windows 10", "configure", "desktop"], - }, { date: "2022-05", title: "Config Tech I — MCPc", @@ -108,14 +77,6 @@ export const timeline: TimelineEntry[] = [ "Hardware configuration, OS imaging, and deployment at scale for enterprise clients.", tags: ["sysadmin", "hardware"], }, - { - date: "2021-10", - title: "Portfolio Site v1", - type: "project", - description: - "React portfolio deployed to www.lerko96.github.io using github pages.", - tags: ["React", "CSS", "github pages"], - }, { date: "2021-01", title: "We Can Code IT — Java Bootcamp", diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 39d32349..5e9c8d90 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -6,7 +6,7 @@ interface Props { const { title = "Tyler Koenig", - description = "SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.", + description = "Security operations, self-hosted infrastructure, and software projects. Homelab, Go, TypeScript, and more.", } = Astro.props; --- @@ -27,12 +27,10 @@ const { -
    +
    diff --git a/src/pages/archive.astro b/src/pages/archive.astro index 987bbc6c..285e4ea0 100644 --- a/src/pages/archive.astro +++ b/src/pages/archive.astro @@ -2,11 +2,11 @@ - - + + Redirecting... -

    This page moved. /projects/

    +

    This page moved. /#projects

    diff --git a/src/pages/homelab.astro b/src/pages/homelab.astro index 72784006..8dcb1bc5 100644 --- a/src/pages/homelab.astro +++ b/src/pages/homelab.astro @@ -1,237 +1,12 @@ ---- -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.", - }, -]; ---- - - -