Files
riotpiao.com/IMPLEMENTATION.md

32 KiB
Raw Permalink Blame History

Implementation: Homarr + Portfolio via Terraform

Phase 1: Portfolio Site (Local Dev)

1.1 Initialize Next.js project

cd ~/workplace/riotpiao
npm init -y
npm install next@15 react@19 react-dom@19 typescript tailwindcss postcss autoprefixer
npx tailwindcss init -p
npx tsc --init

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "strict": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules", ".next"]
}

next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  reactStrictMode: true,
};

module.exports = nextConfig;

package.json (key fields):

{
  "name": "riotpiao-portfolio",
  "version": "1.0.0",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint ."
  }
}

1.2 App Router structure

app/layout.tsx:

import "@/styles/globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";

export const metadata = {
  title: "Rock Liang — Portfolio & Demos",
  description: "Software engineer showcasing live demos and open-source projects.",
  openGraph: {
    title: "Rock Liang",
    description: "Portfolio and interactive demo platform",
    url: "https://portfolio.riotpiao.homelab.com",
    images: [
      {
        url: "https://portfolio.riotpiao.homelab.com/og-image.png",
        width: 1200,
        height: 630,
      },
    ],
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="bg-white text-gray-900 font-sans">
        <Header />
        <main className="min-h-screen">{children}</main>
        <Footer />
      </body>
    </html>
  );
}

app/page.tsx (landing):

import { projects } from "@/content/projects";
import ProjectCard from "@/components/ProjectCard";

export default function HomePage() {
  return (
    <>
      <section className="bg-gradient-to-r from-blue-600 to-blue-800 text-white py-20 px-6">
        <div className="max-w-4xl mx-auto">
          <h1 className="text-5xl font-bold mb-4">Rock Liang</h1>
          <p className="text-xl text-blue-100 mb-8">
            Software Engineer  Building scalable systems  Live demos & open source
          </p>
          <div className="flex gap-4">
            <a href="#projects" className="bg-white text-blue-600 px-6 py-3 rounded font-semibold hover:bg-gray-100">
              View Projects
            </a>
            <a href="/about" className="border border-white text-white px-6 py-3 rounded font-semibold hover:bg-white/10">
              About Me
            </a>
          </div>
        </div>
      </section>

      <section id="projects" className="max-w-5xl mx-auto px-6 py-20">
        <h2 className="text-3xl font-bold mb-12">Projects & Demos</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {projects.map(project => (
            <ProjectCard key={project.slug} project={project} />
          ))}
        </div>
      </section>
    </>
  );
}

app/about/page.tsx:

import { experience, skills } from "@/content/experience";

export default function AboutPage() {
  return (
    <div className="max-w-3xl mx-auto px-6 py-12">
      <h1 className="text-4xl font-bold mb-6">About Rock Liang</h1>

      <section className="prose prose-lg mb-12">
        <p className="text-gray-700 leading-relaxed mb-4">
          I'm a software engineer focused on building scalable, observable systems. 
          Currently exploring event-driven architecture, Kubernetes at scale, and developer 
          experience in the cloud-native ecosystem.
        </p>
      </section>

      <section className="mb-12">
        <h2 className="text-2xl font-bold mb-6">Skills</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          {Object.entries(skills).map(([category, items]) => (
            <div key={category}>
              <h3 className="font-semibold text-lg mb-2 capitalize">{category.replace(/_/g, " ")}</h3>
              <ul className="space-y-1">
                {items.map(skill => (
                  <li key={skill} className="text-gray-600">{skill}</li>
                ))}
              </ul>
            </div>
          ))}
        </div>
      </section>

      <section className="mb-12">
        <h2 className="text-2xl font-bold mb-6">Experience</h2>
        <div className="space-y-6">
          {experience.map((role, idx) => (
            <div key={idx} className="border-l-4 border-blue-500 pl-4">
              <h3 className="font-bold text-lg">{role.role}</h3>
              <p className="text-sm text-gray-600">{role.company}  {role.period}</p>
              <ul className="text-gray-700 mt-2 space-y-1">
                {role.highlights.map((h, i) => (
                  <li key={i} className="text-sm"> {h}</li>
                ))}
              </ul>
            </div>
          ))}
        </div>
      </section>
    </div>
  );
}

