1349 lines
32 KiB
Markdown
1349 lines
32 KiB
Markdown
# 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: "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`:**
|
||
```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)
|