Files
riotpiao.com/PLAN.md
T

14 KiB

Homelab Auth + Portfolio: Authentik Forward-Auth + Homarr SSO + Portfolio Site (Terraform)

Context

Three services (Longhorn, Portainer, Prometheus) currently zero-auth. Goal: rebuild with Authentik forward-auth + Homarr dashboard + personal portfolio site. Infrastructure as Terraform (not Helm).

Authentik bootstrap (Proxy Providers, groups) via Python helper. Homarr + Portfolio deployed as K8s manifests via Terraform modules.


Track A — Authentik Forward-Auth (Setup via Python, Ingress via TF)

Authentik cluster already running (deployed separately). Python helper script registers Proxy Providers + groups:

k8s/talos-iam/register_proxy_app.py (mirrors register_oauth_app.py):

  • Queries embedded outpost, creates /providers/proxy/ (mode: forward_single)
  • Creates /core/applications/ entry
  • Creates/binds Authentik group
  • Patches outpost providers list
python3 k8s/talos-iam/register_proxy_app.py \
  --service-name longhorn --namespace longhorn-system \
  --external-host longhorn.riotpiao.homelab.com \
  --add-group infra-admins
# Repeat for portainer, prometheus

Ingress wiring (Terraform):

modules/ingress/protected-services.tf:

resource "kubernetes_ingress_v1" "protected_services" {
  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.riotpiao.homelab.com/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"
    }
  }

  spec {
    ingress_class_name = "nginx"
    rule {
      host = each.value.host
      http {
        path {
          path      = "/"
          path_type = "Prefix"
          backend {
            service {
              name = each.value.service_name
              port {
                number = each.value.service_port
              }
            }
          }
        }
      }
    }
  }
}

NetworkPolicy (per-service, Terraform):

modules/network-policy/backend-isolation.tf:

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

  metadata {
    name      = "${each.key}-deny-except-ingress"
    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"
          }
        }
      }
    }
  }
}

Track B — Homarr (Terraform Deployment)

Homarr Helm chart wrapper (Terraform):

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.riotpiao.homelab.com/application/o/homarr/"
  }

  set {
    name  = "env.AUTH_OIDC_URI"
    value = "https://authentik.riotpiao.homelab.com/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]
}

Homarr Ingress (no auth, Homarr handles login):

modules/homarr/ingress.tf:

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.riotpiao.homelab.com"]
      secret_name = "homarr-tls"
    }
    rule {
      host = "homarr.riotpiao.homelab.com"
      http {
        path {
          path      = "/"
          path_type = "Prefix"
          backend {
            service {
              name = helm_release.homarr.name
              port {
                number = 3000
              }
            }
          }
        }
      }
    }
  }
}

Authentik setup for Homarr (Python):

python3 k8s/talos-iam/register_oauth_app.py \
  --service-name homarr \
  --namespace dashboard \
  --redirect-uri "https://homarr.riotpiao.homelab.com/api/auth/callback/oidc" \
  --add-group homarr-infra-admins

Then attach groups scope mapping (manual Authentik UI or separate Terraform provider if available).

Homarr board setup (manual runbook in k8s/homarr/README.md):

  • Create groups: Infra (Longhorn/Portainer/Prometheus), Platform (Grafana/Vault), Workflows (Temporal/kmsvc)
  • Each tile deep-links to existing ingress
  • Restrict Infra group visibility to infra-admins

Track C — Portfolio Site (Terraform Deployment)

Portfolio source repo: ~/workplace/riotpiao (Next.js, standalone output).

Portfolio Helm chart (Terraform wrapper):

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      = "./k8s/portfolio"  # Local chart from homelab repo

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

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

  set {
    name  = "replicaCount"
    value = 2
  }

  set {
    name  = "resources.requests.cpu"
    value = "100m"
  }

  set {
    name  = "resources.requests.memory"
    value = "128Mi"
  }

  set {
    name  = "resources.limits.cpu"
    value = "500m"
  }

  set {
    name  = "resources.limits.memory"
    value = "512Mi"
  }
}

Portfolio Ingress (public, no auth):

modules/portfolio/ingress.tf:

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.riotpiao.homelab.com"]
      secret_name = "portfolio-tls"
    }
    rule {
      host = "portfolio.riotpiao.homelab.com"
      http {
        path {
          path      = "/"
          path_type = "Prefix"
          backend {
            service {
              name = helm_release.portfolio.name
              port {
                number = 3000
              }
            }
          }
        }
      }
    }
  }
}