1.3 Components

components/Header.tsx:

export default function Header() {
  return (
    <header className="border-b border-gray-200 sticky top-0 bg-white/95 backdrop-blur">
      <nav className="max-w-5xl mx-auto px-6 py-4 flex justify-between items-center">
        <a href="/" className="text-2xl font-bold text-blue-600">
          riotpiao
        </a>
        <div className="flex gap-6">
          <a href="/" className="text-gray-700 hover:text-blue-600 font-medium">
            Projects
          </a>
          <a href="/about" className="text-gray-700 hover:text-blue-600 font-medium">
            About
          </a>
          <a href="https://github.com/riotpiaole" target="_blank" rel="noopener noreferrer" className="text-gray-700 hover:text-blue-600 font-medium">
            GitHub
          </a>
        </div>
      </nav>
    </header>
  );
}

components/Footer.tsx:

export default function Footer() {
  return (
    <footer className="border-t border-gray-200 bg-gray-50 py-8">
      <div className="max-w-5xl mx-auto px-6 text-center text-sm text-gray-600">
        <p>© 2025 Rock Liang. All rights reserved.</p>
        <p>
          <a href="https://github.com/riotpiaole" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600">
            GitHub
          </a>
          {" • "}
          <a href="mailto:[email protected]" className="hover:text-blue-600">
            Email
          </a>
        </p>
      </div>
    </footer>
  );
}

components/ProjectCard.tsx:

import { Project } from "@/content/projects";

export default function ProjectCard({ project }: { project: Project }) {
  return (
    <div className="border border-gray-200 rounded-lg p-6 hover:shadow-lg transition-shadow">
      <h3 className="text-lg font-bold mb-2">{project.title}</h3>
      <p className="text-gray-600 text-sm mb-4">{project.description}</p>

      <div className="flex flex-wrap gap-2 mb-4">
        {project.tags.map(tag => (
          <span key={tag} className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
            {tag}
          </span>
        ))}
      </div>

      <div className="flex gap-3">
        <a
          href={project.githubUrl}
          target="_blank"
          rel="noopener noreferrer"
          className="text-sm text-blue-600 hover:underline"
        >
          GitHub
        </a>

        {project.demoStatus === "live" && project.demoUrl ? (
          <a
            href={project.demoUrl}
            target="_blank"
            rel="noopener noreferrer"
            className="text-sm bg-green-500 text-white px-3 py-1 rounded hover:bg-green-600"
          >
            Live Demo
          </a>
        ) : (
          <button
            disabled
            className="text-sm bg-gray-300 text-gray-500 px-3 py-1 rounded cursor-not-allowed"
          >
            Coming Soon
          </button>
        )}
      </div>
    </div>
  );
}

1.4 Content

content/projects.ts:

export interface Project {
  slug: string;
  title: string;
  description: string;
  tags: string[];
  githubUrl: string;
  demoUrl?: string;
  demoStatus: "live" | "coming-soon";
}

export const projects: Project[] = [
  {
    slug: "rest-api-demo",
    title: "REST API Demo",
    description: "Go-based REST API with OpenAPI docs and rate limiting.",
    tags: ["REST API", "Go", "Docker", "Kubernetes"],
    githubUrl: "https://github.com/riotpiaole/rest-api-demo",
    demoStatus: "coming-soon",
  },
  {
    slug: "auth-demo",
    title: "Auth System Demo",
    description: "OIDC/OAuth2 flow with Authentik integration.",
    tags: ["Auth", "OIDC", "Authentik", "Go"],
    githubUrl: "https://github.com/riotpiaole/auth-demo",
    demoStatus: "coming-soon",
  },
  {
    slug: "monitoring-stack",
    title: "Monitoring Stack Demo",
    description: "Prometheus + Grafana + Loki in a sandbox environment.",
    tags: ["Monitoring", "Prometheus", "Grafana", "Observability"],
    githubUrl: "https://github.com/riotpiaole/monitoring-demo",
    demoStatus: "coming-soon",
  },
];

content/experience.ts:

export const skills = {
  languages: ["Go", "TypeScript", "Rust"],
  infrastructure: ["Kubernetes", "Talos", "Helm", "Terraform"],
  observability: ["Prometheus", "Grafana", "Loki", "Temporal"],
  architecture: ["Event-Driven", "Microservices", "OIDC/Security"],
};

