feat: switch to gugagr.xyz with TLS via Let's Encrypt (#39)

Adds Traefik Helm release (kube-system) with ACME HTTP-01 challenge
configured for Let's Encrypt, replacing the k3s-disabled bundled Traefik.

Migrates all hostnames from *.homelab.local to *.gugagr.xyz and upgrades
all ingresses to HTTPS with certresolver=letsencrypt annotations.

Adds var.domain (default homelab.local) to Terraform so the domain is
a single config point for monitoring and Gitea ingresses.

Gateway reads DOMAIN env var at runtime — falls back to homelab.local
so local k3d dev continues to work without changes.

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-26 21:45:19 +01:00 committed by GitHub
parent 8436295bbc
commit d4ccff518e
11 changed files with 119 additions and 33 deletions

View File

@ -36,6 +36,8 @@ spec:
value: "http://users"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "jaeger.monitoring.svc:4317"
- name: DOMAIN
value: "gugagr.xyz"
livenessProbe:
httpGet:
path: /healthz

View File

@ -5,10 +5,15 @@ metadata:
name: gateway-home
annotations:
traefik.ingress.kubernetes.io/router.middlewares: auth-forward-auth@kubernetescrd
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec:
ingressClassName: traefik
tls:
- hosts:
- gugagr.xyz
rules:
- host: homelab.local
- host: gugagr.xyz
http:
paths:
- path: /

View File

@ -3,10 +3,16 @@ kind: Ingress
metadata:
namespace: auth
name: gateway
annotations:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec:
ingressClassName: traefik
tls:
- hosts:
- auth.gugagr.xyz
rules:
- host: auth.homelab.local
- host: auth.gugagr.xyz
http:
paths:
- path: /api/

View File

