45 KiB
Migratieplan: Proxmox/Stacks → k3s-cluster met Argo CD
Complete migratie van je huidige stacks-repo naar een GitOps-managed k3s-cluster op 3 Optiplexen (i5-6500T, i3-8100T, + Proxmox-VM of 3e Optiplex), inclusief Debian-installatie, k3s, Argo CD, en migratie van alle services.
Structuur: Deel A (Fase 0–3) is de afgeronde basisinstallatie. Deel B legt uit hoe diensten bereikbaar zijn (poorten, hostnames, TLS). Deel C is het stap-voor-stap uitvoeringsplan — beginnend bij Argo CD, dan elke dienst via de Gitea-cluster-repo. Bijlagen A–E bevatten de achtergronddetails per onderdeel.
Fase 0 — Voorbereiding (vóór de Optiplexen binnenkomen)
- Backup huidige stacks-repo (git clone, incl. LFS indien aanwezig)
- Exporteer/configs van draaiende services: AdGuard (yaml), dashboards, Renovate-config
- Inventariseer huidige stack: welke services draaien waar, welke ports, welke volumes
- Reserveer statische IP's voor 3 cluster-nodes + 1 "spare" (DHCP-reserveringen of static via router)
- Bedenk een naamgevingsschema: bijv. k3s-node-01 t/m -03
- DNS-plan: welke hostnames wil je straks (adguard.example.com, homarr.example.com, etc.)
- Zorg dat je een USB-stick (8 GB+) hebt voor Debian-installatie
Fase 1 — Debian 13 installeren op de eerste Optiplex (k3s-node-01)
1.1 Installatiemedia maken
- Download Debian 13 netinst ISO
- Schrijf naar USB-stick (Rufus/balenaEtcher)
- Boot de Optiplex van USB (F12 = boot menu bij Dell)
1.2 Installatie
- Graphical install → taal naar eigen voorkeur (English is prima voor server)
- Hostname:
k3s-node-01 - Gebruiker: eigen account, sterke wachtwoord (geen root-login, sudo is veiliger)
- Partitionering: "Guided - use entire disk" → aparte
/bootniet nodig, alles in één partitie is prima. Geen swap-partitie nodig (komt later via zram) - Softwareselectie: vink alles uit behalve:
- ✅ SSH server
- ❌ desktop environments
- ❌ print server
- GRUB naar primaire schijf, reboot
1.3 Basisconfiguratie na installatie
# Als sudo-user inloggen
sudo apt update && sudo apt upgrade -y
# Handige tools
sudo apt install -y curl git htop vim net-tools
# Zram swap (voorkomt disk-gebruik, prima voor 8GB nodes)
sudo apt install -y systemd-zram-generator
echo -e "[zram0]\nzram-size = min(ram / 2, 4096)" | sudo tee /etc/systemd/zram-generator.conf
# Timezone
sudo timedatectl set-timezone Europe/Amsterdam
1.4 Unattended-upgrades (automatische security-updates)
Cluster-nodes willen nooit verwaarloosd worden, maar je wil ook niet wekelijks handmatig patchen. De golden middle: security-updates automatisch, de rest handmatig.
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades # kies "Ja"
Configuratie met verstandige defaults voor k3s-nodes:
sudo tee /etc/apt/apt.conf.d/50unattended-upgrades-local > /dev/null <<'EOF'
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename},label=Debian-Security";
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
};
// Alleen security-updates, geen gewone package-upgrades:
Unattended-Upgrade::Package-Blacklist {
};
// GEEN automatische reboot — kured regelt dat (zie hieronder)
Unattended-Upgrade::Automatic-Reboot "false";
// Mail/rapportage optioneel (logcheck via /var/log/unattended-upgrades/)
Unattended-Upgrade::Automatic-Reboot-Time "02:00";
EOF
Waarom geen Automatic-Reboot true? Bij een etcd-quorum wil je nooit dat alle 3 nodes tegelijk besluiten te rebooten. Daarvoor is kured (Kubernetes Reboot Daemon) de standaardoplossing:
- kured detecteert dat een node een reboot nodig heeft (
/var/run/reboot-requireddoor Debian aangemaakt bij kernel-updates) - Hij draine't en reboot't nodes één voor één, netjes binnen je quorum
- Installeer via Argo CD in
infrastructure/kured/:
sudo kubectl apply -f https://github.com/kubereboot/kured/releases/download/1.17.1/kured-1.17.1.yaml
# Optioneel: reboot-window vastleggen zodat het alleen 's nachts gebeurt
# --reboot-days=mon --start-time=2am --end-time=6am (via de helm chart values)
Samen is dit het complete patroon: unattended-upgrades patcht security-gaten dagelijks zonder reboot, en kured plant de nodige reboots netjes gespreid en 's nachts.
Extra tip: pin de k3s-versie zelf expliciet (INSTALL_K3S_VERSION of de versioned download-URL) — k3s-updates wil je bewust via een PR in je repo doen, niet automatisch, zodat je cluster-versie en je Git-repo synchroon blijven.
1.5 Netwerk statisch maken (belangrijk voor cluster-node)
# Check interfacenaam
ip a
# Statische IP via systemd-networkd of Netplan — gebruik degene die je installatie heeft
# Netplan-voorbeeld (Ubuntu-stijl):
sudo tee /etc/netplan/01-static.yaml > /dev/null <<EOF
network:
version: 2
ethernets:
enp2s0: # <-- vervang door jouw interfacenaam
dhcp4: no
addresses: [192.168.1.101/24]
routes:
- to: default
via: 192.168.1.1
nameservers:
addresses: [192.168.1.1, 1.1.1.1]
EOF
sudo netplan apply
(Debian gebruikt ifupdown (/etc/network/interfaces) — kies het pad dat bij jouw setup past; het punt is: statisch IP, geen DHCP-afhankelijkheid voor de node zelf)
1.6 BIOS-instellingen
- AC Power Recovery: "On" (automatisch opstarten na stroomuitval)
- Wake on LAN: aan (optioneel, handig)
- Virtualisatie (VT-x/VT-d): aan (voor toekomstige workloads)
- Secure Boot: uit (voorkomt gedoe met kernel-modules zoals Longhorn/zram)
- Deep Sleep/S4: uit (server moet altijd bereikbaar zijn)
1.7 Tailscale op de nodes (beheer-toegang via je Tailnet)
Elke node krijgt tailscaled voor SSH/kubectl-toegang vanaf je Tailnet — geen port-forwards of publieke beheer-poorten nodig.
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --hostname=k3s-node-01 --ssh
# Eén van de nodes (bijv. node-01) adverteert daarnaast je LAN-subnet:
sudo tailscale up --advertise-routes=192.168.1.0/24 --hostname=k3s-node-01 --ssh
# Accepteer de route in de Tailscale-admin-console (Machines → node-01 → Edit route settings)
Wat dit oplevert:
- SSH en kubectl vanaf elke locatie via MagicDNS (
k3s-node-01.<tailnet>.ts.net), incl. Tailscale-SSH met device-authenticatie - Beheer-UI's (Argo CD, Grafana, k3s-metrics) bereikbaar via Tailnet-i.p.v. publiek op DuckDNS
- Via de subnet-router is ook je Proxmox-UI, AdGuard-fallback-LXC e.d. remote bereikbaar
Wat je NIET door het Tailnet haalt:
- Cluster-intern verkeer (k3s-nodes onderling) en NFS blijven op je LAN — Tailscale is alleen de beheer-overlay, geen transport voor workload-verkeer
- AdGuard DNS-port 53 blijft via je LAN/DHCP-regels, niet via Tailscale
Etcd-communicatie blijft op LAN-IP's — verander niets aan --node-ip; Tailnet-IP's zijn alleen extra interfaces.
1.8 SSH-toegang regelen
# Vanaf je eigen machine
ssh-copy-id [email protected]
# Test sudo zonder wachtwoord-prompt (optioneel voor ansible later)
sudo visudo # voeg toe: user ALL=(ALL) NOPASSWD: ALL
Fase 2 — k3s installeren op node 01 (met cluster-init)
2.1 Waarom --cluster-init?
Je wil straks HA met 3 servers, dus start meteen met embedded etcd — dan hoef je later niet te migreren.
2.2 Installatie
curl -sfL https://get.k3s.io | sh -s - server \
--cluster-init \
--node-ip 192.168.1.101 \
--node-taint CriticalAddonsOnly=true:NoExecute \
--disable traefik # zie opmerking hieronder
Traefik: k3s levert standaard Traefik als ingress. Je kunt hem laten staan (aanrader om te beginnen) of uitzetten als je bijv. nginx-ingress wil. Voor dit plan: laten staan (weghalen uit bovenstaand commando) tenzij je reden hebt.
2.3 Verificatie
# Node is klaar?
sudo kubectl get nodes
# Verwacht: k3s-node-01 Ready control-plane,etcd,master
# Kubeconfig voor je eigen account
sudo mkdir -p ~/.kube
sudo k3s kubectl config view --raw | sudo tee ~/.kube/config > /dev/null
sudo chown -R $USER ~/.kube
2.4 Node-token bewaren (nodig voor node 02/03)
sudo cat /var/lib/rancher/k3s/server/node-token
# Bewaar deze veilig (password manager)
2.5 Automatische etcd-snapshots naar Proxmox (NFS)
Op je Proxmox: maak een NFS-share (bijv. /zfs-pool/data/k3s-backups).
# Op k3s-node-01: herstart k3s met snapshot-config
sudo tee /etc/rancher/k3s/config.yaml > /dev/null <<EOF
etcd-snapshot-schedule-cron: "0 */6 * * *"
etcd-snapshot-snapshot-retention: 14
etcd-s3: false
EOF
# Of naar S3-compatible (MinIO op Proxmox VM) — geavanceerder, kan later
sudo systemctl restart k3s
Fase 3 — Node 02 en 03 toevoegen als servers (HA-quorum)
Herhaal Fase 1 volledig op de tweede Optiplex (i3-8100T) en op je derde node (derde Optiplex óf een VM op Proxmox).
⚠️ Derde node via Proxmox-VM: een VM telt als "één fysiek punt van faal" als je meerdere nodes op dezelfde Proxmox-host zet. Voor een écht HA-quorum wil je idealiter 3 fysieke machines. Heb je maar 2 Optiplexen + Proxmox: accepteer de risico-afweging (Proxmox is vaak stabieler dan een nieuwe goedkope Mini) of wacht op een 3e fysieke node. De cluster werkt in beide gevallen.
3.1 Join als server
curl -sfL https://get.k3s.io | sh -s - server \
--server https://192.168.1.101:6443 \
--token <NODE_TOKEN> \
--node-ip 192.168.1.102
3.2 Verificatie op node 01
sudo kubectl get nodes
# Verwacht: 3 nodes, alle Ready, alle met control-plane,etcd,master rol
3.3 Labels zetten (handig voor later)
sudo kubectl label nodes k3s-node-01 node-type=intel-big
sudo kubectl label nodes k3s-node-02 node-type=intel-small
# etc.
Deel B — Hoe wordt een dienst bereikbaar? (het poorten-verhaal)
Voordat het stappenplan komt, eerst het antwoord op de kernvraag: hoe kan het dat honderd pods allemaal "poort 80" gebruiken terwijl een node maar één IP heeft? Het antwoord heeft drie lagen:
B.1 Pods: elk hun eigen cluster-IP
- Elke pod krijgt een eigen privé-IP uit het clusternetwerk (bij k3s:
10.42.x.x) - Pod A en pod B die allebei intern op poort 80 luisteren: geen conflict — het zijn aparte IP's, net zoals twee laptops thuis allebei poort 80 kunnen gebruiken
- Deze IP's zijn alleen binnen het cluster bereikbaar en vervallen bij herstart — daarom praat je er (bijna) nooit rechtstreeks tegen
B.2 Services: vaste naam + virtueel IP binnen het cluster
- Een Service is een vast virtueel IP (ClusterIP) + DNS-naam (
homarr.homarr.svc.cluster.local) dat naar de bijbehorende pods load-balancet - Pods komen en gaan, de Service blijft — dit is het "telefoonnummer" binnen het cluster
- Nog steeds géén node-poort in gebruik: dit verkeer blijft binnen het clusternetwerk
B.3 Ingress (Traefik): één deur, onderscheid op naam
- Traefik is de enige die op de node op poort 80 en 443 luistert (via de svclb/klipper-helper, precies zoals je in de logs zag bij
svclb-traefik) - Alle webapp-diensten delen dus die twee poorten. Het onderscheid wordt gemaakt op hostname: de browser vraagt
https://homarr.zb-kw.duckdns.org, en Traefik leest de hostname (HTTP Host-header vóór de omleiding, SNI binnen TLS) en kijkt in z'n Ingress-regels: "welke Ingress hoort bijhomarr.zb-kw.duckdns.org?" → routeert naar de Servicehomarrin namespacehomarr→ en die brengt het naar de pod - Concreet: één node-IP, twee poorten (80/443), oneindig veel diensten — elk met z'n eigen hostname
B.4 Diensten die géén HTTP zijn
Wat niet via Host-header kan (protocol zonder hostnames), krijgt z'n eigen poort op de node:
| Dienst | Protocol/poort | Oplossing |
|---|---|---|
| AdGuard Home | DNS, 53/udp+tcp | Service type LoadBalancer → klipper bindt 53 op de node(s) |
| Gitea SSH | SSH, 22 | LoadBalancer op poort 2222 (of 22) op de node |
| Databases, Kafka e.d. | alleen intern | ClusterIP Service — komt nooit op de node |
Regel: alles met een browser-URL loopt via Traefik op 80/443; de rest krijgt een eigen, unieke node-poort.
B.5 Intern vs. extern — zelfde namen, zelfde certificaat
- Intern: je AdGuard geeft een DNS-rewrite:
homarr.zb-kw.duckdns.org→ IP van een cluster-node. Je browser praat dus rechtstreeks met Traefik in je LAN. - Extern: je router forward 80/443 naar hetzelfde node-IP. Van buiten kom je op dezelfde Traefik binnen, met dezelfde hostname en hetzelfde wildcard-certificaat.
- TLS wordt overal door Traefik zelf afgehandeld (terminated), met het wildcard-certificaat
*.zb-kw.duckdns.org. Interne pods ontvangen verkeer via HTTP binnen het cluster (het clusternetwerk is privé). - Voordeel van deze aanpak: één DNS-aanpak, één certificaat, geen verschil tussen binnen en buiten.
B.6 Waar nginx proxy manager in het verhaal zit (interim → afbouw)
Nu forward je router 80/443 naar NPM op Proxmox. Dit plan rekent daarmee:
- Interim-fase (nu → alle services gemigreerd): NPM blijft op 80/443 van de router, maar per gemigreerde dienst zet je de NPM-proxy-host om van "naar de oude LXC/VM" naar "forward naar cluster-node-IP poort 80". Traefik neemt het vanaf daar over op hostname. Zo werk je dienst-voor-dienst om zonder DNS-risico.
- Definitief (Stap 9): router-forward 80/443 gaat direct naar het cluster-node-IP. NPM verliest z'n rol voor de DuckDNS-hostnames — behalve eventueel voor diensten die bewust buiten het cluster blijven (zoals HAOS), en ook die kan Traefik naar een LAN-IP routeren. Daarna NPM afbouwen.
Deel C — Stappenplan: van cluster naar bereikbare diensten met TLS
Doorloop deze stappen op volgorde. Wat al is uitgevoerd staat met ✅ en dient ter verificatie.
Stap 1 — TLS-fundering afronden ✅ (grotendeels gedaan)
- cert-manager geïnstalleerd in namespace
cert-manager - DuckDNS-webhook geïnstalleerd (helm chart
ebrianne/cert-manager-webhook-duckdns) - Token-secret
duckdns-tokenin namespacecert-manager - ClusterIssuer
duckdns—READY True - Wildcard-Certificate
zb-kw-wildcardin namespaceargocd(secretzb-kw-wildcard-tls) - RBAC voor de webhook — bewaar dit in de repo:
# infrastructure/cert-manager/webhook-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: cert-manager-webhook-duckdns-token-reader
namespace: cert-manager
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["duckdns-token"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: cert-manager-webhook-duckdns-token-reader
namespace: cert-manager
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: cert-manager-webhook-duckdns-token-reader
subjects:
- kind: ServiceAccount
name: cert-manager-webhook-duckdns
namespace: cert-manager
- Verificatie:
kubectl get certificate -n argocd→READY True(na het RBAC-fix herpakt de challenge zichzelf)
Belangrijk voor latere namespaces: het wildcard-secret staat nu alleen in argocd. Voor elke nieuwe namespace maak je een kleine Certificate-resource mee (zelfde issuerRef, zelfde dnsNames: ["*.zb-kw.duckdns.org"], zelfde secretName) in die namespace — cert-manager vernieuwt ze automatisch. Zo heeft elke namespace z'n eigen TLS-secret bij Traefik.
Stap 2 — Argo CD op z'n eigen hostname (het voorbeeld voor alle volgende)
2.1 argocd-server in HTTP-mode (Traefik doet de TLS)
sudo kubectl patch deployment argocd-server -n argocd \
-p '{"spec":{"template":{"spec":{"containers":[{"name":"argocd-server","env":[{"name":"ARGOCD_INSECURE","value":"true"}]}]}}}}'
2.2 Ingress-manifest (in de repo als services/argocd/ingress.yaml)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: argocd
namespace: argocd
spec:
rules:
- host: argocd.zb-kw.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: argocd-server
port:
number: 80 # poort 80 van de svc = HTTP-backend
tls:
- hosts: [argocd.zb-kw.duckdns.org]
secretName: zb-kw-wildcard-tls
2.3 Testen vóórdat je DNS aanpast — vanaf de node zelf:
curl -vk --resolve argocd.zb-kw.duckdns.org:443:192.168.2.<node-ip> https://argocd.zb-kw.duckdns.org
Krijg je het Argo CD-login-scherm, dan werkt de hele keten: Traefik → Ingress → Service → pod, mét het geldige wildcard-certificaat.
Stap 3 — Interne DNS: rewrite in AdGuard
Zodat je binnen je LAN https://argocd.zb-kw.duckdns.org kunt gebruiken:
- AdGuard (de LXC én later de in-cluster instantie) → DNS rewrites:
argocd.zb-kw.duckdns.org→192.168.2.<node-ip>- Per dienst één rewrite (DuckDNS-rewrites ondersteunen geen wildcard) — bij ~10 diensten is dat een keer typen
- Test vanaf je laptop:
nslookup argocd.zb-kw.duckdns.orggeeft het node-IP; de browser opent de UI - Geen kip-en-ei met cert-manager: de DNS-01-challenge loopt via de DuckDNS-API en checkt TXT-records, niet je lokale A-records
Stap 4 — NPM ombouwen tot doorgeefluik (interim)
- In NPM: proxy-host
argocd.zb-kw.duckdns.orgaanmaken/wijzigen → forward naar192.168.2.<node-ip>:80(HTTP — Traefik doet de rest, inclusief de HTTPS-redirect op hostname) - Zo werkt de dienst ook van buiten zolang de router nog naar NPM wijst
- Voor elke volgende gemigreerde dienst: verwijder de oude NPM-host, zet de nieuwe forward naar het node-IP — 1 minuut werk
- NPM's eigen Let's Encrypt-certs zijn voor deze hostnames overbodig geworden; laat ze verlopen, verwijder ze pas als NPM niets meer bedient
Stap 5 — De cluster-repo op Gitea wordt de bron van alles
Alle YAML's hieronder leven in dezelfde repo (gitea.zb-kw.duckdns.org/willemjan/cluster). Vanaf de node apply je daarna nog maar één manifest (de bootstrap) — de rest trekt Argo CD zelf.
5.1 Repo-structuur
cluster/
├── bootstrap/
│ └── root-app.yaml # de enige die je handmatig applyt (Stap 6)
├── apps/ # één Argo CD-Application per dienst
│ ├── argocd.yaml # self-management (Stap 6)
│ ├── adguard.yaml
│ └── homarr.yaml
├── infrastructure/
│ ├── cert-manager/
│ │ ├── cert-manager.yaml # al geïnstalleerd — ter documentatie in Git
│ │ ├── duckdns-issuer.yaml
│ │ ├── webhook-rbac.yaml
│ │ └── wildcard-cert-argocd.yaml
│ └── storage/
│ └── nfs-provisioner.yaml # zie Bijlage A
└── services/ # de echte workloads
├── argocd/
│ └── ingress.yaml
├── adguard/
│ ├── namespace.yaml
│ ├── deployment.yaml
│ ├── service.yaml # incl. LoadBalancer voor poort 53
│ ├── ingress.yaml
│ └── cert.yaml
└── homarr/…
5.2 Het handmatige deel: root-app applyen vanaf de node
sudo kubectl apply -f https://gitea.zb-kw.duckdns.org/willemjan/cluster/raw/branch/main/bootstrap/root-app.yaml
# bootstrap/root-app.yaml — de "app-of-apps"
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
spec:
project: default
source:
repoURL: https://gitea.zb-kw.duckdns.org/willemjan/cluster.git
targetRevision: main
path: apps # elke yaml hierin = één Application
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true # wat uit Git verdwijnt, verdwijnt uit het cluster
selfHeal: true # handmatige kubectl-wijzigingen worden teruggedraaid
Vanaf dit moment is Git de waarheid: een commit op main verandert het cluster binnen ~3 minuten (Argo CD's poll-interval).
5.3 Repo-toegang voor Argo CD
Argo CD heeft leesrechten op de repo nodig. In de Argo CD UI: Settings → Repositories → Connect repo using https met je Gitea-gebruiker + access-token (verplicht bij een private repo; check met Test). Zie ook Bijlage C.
Stap 6 — Argo CD beheert zichzelf (GitOps sluiten)
- Commit de bestaende Argo CD-installatie + ingress in de repo onder
services/argocd/ - Maak
apps/argocd.yaml:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: argocd
namespace: argocd
spec:
project: default
source:
repoURL: https://gitea.zb-kw.duckdns.org/willemjan/cluster.git
targetRevision: main
path: services/argocd
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated: {prune: true, selfHeal: true}
- Commit → de root-app pikt het bestand op → Argo CD beheert zichzelf vanaf nu vanuit Git. GitOps is rond.
Stap 7 — Standaardrecept: een dienst toevoegen (geldt voor elke volgende)
Altijd dezelfde volgorde:
- Map + manifests in de repo (
services/<naam>/): namespace, deployment, service, ingress (+ PVC/secret) — templates hieronder - Application-manifest in
apps/<naam>.yaml(kopieerapps/argocd.yaml, paspath: services/<naam>aan) - Commit & push → Argo CD pakt het op (of klik Sync)
- DNS-rewrite in AdGuard:
<naam>.zb-kw.duckdns.org→ node-IP - NPM (interim): forward
<naam>.zb-kw.duckdns.org→ node-IP:80 — of sla over als je al in Stap 9 zit - Test intern (browser) en extern (via de router)
Template: de complete app in één bestand
# services/<naam>/app.yaml
apiVersion: v1
kind: Namespace
metadata:
name: <naam>
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: <naam>-config
namespace: <naam>
spec:
storageClassName: nfs-client # NFS vanaf Proxmox — Bijlage A
accessModes: [ReadWriteMany]
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: <naam>
namespace: <naam>
spec:
selector:
matchLabels: {app: <naam>}
template:
metadata:
labels: {app: <naam>}
spec:
containers:
- name: <naam>
image: <image>:<tag> # pin de tag! (Renovate houdt ze bij)
ports: [{containerPort: <poort>}]
volumeMounts: [{name: config, mountPath: /config}]
volumes:
- name: config
persistentVolumeClaim: {claimName: <naam>-config}
---
apiVersion: v1
kind: Service
metadata:
name: <naam>
namespace: <naam>
spec:
selector: {app: <naam>}
ports: [{port: 80, targetPort: <poort>}] # intern op 80 zetten is handig
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: <naam>
namespace: <naam>
spec:
rules:
- host: <naam>.zb-kw.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service: {name: <naam>, port: {number: 80}}
tls:
- hosts: [<naam>.zb-kw.duckdns.org]
secretName: zb-kw-wildcard-tls
En eenmalig per namespace de bijbehorende Certificate (zelfde wildcard, in deze namespace):
# services/<naam>/cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: zb-kw-wildcard
namespace: <naam>
spec:
secretName: zb-kw-wildcard-tls
dnsNames: ["*.zb-kw.duckdns.org"]
issuerRef: {name: duckdns, kind: ClusterIssuer}
Voor niet-HTTP-diensten (zoals AdGuard): extra Service-blok
# toevoegen in service.yaml — DNS op de node zelf
apiVersion: v1
kind: Service
metadata:
name: adguard-dns
namespace: adguard
spec:
type: LoadBalancer # klipper bindt poort 53 op de node(s)
selector: {app: adguard}
ports:
- {name: dns-tcp, port: 53, targetPort: 53, protocol: TCP}
- {name: dns-udp, port: 53, targetPort: 53, protocol: UDP}
(Zie B.4: alleen voor diensten zonder browser-URL.)
Stap 8 — Diensten migreren in volgorde van laag → hoog risico
Details per dienst in Bijlage D:
- Argo CD (Stap 2–6) — bijna af
- AdGuard Home in-cluster — mét de fallback-LXC als tweede DNS tijdens de omschakeling (DNS is je kritiekste dienst: eerst redundantie, dan migratie)
- Homarr (dashboard — lichte dienst, mooie tweede)
- Authentik (identiteitsprovider — de SSO-laag voor alle overige diensten; vóór de rest migreren)
- Renovate (verhuist als CronJob of deployment)
- Immich/Nextcloud-achtige diensten — zorgvuldig met de NFS-datamigratie (Bijlage A/D)
- Gitea zelf, als laatste (de kip-en-ei, Bijlage D): Gitea verhuist pas als alles al via Git beheerd wordt én er een veilige fallback is
Stap 9 — Router omschakelen: het cluster wordt de front-deur (definitief)
Als alle diensten in het cluster zitten:
- Router port-forwards 80/tcp en 443/tcp omzetten van NPM-VM → cluster-node-IP
- Test extern: alle
*.zb-kw.duckdns.org-hostnames - NPM afbouwen: doorgeef-hosts verwijderen; houd NPM alleen aan voor wat bewust buiten het cluster blijft (HAOS) — óf routeer dat via een Traefik-IngressRoute naar het LAN-IP van de HAOS-VM
- Bewaak in Uptime Kuma: al je hostnames (extern én intern)
- Optioneel later: een VIP/MetalLB voor 80/443 zodat het niet aan één node kleeft
Stap 10 — Testen, chaos en monitoring (afsluiting)
Zie Bijlage E: kube-prometheus-stack, Beszel-agent op de nodes, Uptime Kuma, backup-strategie en de chaos-test (node uit, Proxmox-reboot i.v.m. NFS, certificaatvernieuwing droog oefenen).
Bijlagen — service-specifieke en achtergronddetails
Bijlage A — Storage: NFS vanaf Proxmox
Je Proxmox-server wordt de centrale storage-backend: k3s mount een NFS-export en PVC's worden subdirectories daarop. Voordeel: een pod kan naar elke node verhuizen en z'n data blijft bereikbaar.
Stap 1 — NFS-server direct op de Proxmox-host:
We draaien de NFS-server rechtstreeks op de Proxmox-host (niet in een LXC/VM). Reden: nfs-kernel-server in een unprivileged container is een bekend breekpunt (nfsd-kernelmodule in user namespaces), en de ZFS-pool is toch al aan de host gebonden. Je immich/nextcloud/fileshare-LXC's blijven gewoon bestaan — dit is alleen voor de k3s-storage-export.
# 1. Op de Proxmox-host: dataset aanmaken voor de k3s-data
zfs create zfs-pool/data/k3s-storage
# Optioneel: quota erop zetten zodat k3s je pool niet volzuigt
zfs set quota=200G zfs-pool/data/k3s-storage
# 2. NFS-server installeren op de host
apt install -y nfs-kernel-server
# 3. Export aanmaken — beperk tot je cluster-subnet!
echo "/zfs-pool/data/k3s-storage 192.168.2.0/24(rw,sync,no_subtree_check,no_root_squash)" >> /etc/exports
# 4. Export activeren
exportfs -ra
exportfs -v # controleer dat de export er staat
# 5. Firewall-check (indien pve-firewall actief): poort 2049/tcp open voor cluster-subnet
# ook rpcbind/nfs-helperdiensten: `systemctl status nfs-server`
Waarom deze plek (zfs-pool/data/...): data in /zfs-pool/data/ leeft op je ZFS-pool, buiten elke container of VM om — hij overleeft recreaties en is direct backupbaar met je bestaande ZFS-snapshots/replicatie. Precies hetzelfde patroon als je immich/nextcloud/fileshare-datasets.
Belangrijk: de Proxmox-host doet nu óók dienst als NFS-server — dat is een extra service op je hypervisor. Voor een homelab prima, maar besef: herstart je Proxmox, dan bevriezen in-cluster pods met NFS-PVC's tijdelijk (writes blokkeren tot NFS terug is). Dit scenario zit in de chaos-test van Fase 8.
Stap 2 — NFS als StorageClass in k3s via nfs-subdir-external-provisioner:
# infrastructure/storage/nfs-provisioner.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nfs-subdir-external-provisioner
namespace: kube-system
spec:
selector:
matchLabels: {app: nfs-subdir-external-provisioner}
template:
metadata:
labels: {app: nfs-subdir-external-provisioner}
spec:
serviceAccountName: nfs-subdir-external-provisioner
containers:
- name: nfs-subdir-external-provisioner
image: registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.7.0
volumeMounts: [{name: nfs, mountPath: /persistentvolumes}]
env:
- {name: PROVISIONER_NAME, value: k8s-sigs.io/nfs-subdir}
- {name: NFS_SERVER, value: 192.168.2.254} # <-- Proxmox-host IP
- {name: NFS_PATH, value: /zfs-pool/data/k3s-storage}
volumes:
- name: nfs
nfs: {server: 192.168.2.254, path: /zfs-pool/data/k3s-storage}
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-client
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: k8s-sigs.io/nfs-subdir
parameters:
archiveOnDelete: "true"
reclaimPolicy: Retain
volumeBindingMode: Immediate
(De RBAC-regels voor de provisioner horen er ook bij — gebruik de officiële helm chart nfs-subdir-external-provisioner voor de complete set; hier ter illustratie het principe)
Wat dit je geeft:
- PVC's landen als mappen op de Proxmox-NFS: backupbaar op één plek, uitbreidbaar met een grote schijf
- Pods zijn vrij verplaatsbaar tussen nodes (geen nodeSelector-pinning meer voor storage-redenen)
- Minder RAM-overhead dan Longhorn
Trade-off om te kennen:
- Proxmox wordt een dependency: valt de NFS-server uit, dan verliezen pods toegang tot hun data. Pods blijven draaien maar freezes bij writes tot NFS terug is. Voor een homelab acceptabel; je chaos-test in Fase 8 moet dit scenario bevatten.
- Longhorn (replicated block storage op de nodes zelf) blijft het alternatief zonder deze dependency, maar is zwaarder en vraagt meer van je 128 GB SSD's. NFS-via-Proxmox past beter bij jouw setup — en je had de Proxmox toch al als storage-backend gepland.
Bijlage B — Cert-manager met DuckDNS (al uitgevoerd — ter naslag in de repo)
Je huidige DuckDNS-domein werkt gewoon door in het cluster. Belangrijkste punt: HTTP-01-validatie werkt alleen op poort 80/443 van buiten bereikbaar, en DuckDNS ondersteunt wildcards via DNS-01 — cert-manager heeft een officiële DuckDNS-webhook.
Stap 1 — cert-manager installeren (Argo CD-managed in infrastructure/cert-manager/):
sudo kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml
Stap 2 — DuckDNS-webhook installeren (ebrianne/cert-manager-webhook-duckdns):
De ClusterIssuer praat met een webhook-pod die de DNS-01-challenge via de DuckDNS-API uitvoert. Installeer eerst de webhook:
# 1. Token-secret aanmaken (haal je token van duckdns.org)
sudo kubectl create secret generic duckdns-token \
-n cert-manager --from-literal=token=<JOUW_DUCKDNS_TOKEN>
# 2. Webhook installeren via helm
helm install cert-manager-webhook-duckdns \
--namespace cert-manager \
--set duckdns.token='<JOUW_DUCKDNS_TOKEN>' \
ebrianne/cert-manager-webhook-duckdns
# 3. Verifieer de APIService
sudo kubectl get apiservice | grep duckdns
# Verwacht: v1alpha1.acme.duckdns.org Available
Stap 3 — DuckDNS-issuer (werkt voor wildcard én losse hostnames):
# infrastructure/cert-manager/duckdns-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: duckdns
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: [email protected]
privateKeySecretRef:
name: duckdns-account-key
solvers:
- dns01:
webhook:
groupName: acme.duckdns.org # default van de ebrianne-webhook
solverName: duckdns
config:
apiTokenSecretRef: # verwijst naar je token-secret
name: duckdns-token
key: token
De webhook haalt het te valideren domein uit de Challenge zelf — je Certificate-manifest bepaalt dus wat er uitgegeven wordt. Geen subdomain-config nodig.
Aanpak per hostname — twee opties:
- Wildcard (
*.zb-kw.duckdns.org) — aanrader: één Certificate-resource metdnsNames: ["*.zb-kw.duckdns.org"], door al je Ingresses hergebruikt via éénsecretName. Eenvoudiger beheer en DNS-01 werkt ook voor hostnames die niet publiek routen. - Losse subdomeinen (zoals nu):
adguard.zb-kw.duckdns.org,auth.zb-kw.duckdns.org, enz. — elke hostname z'n eigen Certificate. Werkt net zo goed, alleen meer resources om te beheren.
Tip: test de eerste issuance met het staging-endpoint (https://acme-staging-v02.api.letsencrypt.org/directory) om Let's Encrypt rate-limits te omzeilen, en schakel pas naar productie als de flow werkt.
Poorten uitsturen: DuckDNS-verwijzingen moeten naar je publieke IP wijzen (zoals nu al), en je router forwarded 80/443 naar het LoadBalancer-IP van Traefik. Voor DNS-only diensten zoals AdGuard (poort 53) gebruik je DNS-redirection in je router, geen port-forward — meer daarover in de AdGuard-sectie.
Belangrijk: gebruik DNS-01, niet HTTP-01 — DuckDNS-webhook + wildcard werkt zonder dat je iets extra op poort 80 beschikbaar hoeft te stellen, en ook achter CGNAT/dynamisch IP zolang de DNS-verwijzing klopt.
Bijlage C — Argo CD: installatie- en beheerdetails
5.1 Argo CD installeren
sudo kubectl create namespace argocd
sudo kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# UI bereikbaar maken
sudo kubectl patch svc argocd-server -n argocd -p '{"spec":{"type":"LoadBalancer"}}'
# LoadBalancer = k3s klipper → krijgt een IP uit je node-range (prima voor homelab)
# Admin-wachtwoord ophalen
sudo kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
5.2 Pin Argo CD op één node (prettig voor performance)
# infrastructure/argocd/values.yaml of patch
spec:
template:
spec:
nodeSelector:
node-type: intel-big
5.3 App-of-apps patroon (aanrader)
Maak één "root-app" die alle andere apps beheert:
# argocd/root-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
spec:
project: default
source:
repoURL: https://gitea.zb-kw.duckdns.org/je-gebruikersnaam/cluster.git
targetRevision: main
path: infrastructure
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
5.4 Repo-toegang via SSH-key of token
- Maak in Gitea een read-only deploy-token of SSH-key aan
- Configureer dit in Argo CD:
argocd repo add git@gitea...:user/cluster.git --ssh-private-key-path ...
5.5 Sync-policy per app
Begin met automated: {prune: true, selfHeal: true} voor alles in infrastructure/ — daarna per service kijken of je wilt dat Argo CD ook live veranderingen teruggedraait (selfHeal kan irritant zijn bij debugging).
Bijlage D — Service-specifieke migratiedetails
Migreer in deze volgorde: lichte/kritiek-loze services eerst, pas later de diensten waar je huisnetwerk vanaf hangt.
6.1 AdGuard Home — met redundante tweede instantie buiten het cluster
Waarom eerste: klein, geen dependencies, direct nut — én je DNS-redundantie-plan is hier het belangrijkste onderdeel.
De architectuur (zoals je nu al hebt, maar dan hybride):
| Instantie | Draait op | Rol |
|---|---|---|
| adguard-cluster | k3s (namespace networking) |
Primair, GitOps-beheerd |
| adguard-fallback | LXC op Proxmox | Secundair, DNS-fallback als het cluster (her)start of platligt |
Waarom dit slim is: DNS is de kritieke dienst van je netwerk — valt die weg, dan lijkt "alles" plat, inclusief de diensten die gewoon draaien. Een tweede AdGuard buiten het cluster overleeft cluster-upgrades, node-reboots en etcd-herstel.
Config-sync tussen de twee instanties (je huidige aanpak doorzetten):
AdGuard Home heeft geen ingebouwde sync, maar er zijn twee beproefde patronen:
- GitOps-sync (aanrader in jouw setup): de in-cluster AdGuard haalt zijn config uit je cluster-repo (PVC op NFS), en een kleine cron/sync-job rsync't de
AdGuardHome.yamlnaar de LXC-instantie (bijv. via ssh/scp of een gedeelde NFS-mount op de LXC). Aanpassingen doe je via de UI van de cluster-instantie; de sync-propagateert naar de fallback. - AdGuardHome-Sync tool: een community-tool die config van een master naar replica's pusht (incl. filters, rewrites, clients). Draait als sidecar of aparte deployment in-cluster, wijst naar je LXC-instantie als replica.
Kies optie 2 als je weinig wilt bouwen; optie 1 als je alles in je eigen tooling wil houden.
Router/DHCP-configuratie: zet in je DHCP-config beide DNS-servers: eerst het cluster-IP (LoadBalancer of hostPort op een vaste node), dan de LXC-IP als fallback. Clients vallen dan automatisch terug op de tweede AdGuard als de eerste niet antwoordt — precies de redundancy die je wil.
Deployment in-cluster (core):
# services/adguard/deployment.yaml — DNS-ports via hostPort of LoadBalancer
apiVersion: apps/v1
kind: Deployment
metadata:
name: adguard-home
namespace: networking
spec:
replicas: 1
selector:
matchLabels: {app: adguard-home}
template:
metadata:
labels: {app: adguard-home}
spec:
containers:
- name: adguard-home
image: adguard/adguardhome:v0.107.62
ports:
- {containerPort: 53, protocol: UDP, hostPort: 53}
- {containerPort: 53, protocol: TCP, hostPort: 53}
- {containerPort: 3000} # admin UI eerste setup, daarna 80
volumeMounts:
- {name: config, mountPath: /opt/adguardhome/conf}
volumes:
- name: config
persistentVolumeClaim: {claimName: adguard-config}
(DNS op poort 53: gebruik hostPort op de node met vaste nodeSelector, óf de klipper LoadBalancer met extra IP. In beide gevallen: de router/DHCP verwijst naar dat IP. Zet de pod vast op één node zolang je hostPort gebruikt.)
Belangrijk voor AdGuard: zet je router-DNS pas om naar de nieuwe IP's nadát beide instanties getest zijn — de LXC-fallback maakt deze overstap veel veiliger dan bij een enkele instantie.
6.2 Homarr (dashboard)
apiVersion: apps/v1
kind: Deployment
metadata:
name: homarr
namespace: dashboards
spec:
replicas: 1
selector:
matchLabels: {app: homarr}
template:
metadata:
labels: {app: homarr}
spec:
containers:
- name: homarr
image: ghcr.io/ajnart/homarr:latest
ports: [{containerPort: 7575}]
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /app/data/configs
volumes:
- name: data
persistentVolumeClaim: {claimName: homarr-data}
- name: config
persistentVolumeClaim: {claimName: homarr-config}
---
apiVersion: v1
kind: Service
metadata: {name: homarr, namespace: dashboards}
spec:
selector: {app: homarr}
ports: [{port: 80, targetPort: 7575}]
---
# Ingress met cert-manager TLS: homarr.zb-kw.duckdns.org
6.3 Authentik (nieuw — nog nergens draaiend)
Authentik bestaat uit 4 componenten: server, worker, PostgreSQL en Redis.
# services/authentik/ - samengevat, gebruik de officiële Helm chart voor volledige setup
# Helm chart: authentik/authentik
# Belangrijkste values:
authentik:
secret_key: "<via sealed-secrets of SOPS>"
error_reporting: false
server:
ingress:
host: auth.zb-kw.duckdns.org
postgresql:
enabled: true
redis:
enabled: true
resources:
server: {requests: {cpu: 250m, memory: 512Mi}}
worker: {requests: {cpu: 250m, memory: 512Mi}}
Resource-planning: Authentik + postgres + redis samen ~1,5-2 GB RAM. Zet de postgres-PVC op je node met de meeste vrije ruimte, of gebruik Longhorn zodra actief. Pin op één node zolang je local-path storage gebruikt.
Volgorde: configureer Authentik eerst met één lokale admin-user, zet daarna pas SSO-integraties op andere services.
6.4 Renovate
Renovate als CronJob in-cluster (of gebruik Mend-hosted — hosted is minder werk):
apiVersion: batch/v1
kind: CronJob
metadata:
name: renovate
namespace: ci
spec:
schedule: "0 3 * * *" # elke nacht 03:00
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: renovate
image: renovate/renovate:39
env:
- name: RENOVATE_TOKEN
valueFrom:
secretKeyRef: {name: renovate-token, key: token}
- name: RENOVATE_PLATFORM
value: "gitea"
- name: RENOVATE_ENDPOINT
value: "https://gitea.zb-kw.duckdns.org/api/v1"
- name: RENOVATE_REPOSITORIES
value: "[]"
- name: RENOVATE_AUTODISCOVER
value: "true"
resources:
requests: {cpu: 250m, memory: 512Mi}
limits: {memory: 2Gi}
Let op: Renovate draait in-cluster die zijn eigen cluster-repo beheert — dat werkt prima (PR's op Gitea, Argo CD past toe na merge), maar zorg dat Renovate niet zijn eigen deploy platgooit door een broken image-tag te PR'en; gebruik Renovate's minimumReleaseAge of pin de cluster-repo op major-versions via packageRules.
6.5 Overige services uit je stacks-repo
Voor elke bestaande service (pi-hole-achtigen, uptime-kuma, etc.):
- Container-image en config uit huidige repo halen
- Omzetten naar Deployment + Service + Ingress + PVC
- Als Argo CD draait: gewoon yaml in de juiste
services/<naam>/-map committen → Argo CD pakt het op - DNS/router pas omschakelen als de nieuwe variant getest is
D.6 — Gitea zelf migreren (de kip-en-ei-vraag)
Gitea kan in-cluster draaien, maar er is een bootstrapping-probleem: Argo CD heeft je Git-repo nodig om te weten wat hij moet deployen. Staat de repo in het cluster zelf, dan heb je een circular dependency.
Aanrader: Gitea op Proxmox laten staan (of een aparte lichte VM), niet in-cluster. Redenen:
- CI/CD-infrastructuur buiten je cluster = je kunt het cluster altijd herbouwen vanuit Git
- Gitea is niet zwaar, een kleine VM volstaat
- Wil je Gitea tóch in-cluster: gebruik Argo CD's fallback naar een lokale bootstrap-kustomization als bron onbereikbaar is — werkt, maar extra complexiteit
Bijlage E — Testing, monitoring en afsluiting
8.1 Monitoring
Beszel uitbreiden naar de cluster-nodes:
Je draait al Beszel voor je Proxmox-node — de agent kan gewoon op elke k3s-node meedraaien (klein systemd-proces, conflicteert niet met k3s):
# Op elke node (haal de installer/hub-key uit je Beszel-hub)
curl -sL https://get.beszel.dev -o /tmp/beszel.sh && bash /tmp/beszel.sh
# Voeg de node daarna in de hub toe als "system" met z'n LAN- of Tailnet-IP
- Zo behoud je één vertrouwd dashboard voor alle hardware (Proxmox + 3 Optiplexen) op OS-niveau
- Gebruik de Tailnet-IP's of MagicDNS-namen (
k3s-node-01.<tailnet>.ts.net) als connectie-adres — dan werkt monitoring ook remote, en je hoeft geen SSH-keys van buiten je Tailnet toe te staan - Beszel bewaakt alleen nodes; voor pods/workloads in Kubernetes geldt het volgende:
Kubernetes-native monitoring (optioneel, later):
- kube-prometheus-stack in
monitoring/— pas toevoegen als je RAM-headroom hebt (kost zelf ~1-1,5 GB) - Alert naar je telefoon (Alertmanager → Telegram/Discord webhook)
- Uptime Kuma op Proxmox-VM (buiten het cluster) om de cluster-diensten zelf te bewaken — Bewaakt samen met Beszel-hub de "van buitenaf"-view
- Alerteer in Uptime Kuma ook op je AdGuard-fallback-LXC (DNS is je kritiekste dienst)
8.2 Backups
- etcd-snapshots (Fase 2.5) — verifiëer dat ze verschijnen en test een restore!
sudo k3s etcd-snapshot restore /var/lib/rancher/k3s/server/db/snapshots/<naam> - Velero voor workload-backups (optioneel, later)
- Gitea-data op Proxmox: Proxmox Backup Server of vzdump cron
8.3 Chaos-test vóór productie
- Start één node opnieuw op → cluster blijft beheerbaar, pods reschedule
- Trek de stekker van één node → zelfde test, zonder nette shutdown
- DNS werkt nog: cluster-AdGuard uit → clients vallen automatisch terug op de LXC-fallback (DHCP verwijst naar beide)
8.4 Router/DNS-omschakeling (laatste stap)
- DHCP DNS-server → cluster-IP's
- Router-advertenties/port-forwards omzetten naar LoadBalancer-IP's van k3s
- Alles één week dubbel draaien (oude stack + k3s parallel) alvorens oude stack af te bouwen
Samengevat: volgorde in één blik
Basis (afgerond): Fase 0–3 — Debian op de nodes, k3s met --cluster-init, quorum van 3.
Hoe bereikbaarheid werkt: één deur (Traefik op node-IP 80/443), onderscheid op hostname, TLS via wildcard-certificaat; niet-HTTP-diensten krijgen een eigen node-poort (B.1 t/m B.6).
| # | Stap | Resultaat |
|---|---|---|
| 1 | TLS-fundering (cert-manager + DuckDNS-webhook + RBAC + wildcard) | Cluster kan zelf geldige certificaten halen ✅ |
| 2 | Argo CD-ingress + ARGOCD_INSECURE |
argocd.zb-kw.duckdns.org werkt op de node |
| 3 | AdGuard DNS-rewrites | Hostnames resolven intern naar het cluster |
| 4 | NPM als doorgeefluik naar node:80 | Interim: alles blijft ook van buiten bereikbaar |
| 5 | Cluster-repo op Gitea + root-app (bootstrap) | Eén kubectl apply vanaf de node |
| 6 | Argo CD self-management | GitOps gesloten — Git is de waarheid |
| 7 | Standaardrecept per dienst (app.yaml + cert.yaml + apps/x.yaml) | Elke nieuwe dienst is een commit |
| 8 | Migreren: AdGuard → Homarr → Authentik → Renovate → data-diensten → Gitea | Diensten verhuizen laag→hoog risico |
| 9 | Router 80/443 → cluster-node-IP, NPM afbouwen | Cluster is de front-deur, intern én extern |
| 10 | Monitoring, backups, chaos-test | Produktief vertrouwen |