Deployment Guide

Step-by-step sequence for deploying the full homelab stack from scratch.

This guide covers the complete deployment sequence. Each phase depends on the previous one completing successfully.


Phase 0 — Local prerequisites (one-time)

# Tools
brew install pulumi talosctl kubectl just sops age go
npm install -g cdk8s-cli

# age key (one-time — back this up securely)
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
# ↑ prints: Public key: age1abc123...

# Add to shell profile (required on every machine you run pulumi from)
echo 'export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"' >> ~/.zshrc
source ~/.zshrc

Register your public key in .sops.yaml at the repo root, then populate the bootstrap secrets file:

sops infra/secrets/bootstrap.sops.yaml

The file must contain these keys (add any missing ones):

INFISICAL_DB_PASSWORD: <strong-random-password>
INFISICAL_ENCRYPTION_KEY: <32-char-hex>
INFISICAL_AUTH_SECRET: <32-char-hex>
REDIS_PASSWORD: <strong-random-password>
CLOUDFLARE_API_TOKEN: <cloudflare-dns-zone-edit-token>
AUTHENTIK_TOKEN: <authentik-api-token>
AUTHENTIK_GITHUB_SECRET: <github-oauth-app-secret>
NB_DATA_STORE_KEY: <openssl rand -base64 32>
NB_RELAY_SECRET: <openssl rand -base64 32>

CLOUDFLARE_API_TOKEN is used by both cert-manager (K8s) and Traefik (Bifrost VPS, DNS ACME challenge). Same token, automatically reused — no manual step.


Phase 1 — Bootstrap K8s cluster

# Creates two k8s Secrets before ArgoCD runs:
#   infisical/infisical-secrets
#   cert-manager/cloudflare-api-token
just create-secrets

# Provision Proxmox VMs → bootstrap Talos → install Cilium + ArgoCD (~15 min)
just pulumi talos up

# Apply Cilium Gateway, IP pool, HTTPRoutes
just pulumi platform up

Verify the cluster is up:

talosctl health --nodes 192.168.1.211
kubectl get nodes
kubectl get applications -n argocd   # ArgoCD starts syncing apps

ArgoCD will deploy all apps from the v0.1.5-manifests branch. Most apps will be Degraded at this point — that's expected. They need Infisical secrets to become healthy.


Phase 2 — Infisical first-time setup

Wait for Infisical to be running (~5 min after Phase 1):

kubectl get pods -n infisical   # wait for Running

Open http://infisical.madhan.app (LAN only).

  1. Create an account → OrganizationProject → slug: homelab-prod
  2. Environments → Add → name: prod, slug: prod
  3. Add secrets (navigate to Secretsprod environment):

Path /grafana

ADMIN_PASSWORD = <strong password>

Path /harbor

HARBOR_ADMIN_PASSWORD = <strong password>

Path /n8n

DB_PASSWORD     = <strong password>
N8N_ENCRYPTION_KEY = <32-char random string — record this, needed on every rebuild>

Path /rancher

BOOTSTRAP_PASSWORD = <strong password>

