32 KiB
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: "2024–Present",
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 pageshttps://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,applysucceed - 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)