Portfolio Helm chart structure (local, in homelab repo):

k8s/portfolio/
├── Chart.yaml
├── values.yaml
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── _helpers.tpl
└── README.md

Build + push script (portfolio repo):

~/workplace/riotpiao/build.sh:

#!/bin/bash
set -euo pipefail

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

docker buildx build --platform linux/amd64 -t "${IMAGE}" .
docker push "${IMAGE}"

echo "✓ Pushed ${IMAGE}"

Terraform Structure

~/workplace/homelab-terraform/  (or extend existing homelab repo)
├── main.tf                      # K8s provider config
├── variables.tf                 # Inputs
├── outputs.tf
│
├── modules/
│   ├── ingress/
│   │   ├── protected-services.tf    # Longhorn, Portainer, Prometheus
│   │   └── variables.tf
│   │
│   ├── homarr/
│   │   ├── main.tf                  # Namespace, Secret, Helm release
│   │   ├── ingress.tf               # Public Homarr ingress
│   │   └── variables.tf
│   │
│   ├── portfolio/
│   │   ├── main.tf                  # Namespace, Helm release
│   │   ├── ingress.tf               # Public portfolio ingress
│   │   └── variables.tf
│   │
│   └── network-policy/
│       └── backend-isolation.tf     # Deny ingress except nginx
│
├── environments/
│   └── prod.tfvars                 # Domain, image repos, versions
│
└── .terraform.lock.hcl

Main entry point:

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 "ingress" {
  source = "./modules/ingress"
  protected_services = var.protected_services
}

module "homarr" {
  source = "./modules/homarr"
  homarr_version            = var.homarr_version
  homarr_oidc_client_secret = var.homarr_oidc_client_secret
}

module "portfolio" {
  source = "./modules/portfolio"
  portfolio_image_repo = var.portfolio_image_repo
  portfolio_image_tag  = var.portfolio_image_tag
}

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

variables.tf:

variable "cluster_endpoint" {
  type = string
  description = "K8s API endpoint"
}

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

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

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 "homarr_version" {
  type = string
  default = "~1"
}

variable "homarr_oidc_client_secret" {
  type = string
  sensitive = true
  description = "From Authentik provider"
}

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

variable "portfolio_image_tag" {
  type = string
  default = "latest"
}

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"
      }
    }
  }
}

Implementation Sequencing

  1. Authentik setup (Python):

    • Register Proxy Providers (Longhorn, Portainer, Prometheus)
    • Create groups (infra-admins, homarr-infra-admins)
    • Verify /outpost.goauthentik.io/auth/nginx endpoint accessible
  2. Terraform deploy (parallel):

    • Ingress + NetworkPolicies (protected services)
    • Homarr (depends on Authentik, has own OIDC)
    • Portfolio (independent, public)
  3. Portfolio build + push:

    • cd ~/workplace/riotpiao && ./build.sh
    • Verify image in Forgejo registry
  4. Terraform apply:

    terraform init
    terraform plan
    terraform apply
    
  5. Verification:

    • curl -I https://longhorn.riotpiao.homelab.com → 302 to Authentik (unauthenticated)
    • Login as infra-admins member → Longhorn UI loads
    • curl -I https://homarr.riotpiao.homelab.com → OIDC sign-in page
    • Login → Homarr boards visible
    • curl -I https://portfolio.riotpiao.homelab.com → 200, no redirect

Files to Create

In homelab repo:

  • terraform.tfvars (git-ignored)
  • main.tf, variables.tf, outputs.tf
  • modules/{ingress,homarr,portfolio,network-policy}/*
  • k8s/homarr/README.md (board setup runbook)
  • k8s/portfolio/Chart.yaml, templates/*

In portfolio repo (~/workplace/riotpiao):

  • app/layout.tsx, app/page.tsx, app/about/page.tsx
  • components/{Header,Footer,ProjectCard}.tsx
  • content/{projects,experience,metadata}.ts
  • styles/globals.css
  • Dockerfile, build.sh, next.config.js
  • package.json (Next.js 15 + Tailwind)

Success Criteria

  • Protected services (Longhorn/Portainer/Prometheus) redirect unauthenticated to Authentik
  • Infra-admins group members see UI; non-members denied
  • Homarr OIDC login works; boards render for authorized groups
  • Portfolio site accessible without auth; project grid + about page live
  • All deployed via terraform apply (no manual kubectl)
  • Terraform state tracks 100% of resources