(Skip /netbird for now — NetBird isn't running yet. The netbird-peer pod will stay Pending until Phase 5.)

  1. Create a Service Token: SettingsService TokensAdd Token

    • Name: k8s-homelab, Environment: prod, Path: /, No expiry, Read only
    • Copy the token (shown once only)
  2. Store the token in K8s:

kubectl create secret generic infisical-service-token \
  --from-literal=infisicalToken=<paste-token> \
  -n infisical

Within 60 seconds, InfisicalSecret CRs sync and apps start becoming healthy. Check:

kubectl get applications -n argocd
# grafana, harbor, n8n, rancher should move to Healthy

Phase 3 — Authentik (Pulumi)

just pulumi authentik up

This creates in Authentik:

  • GitHub OAuth source
  • NetBird OIDC application (client ID: aumenijDycfG1cQURqH9BNJpV3KVUCoMHGPUVUlT)
  • Homelab ForwardAuth proxy provider + embedded outpost (covers *.madhan.app)
  • Service account sa-netbird + API token

Note the token for Bifrost:

cd infra/pulumi && pulumi stack output NetbirdServiceToken --stack authentik

Phase 4 — Bifrost VPS deploy

4a. Update config and DNS records

# Creates Cloudflare DNS records:
#   auth.madhan.app, netbird.madhan.app, proxy.madhan.app → 23.121.200.108
#   *.proxy.madhan.app → 23.121.200.108
#   grafana.madhan.app, harbor.madhan.app → 23.121.200.108
just pulumi cloudflare up

# Deploy/update Hetzner VPS + copy Bifrost config (docker-compose, traefik, netbird)
# This also auto-generates:
#   bifrost/traefik/dynamic/public-services.yml  (Traefik public routes)
#   bifrost/.secrets.env                          (CF_DNS_API_TOKEN from SOPS)
just pulumi hetzner up

4b. Populate bifrost/.env on the VPS

SSH into Bifrost and append the remaining secrets:

ssh root@23.121.200.108

cat >> /etc/bifrost/.env << 'EOF'
NB_DATA_STORE_KEY=<value from bootstrap SOPS>
NB_RELAY_SECRET=<value from bootstrap SOPS>
NB_IDP_MGMT_TOKEN=<NetbirdServiceToken output from Phase 3>
EOF

NB_DATA_STORE_KEY and NB_RELAY_SECRET are in bootstrap SOPS (added in Phase 0). Fresh install: generate with openssl rand -base64 32. These must never change after first deploy — the NetBird peer database is encrypted with them.

4c. Start containers

# Still on the VPS
cd /etc/bifrost
docker compose pull
docker compose up -d

# Verify all 7 containers are Up
docker compose ps

Expected containers: traefik, authentik-postgres, authentik-server, authentik-worker, netbird-server, netbird-dashboard, netbird-proxy, netbird-agent

Wait ~2 minutes for Traefik to obtain the wildcard TLS cert via Cloudflare DNS challenge:

docker logs traefik --follow   # look for "Obtained certificate"

4d. Verify Bifrost is working

# From your laptop
curl -I https://auth.madhan.app          # → 200 (Authentik login page)
curl -I https://netbird.madhan.app       # → 200 (NetBird dashboard)
curl -I https://grafana.madhan.app       # → 302 to auth.madhan.app (ForwardAuth working)

Phase 5 — NetBird setup

5a. Create setup keys

Open https://netbird.madhan.app → log in with GitHub (via Authentik).

Setup KeysAdd Key:

NameTypeUsed for
bifrost-agentReusablenetbird-agent container on Bifrost host
k8s-routing-peerReusablenetbird-peer pod in K8s

5b. Add bifrost-agent key to Bifrost

ssh root@23.121.200.108

cat >> /etc/bifrost/.env << 'EOF'
NB_BIFROST_SETUP_KEY=<bifrost-agent key from UI>
EOF

# Generate proxy token (netbird-server must be running)
docker exec netbird-server netbird-server generate-proxy-token
# ↑ copy the output

cat >> /etc/bifrost/.env << 'EOF'
NB_PROXY_TOKEN=<proxy token from above>
EOF

# Restart containers that use the new env vars
cd /etc/bifrost
docker compose restart netbird-agent netbird-proxy

5c. Add k8s-routing-peer key to Infisical

Open http://infisical.madhan.app → Project homelab-prod → Env prod → Path /netbird:

NETBIRD_SETUP_KEY = <k8s-routing-peer key from UI>

Within 60 seconds the InfisicalSecret in the netbird namespace syncs. The netbird-peer pod starts and connects to the NetBird mesh.

Check:

kubectl get pods -n netbird   # → Running

5d. Configure the LAN route in NetBird UI

Network RoutesAdd Route:

  • Network: 192.168.1.0/24
  • Routing Peer: k8s-routing-peer (appears once pod connects)
  • Enabled: Yes

Once the route is active, the Bifrost netbird-agent can reach 192.168.1.220 through the WireGuard mesh. This is how Traefik proxies public services to the K8s gateway.


Phase 6 — Publish CDK8s manifests

The netbird-peer chart is new — it needs to be synthesized and pushed to the manifests branch:

# From repo root
just synth   # generates app/netbird/ directory

# Commit to the manifests branch
git add app/netbird/
git commit -m "feat: Add NetBird routing peer"
git push origin v0.1.5-manifests

ArgoCD auto-syncs within 3 minutes. Watch:

kubectl get application netbird -n argocd

Phase 7 — End-to-end verification

# DNS resolves correctly
dig grafana.madhan.app    # → 23.121.200.108  (public, via Hetzner)
dig headlamp.madhan.app   # → 192.168.1.220   (LAN wildcard, private)

# Public service redirects to SSO when unauthenticated
curl -sI https://grafana.madhan.app | grep location
# → location: https://auth.madhan.app/...

# NetBird mesh
# NetBird UI → Peers: bifrost-agent Connected, k8s-routing-peer Connected
# NetBird UI → Routes: 192.168.1.0/24 Active

# Full end-to-end
# Open https://grafana.madhan.app in browser
# → Redirected to auth.madhan.app → Log in with GitHub → Land on Grafana

Quick reference — what goes where

SecretStored inSet during
INFISICAL_DB_PASSWORD etcBootstrap SOPSPhase 0
CLOUDFLARE_API_TOKENBootstrap SOPSPhase 0
AUTHENTIK_TOKEN, AUTHENTIK_GITHUB_SECRETBootstrap SOPSPhase 0
NB_DATA_STORE_KEY, NB_RELAY_SECRETBootstrap SOPS → bifrost/.env on VPSPhase 0 + 4b
NB_IDP_MGMT_TOKENbifrost/.env on VPSPhase 4b (Pulumi output)
NB_BIFROST_SETUP_KEYbifrost/.env on VPSPhase 5b
NB_PROXY_TOKENbifrost/.env on VPSPhase 5b
NETBIRD_SETUP_KEYInfisical /netbirdPhase 5c
All app passwordsInfisical pathsPhase 2

LAN access without hairpin routing

Public services (grafana.madhan.app, harbor.madhan.app) DNS-resolve to the Hetzner VPS IP for all clients. LAN users still route through Hetzner.

To bypass this on your laptop:

# Add to /etc/hosts — direct LAN access, no SSO
192.168.1.220  grafana.madhan.app harbor.madhan.app

For LAN-wide bypass, set the overrides in your router's DNS (Pi-hole, etc.).