@ -39,6 +39,20 @@ var dashboardTmpl = parseTmpl("templates/base.html", "templates/dashboard.html")
var homeTmpl = parseTmpl("templates/base.html", "templates/home.html")
var forbiddenTmpl = parseTmpl("templates/base.html", "templates/forbidden.html")
func domain() string {
if d := os.Getenv("DOMAIN"); d != "" {
return d
}
return "homelab.local"
}
func scheme() string {
if os.Getenv("DOMAIN") != "" {
return "https"
}
return "http"
}
var tracer = otel.Tracer("gateway")
type Handler struct{}
@ -142,12 +156,13 @@ func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
_, span := tracer.Start(r.Context(), "Home", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
s, d := scheme(), domain()
services := []ServiceCard{
{Name: "Auth", Description: "Login and account management", URL: "http://auth.homelab.local/dashboard", Icon: "🔑", Delay: 0},
{Name: "Finance", Description: "Track your finances", URL: "http://finance.homelab.local", Icon: "💰", Delay: 0.2},
{Name: "Test App", Description: "Example Go service", URL: "http://test.homelab.local", Icon: "🧪", Delay: 0.4},
{Name: "Monitoring", Description: "Use Grafana to monitor services", URL: "http://grafana.homelab.local", Icon: "📊", Delay: 0.6},
{Name: "Jaeger", Description: "Trace service requests", URL: "http://jaeger.homelab.local", Icon: "🔍", Delay: 0.8},
{Name: "Auth", Description: "Login and account management", URL: s + "://auth." + d + "/dashboard", Icon: "🔑", Delay: 0},
{Name: "Finance", Description: "Track your finances", URL: s + "://finance." + d, Icon: "💰", Delay: 0.2},
{Name: "Test App", Description: "Example Go service", URL: s + "://example-service." + d, Icon: "🧪", Delay: 0.4},
{Name: "Monitoring", Description: "Use Grafana to monitor services", URL: s + "://grafana." + d, Icon: "📊", Delay: 0.6},
{Name: "Jaeger", Description: "Trace service requests", URL: s + "://jaeger." + d, Icon: "🔍", Delay: 0.8},
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -300,7 +315,7 @@ func (h *Handler) LoginAPI(w http.ResponseWriter, r *http.Request) {
Name: "auth_token",
Value: token,
Path: "/",
Domain: ".homelab.local",
Domain: "." + domain(),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
@ -343,7 +358,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
Name: "auth_token",
Value: token,
Path: "/",
Domain: ".homelab.local",
Domain: "." + domain(),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
@ -364,12 +379,12 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
Name: "auth_token",
Value: "",
Path: "/",
Domain: ".homelab.local",
Domain: "." + domain(),
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "http://homelab.local/", http.StatusSeeOther)
http.Redirect(w, r, scheme()+"://"+domain()+"/", http.StatusSeeOther)
slog.InfoContext(ctx, "user logged out")
}
@ -384,7 +399,7 @@ func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
if redirect == "" {
redirect = fmt.Sprintf("http://%s/", host)
}
loginURL := fmt.Sprintf("http://auth.homelab.local/login?redirect=%s", url.QueryEscape(redirect))
loginURL := fmt.Sprintf("%s://auth.%s/login?redirect=%s", scheme(), domain(), url.QueryEscape(redirect))
span.SetAttributes(attribute.String("verify.result", "no_cookie"))
slog.DebugContext(ctx, "verify: no cookie, redirecting", "to", loginURL)
http.Redirect(w, r, loginURL, http.StatusFound)
@ -395,7 +410,7 @@ func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
if err != nil {
span.SetAttributes(attribute.String("verify.result", "invalid_token"))
slog.WarnContext(ctx, "verify: invalid token")
http.Redirect(w, r, "http://auth.homelab.local/login", http.StatusFound)
http.Redirect(w, r, scheme()+"://auth."+domain()+"/login", http.StatusFound)
return
}
span.SetAttributes(
@ -407,7 +422,7 @@ func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
// Check service-specific permission
host := originalHost(r)
if reqPerm, ok := servicePermissions[host]; ok {
if reqPerm, ok := servicePermissions()[host]; ok {
span.SetAttributes(attribute.String("verify.target_host", host), attribute.String("verify.required_perm", reqPerm))
if !hasPermission(claims.Permissions, reqPerm) {
span.SetAttributes(attribute.String("verify.result", "forbidden"))
@ -541,10 +556,13 @@ func (h *Handler) RegisterProxy(w http.ResponseWriter, r *http.Request) {
w.Write(respBody)
}
var servicePermissions = map[string]string{
"grafana.homelab.local": "service:grafana:access",
"jaeger.homelab.local": "service:jaeger:access",
"homelab.local": "service:home:access",
func servicePermissions() map[string]string {
d := domain()
return map[string]string{
"grafana." + d: "service:grafana:access",
"jaeger." + d: "service:jaeger:access",
d: "service:home:access",
}
}
func hasPermission(perms []string, target string) bool {

View File

@ -32,7 +32,7 @@ spec:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "jaeger.monitoring.svc:4317"
- name: BASE_URL
value: "https://finance.homelab.local"
value: "https://finance.gugagr.xyz"
- name: ADMIN_EMAIL
valueFrom:
secretKeyRef:

View File

@ -3,13 +3,16 @@ kind: Ingress
metadata:
name: api
namespace: finance
annotations:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec:
ingressClassName: traefik
tls:
- hosts:
- finance.homelab.local
- finance.gugagr.xyz
rules:
- host: finance.homelab.local
- host: finance.gugagr.xyz
http:
paths:
- path: /

View File

@ -1,12 +1,17 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-service
annotations:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec:
ingressClassName: traefik
tls:
- hosts:
- example-service.gugagr.xyz
rules:
- host: example-service.homelab.local
- host: example-service.gugagr.xyz
http:
paths:
- path: /

View File

@ -13,7 +13,7 @@ resource "kubernetes_secret" "gitea_admin" {
data = {
username = "admin"
password = random_password.gitea_admin[0].result
email = "admin@homelab.local"
email = "admin@${var.domain}"
}
}
@ -35,8 +35,8 @@ resource "helm_release" "gitea" {
config = {
APP_NAME = "Homelab Git"
server = {
DOMAIN = "git.homelab.local"
ROOT_URL = "http://git.homelab.local"
DOMAIN = "git.${var.domain}"
ROOT_URL = "http://git.${var.domain}"
SSH_DOMAIN = "localhost"
SSH_PORT = 30001
}
@ -57,7 +57,7 @@ resource "helm_release" "gitea" {
enabled = true
className = "traefik"
hosts = [{
host = "git.homelab.local"
host = "git.${var.domain}"
paths = [{ path = "/", pathType = "Prefix" }]
}]
}
@ -112,7 +112,7 @@ resource "terraform_data" "gitea_runner_registration" {
command = <<-EOT
set -e
echo "Waiting for Gitea to be ready..."
until curl -sf "http://git.homelab.local/api/v1/version" > /dev/null 2>&1; do
until curl -sf "http://git.${var.domain}/api/v1/version" > /dev/null 2>&1; do
sleep 5
done
@ -121,7 +121,7 @@ resource "terraform_data" "gitea_runner_registration" {
TOKEN=$(curl -sf \
-u "admin:$PASSWORD" \
"http://git.homelab.local/api/v1/admin/runners/registration-token" \
"http://git.${var.domain}/api/v1/admin/runners/registration-token" \
| grep -o '"token":"[^"]*"' | cut -d'"' -f4)
kubectl patch secret gitea-runner-token -n gitea \
@ -147,7 +147,7 @@ resource "kubernetes_secret" "gitea_registry" {
data = {
".dockerconfigjson" = jsonencode({
auths = {
"git.homelab.local" = {
"git.${var.domain}" = {
auth = base64encode("admin:${random_password.gitea_admin[0].result}")
}
}

View File

@ -21,7 +21,7 @@ resource "helm_release" "kube_prometheus_stack" {
adminPassword = random_password.grafana[0].result
ingress = {
enabled = true
hosts = ["grafana.homelab.local"]
hosts = ["grafana.${var.domain}"]
ingressClassName = "traefik"
annotations = {
"traefik.ingress.kubernetes.io/router.middlewares" = "auth-forward-auth@kubernetescrd"
@ -32,7 +32,7 @@ resource "helm_release" "kube_prometheus_stack" {
name = "Jaeger"
type = "jaeger"
uid = "jaeger"
url = "http://jaeger.monitoring.svc:16686"
url = "http://jaeger.monitoring.svc.cluster.local:16686"
access = "proxy"
isDefault = false
},
@ -92,7 +92,7 @@ resource "helm_release" "jaeger" {
jaeger = {
ingress = {
enabled = true
hosts = ["jaeger.homelab.local"]
hosts = ["jaeger.${var.domain}"]
annotations = {
"traefik.ingress.kubernetes.io/router.middlewares" = "auth-forward-auth@kubernetescrd"
}

View File

@ -0,0 +1,41 @@
resource "helm_release" "traefik" {
name = "traefik"
namespace = "kube-system"
repository = "https://traefik.github.io/charts"
chart = "traefik"
version = "~> 33.0"
atomic = true
values = [yamlencode({
ports = {
web = {
redirectTo = { port = "websecure" }
}
}
ingressRoute = {
dashboard = { enabled = false }
}
additionalArguments = [
"--certificatesresolvers.letsencrypt.acme.email=goncalo.gr@proton.me",
"--certificatesresolvers.letsencrypt.acme.storage=/data/acme.json",
"--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web",
]
persistence = {
enabled = true
size = "128Mi"
storageClass = "local-path"
}
service = {
type = "LoadBalancer"
}
resources = {
requests = { cpu = "50m", memory = "64Mi" }
limits = { cpu = "200m", memory = "128Mi" }
}
})]
}

View File

@ -9,3 +9,9 @@ variable "enable_monitoring" {
type = bool
default = true
}
variable "domain" {
description = "Base domain for all ingress hostnames (e.g. gugagr.xyz). Subdomains are created per service."
type = string
default = "homelab.local"
}