export const experience = [
  {
    role: "Infrastructure Engineer",
    company: "Personal Homelab",
    period: "2024Present",
    highlights: [
      "Designed and deployed a 3-node bare-metal Kubernetes cluster (Talos)",
      "Integrated Authentik OIDC, Vault, and event-driven patterns",
      "Built demo platform for skill showcase (this site)",
    ],
  },
];

styles/globals.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

html {
  scroll-behavior: smooth;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

.prose h1,
.prose h2,
.prose h3 {
  @apply font-bold;
}

1.5 Build & test locally

cd ~/workplace/riotpiao
npm run build
npm run start
# Open http://localhost:3000 — verify landing + about pages render

Phase 2: Docker Image

Dockerfile (multi-stage):

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV NEXT_TELEMETRY_DISABLED=1

CMD ["node", "server.js"]

.dockerignore:

.git
.gitignore
.next
node_modules
npm-debug.log
README.md
.env*
.DS_Store

build.sh:

#!/bin/bash
set -euo pipefail

REGISTRY="forgejo.riotpiao.homelab.com"
IMAGE_NAME="rock/portfolio"
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:latest"

echo "Building image: ${FULL_IMAGE}"
docker buildx build --platform linux/amd64 -t "${FULL_IMAGE}" -f Dockerfile .

echo "Authenticating to Forgejo..."
# Get token (adjust based on your secret storage)
REGISTRY_TOKEN=$(cat ~/.docker/config.json | jq -r '.auths."forgejo.riotpiao.homelab.com".auth' | base64 -d | cut -d: -f2)
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u ci-bot --password-stdin

echo "Pushing image..."
docker push "${FULL_IMAGE}"

echo "✓ Complete: ${FULL_IMAGE}"
chmod +x build.sh
./build.sh
docker run -p 3000:3000 forgejo.riotpiao.homelab.com/rock/portfolio:latest
# Verify http://localhost:3000 works

Phase 3: Helm Chart (for Kubernetes)

k8s/portfolio/Chart.yaml (in homelab repo):

apiVersion: v2
name: portfolio
description: Rock Liang personal portfolio site
type: application
version: 1.0.0
appVersion: "1.0"

k8s/portfolio/values.yaml:

replicaCount: 2

image:
  repository: forgejo.riotpiao.homelab.com/rock/portfolio
  pullPolicy: IfNotPresent
  tag: latest

service:
  type: ClusterIP
  port: 3000

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

nodeSelector: {}
tolerations: []
affinity: {}

k8s/portfolio/templates/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "portfolio.fullname" . }}
  labels:
    {{ include "portfolio.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{ include "portfolio.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{ include "portfolio.selectorLabels" . | nindent 8 }}
    spec:
      containers:
      - name: {{ .Chart.Name }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        imagePullPolicy: {{ .Values.image.pullPolicy }}
        ports:
        - name: http
          containerPort: 3000
          protocol: TCP
        livenessProbe:
          httpGet:
            path: /
            port: http
          initialDelaySeconds: 10
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /
            port: http
          initialDelaySeconds: 5
          periodSeconds: 5
        resources:
          {{ toYaml .Values.resources | nindent 10 }}

k8s/portfolio/templates/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "portfolio.fullname" . }}
  labels:
    {{ include "portfolio.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
  - port: {{ .Values.service.port }}
    targetPort: http
    protocol: TCP
    name: http
  selector:
    {{ include "portfolio.selectorLabels" . | nindent 4 }}

k8s/portfolio/templates/_helpers.tpl:

{{- define "portfolio.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "portfolio.labels" -}}
helm.sh/chart: {{ include "portfolio.chart" . }}
{{ include "portfolio.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "portfolio.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{- define "portfolio.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

Phase 4: Terraform Infrastructure

Create in homelab repo (or new homelab-terraform/ directory). Authentik already running at iam namespace — just consume it.

main.tf:

terraform {
  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.25"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.12"
    }
  }

  backend "local" {
    path = "terraform.tfstate"
  }
}

provider "kubernetes" {
  host                   = var.cluster_endpoint
  cluster_ca_certificate = base64decode(var.cluster_ca_cert)
  token                  = var.cluster_token
}

provider "helm" {
  kubernetes {
    host                   = var.cluster_endpoint
    cluster_ca_certificate = base64decode(var.cluster_ca_cert)
    token                  = var.cluster_token
  }
}

module "portfolio" {
  source = "./modules/portfolio"

  portfolio_image_repo = var.portfolio_image_repo
  portfolio_image_tag  = var.portfolio_image_tag
  domain               = var.domain
}

module "homarr" {
  source = "./modules/homarr"

  homarr_version                = var.homarr_version
  homarr_oidc_client_secret     = var.homarr_oidc_client_secret
  domain                        = var.domain
}

module "ingress_protected" {
  source = "./modules/ingress"

  protected_services = var.protected_services
  domain             = var.domain
}

module "network_policy" {
  source = "./modules/network-policy"

  isolated_services = var.isolated_services
}

variables.tf:

variable "cluster_endpoint" {
  type        = string
  description = "Kubernetes API server endpoint"
}

variable "cluster_ca_cert" {
  type        = string
  sensitive   = true
  description = "Cluster CA certificate (base64 encoded)"
}

variable "cluster_token" {
  type        = string
  sensitive   = true
  description = "Kubernetes API token"
}

variable "domain" {
  type        = string
  default     = "riotpiao.homelab.com"
  description = "Base domain for all services"
}

variable "portfolio_image_repo" {
  type        = string
  default     = "forgejo.riotpiao.homelab.com/rock/portfolio"
  description = "Portfolio image repository"
}

variable "portfolio_image_tag" {
  type        = string
  default     = "latest"
  description = "Portfolio image tag"
}

variable "homarr_version" {
  type        = string
  default     = "~1"
  description = "Homarr Helm chart version"
}

variable "homarr_oidc_client_secret" {
  type        = string
  sensitive   = true
  description = "Homarr OIDC client secret from Authentik"
}

variable "protected_services" {
  type = map(object({
    host         = string
    namespace    = string
    service_name = string
    service_port = number
  }))

  default = {
    longhorn = {
      host         = "longhorn.riotpiao.homelab.com"
      namespace    = "longhorn-system"
      service_name = "longhorn-frontend"
      service_port = 80
    }
    portainer = {
      host         = "portainer.riotpiao.homelab.com"
      namespace    = "dashboard"
      service_name = "portainer"
      service_port = 9000
    }
    prometheus = {
      host         = "prometheus.riotpiao.homelab.com"
      namespace    = "monitoring"
      service_name = "prometheus-operated"
      service_port = 9090
    }
  }
}

variable "isolated_services" {
  type = map(object({
    namespace    = string
    pod_selector = map(string)
  }))

  default = {
    longhorn = {
      namespace = "longhorn-system"
      pod_selector = {
        "app.kubernetes.io/name" = "longhorn"
      }
    }
    portainer = {
      namespace = "dashboard"
      pod_selector = {
        app = "portainer"
      }
    }
    prometheus = {
      namespace    = "monitoring"
      pod_selector = {
        "app.kubernetes.io/name" = "prometheus"
      }
    }
  }
}

outputs.tf:

output "portfolio_url" {
  value = "https://portfolio.${var.domain}"
}

output "homarr_url" {
  value = "https://homarr.${var.domain}"
}

output "protected_services" {
  value = {
    for name, config in var.protected_services :
    name => "https://${config.host}"
  }
}

4.1 Portfolio module

modules/portfolio/main.tf:

resource "kubernetes_namespace" "portfolio" {
  metadata {
    name = "portfolio"
    labels = {
      "managed_by" = "terraform"
    }
  }
}

resource "helm_release" "portfolio" {
  name             = "portfolio"
  namespace        = kubernetes_namespace.portfolio.metadata[0].name
  chart            = var.chart_path != null ? var.chart_path : "${path.root}/k8s/portfolio"
  create_namespace = false

  set {
    name  = "image.repository"
    value = var.portfolio_image_repo
  }

  set {
    name  = "image.tag"
    value = var.portfolio_image_tag
  }

  set {
    name  = "replicaCount"
    value = 2
  }

  depends_on = [kubernetes_namespace.portfolio]
}

resource "kubernetes_ingress_v1" "portfolio" {
  metadata {
    name      = "portfolio"
    namespace = kubernetes_namespace.portfolio.metadata[0].name
    annotations = {
      "cert-manager.io/cluster-issuer" = "letsencrypt-prod"
    }
  }

  spec {
    ingress_class_name = "nginx"
    tls {
      hosts       = ["portfolio.${var.domain}"]
      secret_name = "portfolio-tls"
    }
    rule {
      host = "portfolio.${var.domain}"
      http {
        path {
          path      = "/"
          path_type = "Prefix"
          backend {
            service {
              name = "portfolio-portfolio"
              port {
                number = 3000
              }
            }
          }
        }
      }
    }
  }

  depends_on = [helm_release.portfolio]
}

modules/portfolio/variables.tf:

variable "portfolio_image_repo" {
  type = string
}

variable "portfolio_image_tag" {
  type = string
}

variable "domain" {
  type = string
}

variable "chart_path" {
  type        = string
  default     = null
  description = "Path to Helm chart (null = use k8s/portfolio relative to repo root)"
}

modules/portfolio/outputs.tf:

output "namespace" {
  value = kubernetes_namespace.portfolio.metadata[0].name
}

output "service_url" {
  value = "https://portfolio.${var.domain}"
}

4.2 Homarr module

modules/homarr/main.tf:

resource "kubernetes_namespace" "homarr" {
  metadata {
    name = "dashboard"
    labels = {
      "managed_by" = "terraform"
      "reloader"   = "enabled"
    }
  }
}

resource "kubernetes_secret" "homarr_oidc" {
  metadata {
    name      = "homarr-oidc"
    namespace = kubernetes_namespace.homarr.metadata[0].name
  }

  data = {
    AUTH_OIDC_CLIENT_SECRET = var.homarr_oidc_client_secret
  }

  type = "Opaque"
}

resource "helm_release" "homarr" {
  name       = "homarr"
  namespace  = kubernetes_namespace.homarr.metadata[0].name
  chart      = "homarr"
  repository = "https://homarr-labs.github.io/charts/"
  version    = var.homarr_version

  set {
    name  = "env.AUTH_PROVIDERS"
    value = "oidc"
  }

  set {
    name  = "env.AUTH_OIDC_CLIENT_ID"
    value = "homarr"
  }

  set {
    name  = "env.AUTH_OIDC_ISSUER"
    value = "https://authentik.${var.domain}/application/o/homarr/"
  }

  set {
    name  = "env.AUTH_OIDC_URI"
    value = "https://authentik.${var.domain}/application/o/homarr/.well-known/openid-configuration"
  }

  set {
    name  = "env.AUTH_OIDC_GROUPS_ATTRIBUTE"
    value = "groups"
  }

  set {
    name  = "envFrom[0].secretRef.name"
    value = kubernetes_secret.homarr_oidc.metadata[0].name
  }

  set {
    name  = "persistence.enabled"
    value = "true"
  }

  set {
    name  = "persistence.storageClass"
    value = "longhorn"
  }

  set {
    name  = "persistence.size"
    value = "2Gi"
  }

  depends_on = [kubernetes_secret.homarr_oidc]
}

resource "kubernetes_ingress_v1" "homarr" {
  metadata {
    name      = "homarr"
    namespace = kubernetes_namespace.homarr.metadata[0].name
    annotations = {
      "cert-manager.io/cluster-issuer" = "letsencrypt-prod"
    }
  }

  spec {
    ingress_class_name = "nginx"
    tls {
      hosts       = ["homarr.${var.domain}"]
      secret_name = "homarr-tls"
    }
    rule {
      host = "homarr.${var.domain}"
      http {
        path {
          path      = "/"
          path_type = "Prefix"
          backend {
            service {
              name = helm_release.homarr.name
              port {
                number = 3000
              }
            }
          }
        }
      }
    }
  }

  depends_on = [helm_release.homarr]
}

modules/homarr/variables.tf:

variable "homarr_version" {
  type = string
}

variable "homarr_oidc_client_secret" {
  type      = string
  sensitive = true
}

variable "domain" {
  type = string
}

modules/homarr/outputs.tf:

output "namespace" {
  value = kubernetes_namespace.homarr.metadata[0].name
}

output "service_url" {
  value = "https://homarr.${var.domain}"
}

4.3 Ingress module

modules/ingress/main.tf:

resource "kubernetes_ingress_v1" "protected" {
  for_each = var.protected_services

  metadata {
    name      = each.key
    namespace = each.value.namespace
    annotations = {
      "nginx.ingress.kubernetes.io/auth-url"             = "http://authentik-server.iam.svc.cluster.local/outpost.goauthentik.io/auth/nginx"
      "nginx.ingress.kubernetes.io/auth-signin"          = "https://authentik.${var.domain}/outpost.goauthentik.io/start?rd=$scheme://$http_host$escaped_request_uri"
      "nginx.ingress.kubernetes.io/auth-response-headers" = "Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid"
      "cert-manager.io/cluster-issuer"                   = "letsencrypt-prod"
    }
  }

  spec {
    ingress_class_name = "nginx"
    tls {
      hosts       = [each.value.host]
      secret_name = "${each.key}-tls"
    }
    rule {
      host = each.value.host
      http {
        path {
          path      = "/"
          path_type = "Prefix"
          backend {
            service {
              name = each.value.service_name
              port {
                number = each.value.service_port
              }
            }
          }
        }
      }
    }
  }
}

modules/ingress/variables.tf:

variable "protected_services" {
  type = map(object({
    host         = string
    namespace    = string
    service_name = string
    service_port = number
  }))
}

variable "domain" {
  type = string
}

4.4 NetworkPolicy module

modules/network-policy/main.tf:

resource "kubernetes_network_policy" "backend_isolation" {
  for_each = var.isolated_services

  metadata {
    name      = "${each.key}-ingress-only"
    namespace = each.value.namespace
  }

  spec {
    pod_selector {
      match_labels = each.value.pod_selector
    }
    policy_types = ["Ingress"]
    ingress {
      from {
        namespace_selector {
          match_labels = {
            name = "ingress-nginx"
          }
        }
      }
    }
  }
}

modules/network-policy/variables.tf:

variable "isolated_services" {
  type = map(object({
    namespace    = string
    pod_selector = map(string)
  }))
}

Phase 5: Authentik Setup (Python)

k8s/talos-iam/register_proxy_app.py (new):

#!/usr/bin/env python3
import argparse
import json
import os
import requests
from typing import Optional

def _request(method: str, path: str, data: dict = None) -> dict:
    """Helper: authenticate and make Authentik API request."""
    base_url = os.getenv("AUTHENTIK_URL", "https://authentik.riotpiao.homelab.com")
    token = os.getenv("AUTHENTIK_TOKEN")
    if not token:
        raise ValueError("AUTHENTIK_TOKEN not set")
    
    url = f"{base_url}/api/v3/{path}"
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    
    if method == "GET":
        resp = requests.get(url, headers=headers)
    elif method == "POST":
        resp = requests.post(url, headers=headers, json=data)
    elif method == "PATCH":
        resp = requests.patch(url, headers=headers, json=data)
    else:
        raise ValueError(f"Unsupported method: {method}")
    
    resp.raise_for_status()
    return resp.json() if resp.content else {}

def get_or_create_group(name: str) -> dict:
    """Get or create group."""
    resp = _request("GET", f"core/groups/?name={name}")
    if resp["results"]:
        return resp["results"][0]
    
    group = _request("POST", "core/groups/", {"name": name})
    print(f"✓ Created group: {name}")
    return group

def register_proxy_app(
    service_name: str,
    namespace: str,
    external_host: str,
    group_name: str,
):
    """Register Proxy Provider + Application."""
    
    # 1. Get embedded outpost
    outpost = _request("GET", "outposts/instances/?name=embedded")
    if not outpost["results"]:
        raise ValueError("Embedded outpost not found")
    outpost_pk = outpost["results"][0]["pk"]
    print(f"Found outpost PK: {outpost_pk}")
    
    # 2. Create Proxy Provider
    provider = _request("POST", "providers/proxy/", {
        "name": service_name,
        "mode": "forward_single",
        "external_host": external_host,
    })
    print(f"✓ Created Proxy Provider: {service_name}")
    
    # 3. Create Application
    _request("POST", "core/applications/", {
        "name": service_name,
        "slug": service_name,
        "provider": provider["pk"],
    })
    print(f"✓ Created Application: {service_name}")
    
    # 4. Get/create group
    group = get_or_create_group(group_name)
    
    # 5. Bind group to provider
    _request("POST", f"providers/proxy/{provider['pk']}/bind/", {
        "group": group["pk"],
    })
    print(f"✓ Bound group {group_name} to provider {service_name}")
    
    # 6. Add provider to outpost
    outpost_data = _request("GET", f"outposts/instances/{outpost_pk}/")
    current_providers = outpost_data.get("providers", [])
    if provider["pk"] not in current_providers:
        current_providers.append(provider["pk"])
        _request("PATCH", f"outposts/instances/{outpost_pk}/", {
            "providers": current_providers,
        })
        print(f"✓ Added provider to outpost")

def main():
    parser = argparse.ArgumentParser(description="Register Authentik Proxy Provider")
    parser.add_argument("--service-name", required=True)
    parser.add_argument("--namespace", required=True)
    parser.add_argument("--external-host", required=True)
    parser.add_argument("--add-group", default="infra-admins")
    
    args = parser.parse_args()
    
    try:
        register_proxy_app(
            service_name=args.service_name,
            namespace=args.namespace,
            external_host=args.external_host,
            group_name=args.add_group,
        )
        print(f"\n✓ Proxy app registered: {args.service_name}")
    except Exception as e:
        print(f"✗ Error: {e}")
        exit(1)

if __name__ == "__main__":
    main()
chmod +x k8s/talos-iam/register_proxy_app.py

# Set token first
export AUTHENTIK_TOKEN="your_api_token_from_authentik"

# Register protected services
python3 k8s/talos-iam/register_proxy_app.py \
  --service-name longhorn \
  --namespace longhorn-system \
  --external-host longhorn.riotpiao.homelab.com \
  --add-group infra-admins

python3 k8s/talos-iam/register_proxy_app.py \
  --service-name portainer \
  --namespace dashboard \
  --external-host portainer.riotpiao.homelab.com \
  --add-group infra-admins

python3 k8s/talos-iam/register_proxy_app.py \
  --service-name prometheus \
  --namespace monitoring \
  --external-host prometheus.riotpiao.homelab.com \
  --add-group infra-admins

Phase 6: Deploy with Terraform

terraform.tfvars.example:

cluster_endpoint          = "https://10.0.0.1:6443"  # Replace
cluster_ca_cert           = "LS0tLS1CRUdJTi..."      # From kubeconfig
cluster_token             = "eyJhbGc..."             # Service account token
domain                    = "riotpiao.homelab.com"
portfolio_image_repo      = "forgejo.riotpiao.homelab.com/rock/portfolio"
portfolio_image_tag       = "latest"
homarr_version            = "~1"
homarr_oidc_client_secret = "from_authentik"
cp terraform.tfvars.example terraform.tfvars
# Edit with actual values

terraform init
terraform plan
terraform apply -auto-approve

Phase 7: Verification

# Portfolio accessible, no auth
curl -I https://portfolio.riotpiao.homelab.com
# Expected: 200 OK

# Homarr OIDC redirect
curl -I https://homarr.riotpiao.homelab.com
# Expected: 302 or 200 with Authentik sign-in form

# Protected services redirect
curl -I https://longhorn.riotpiao.homelab.com
# Expected: 302 to Authentik sign-in

# Check rollouts
kubectl rollout status deploy/portfolio -n portfolio
kubectl rollout status deploy/homarr -n dashboard

# Verify NetworkPolicy
kubectl describe networkpolicy longhorn-ingress-only -n longhorn-system

Browse:

  • https://portfolio.riotpiao.homelab.com — landing + about pages
  • https://homarr.riotpiao.homelab.com — OIDC login
  • After Homarr login, create boards/tiles pointing to protected services
  • Click tile → redirects to service, Authentik intercepts, login required

Success Criteria

  • Portfolio site builds, runs locally
  • Docker image pushes to Forgejo registry
  • Terraform init, plan, apply succeed
  • Portfolio live at https://portfolio.riotpiao.homelab.com (no auth)
  • Homarr live at https://homarr.riotpiao.homelab.com (OIDC login)
  • Protected services (Longhorn/Portainer/Prometheus) require Authentik login
  • Homarr boards render, tiles link to protected services
  • All resources created by Terraform (kubectl shows labels)