Files
riotpiao.com/IMPLEMENTATION.md

1349 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Implementation: Homarr + Portfolio via Terraform
## Phase 1: Portfolio Site (Local Dev)
### 1.1 Initialize Next.js project
```bash
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`:**
```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`:**
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
};
module.exports = nextConfig;
```
**`package.json` (key fields):**
```json
{
"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`:**
```typescript
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):**
```typescript
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`:**
```typescript
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`:**
```typescript
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`:**
```typescript
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`:**
```typescript
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`:**
```typescript
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`:**
```typescript
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`:**
```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
```bash
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):**
```dockerfile
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`:**
```bash
#!/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}"
```
```bash
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):**
```yaml
apiVersion: v2
name: portfolio
description: Rock Liang personal portfolio site
type: application
version: 1.0.0
appVersion: "1.0"
```
**`k8s/portfolio/values.yaml`:**
```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`:**
```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`:**
```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`:**
```hcl
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`:**
```hcl
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`:**
```hcl
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`:**
```hcl
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`:**
```hcl
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`:**
```hcl
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`:**
```hcl
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`:**
```hcl
variable "homarr_version" {
type = string
}
variable "homarr_oidc_client_secret" {
type = string
sensitive = true
}
variable "domain" {
type = string
}
```
**`modules/homarr/outputs.tf`:**
```hcl
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`:**
```hcl
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`:**
```hcl
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`:**
```hcl
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`:**
```hcl
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):
```python
#!/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()
```
```bash
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`:**
```hcl
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"
```
```bash
cp terraform.tfvars.example terraform.tfvars
# Edit with actual values
terraform init
terraform plan
terraform apply -auto-approve
```
---
## Phase 7: Verification
```bash
# 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)