# 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 1. **Graphical install** → taal naar eigen voorkeur (English is prima voor server) 2. **Hostname:** `k3s-node-01` 3. **Gebruiker:** eigen account, sterke wachtwoord (geen root-login, sudo is veiliger) 4. **Partitionering:** "Guided - use entire disk" → aparte `/boot` niet nodig, alles in één partitie is prima. Geen swap-partitie nodig (komt later via zram) 5. **Softwareselectie:** vink alles uit behalve: - ✅ SSH server - ❌ desktop environments - ❌ print server 6. GRUB naar primaire schijf, reboot ### 1.3 Basisconfiguratie na installatie ```bash # 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**. ```bash sudo apt install -y unattended-upgrades sudo dpkg-reconfigure -plow unattended-upgrades # kies "Ja" ``` Configuratie met verstandige defaults voor k3s-nodes: ```bash 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-required` door 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/`: ```bash 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) ```bash # 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 <.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 ```bash # Vanaf je eigen machine ssh-copy-id user@192.168.1.101 # 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 ```bash 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 ```bash # 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) ```bash 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`). ```bash # Op k3s-node-01: herstart k3s met snapshot-config sudo tee /etc/rancher/k3s/config.yaml > /dev/null < ⚠️ **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 ```bash curl -sfL https://get.k3s.io | sh -s - server \ --server https://192.168.1.101:6443 \ --token \ --node-ip 192.168.1.102 ``` ### 3.2 Verificatie op node 01 ```bash sudo kubectl get nodes # Verwacht: 3 nodes, alle Ready, alle met control-plane,etcd,master rol ``` ### 3.3 Labels zetten (handig voor later) ```bash 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 bij `homarr.zb-kw.duckdns.org`?" → routeert naar de Service `homarr` in namespace `homarr` → 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: 1. **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. 2. **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) - [x] cert-manager geïnstalleerd in namespace `cert-manager` - [x] DuckDNS-webhook geïnstalleerd (helm chart `ebrianne/cert-manager-webhook-duckdns`) - [x] Token-secret `duckdns-token` in namespace `cert-manager` - [x] ClusterIssuer `duckdns` — `READY True` - [x] Wildcard-Certificate `zb-kw-wildcard` in namespace `argocd` (secret `zb-kw-wildcard-tls`) - [ ] **RBAC voor de webhook** — bewaar dit in de repo: ```yaml # 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) ```bash 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`) ```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: ```bash curl -vk --resolve argocd.zb-kw.duckdns.org:443:192.168.2. 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.` - 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.org` geeft 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.org` aanmaken/wijzigen → forward naar `192.168.2.: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 ```bash sudo kubectl apply -f https://gitea.zb-kw.duckdns.org/willemjan/cluster/raw/branch/main/bootstrap/root-app.yaml ``` ```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) 1. Commit de bestaende Argo CD-installatie + ingress in de repo onder `services/argocd/` 2. Maak `apps/argocd.yaml`: ```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} ``` 3. 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: 1. **Map + manifests** in de repo (`services//`): namespace, deployment, service, ingress (+ PVC/secret) — templates hieronder 2. **Application-manifest** in `apps/.yaml` (kopieer `apps/argocd.yaml`, pas `path: services/` aan) 3. **Commit & push** → Argo CD pakt het op (of klik Sync) 4. **DNS-rewrite** in AdGuard: `.zb-kw.duckdns.org` → node-IP 5. **NPM** (interim): forward `.zb-kw.duckdns.org` → node-IP:80 — of sla over als je al in Stap 9 zit 6. **Test** intern (browser) en extern (via de router) ### Template: de complete app in één bestand ```yaml # services//app.yaml apiVersion: v1 kind: Namespace metadata: name: --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: -config namespace: spec: storageClassName: nfs-client # NFS vanaf Proxmox — Bijlage A accessModes: [ReadWriteMany] resources: requests: storage: 5Gi --- apiVersion: apps/v1 kind: Deployment metadata: name: namespace: spec: selector: matchLabels: {app: } template: metadata: labels: {app: } spec: containers: - name: image: : # pin de tag! (Renovate houdt ze bij) ports: [{containerPort: }] volumeMounts: [{name: config, mountPath: /config}] volumes: - name: config persistentVolumeClaim: {claimName: -config} --- apiVersion: v1 kind: Service metadata: name: namespace: spec: selector: {app: } ports: [{port: 80, targetPort: }] # intern op 80 zetten is handig --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: namespace: spec: rules: - host: .zb-kw.duckdns.org http: paths: - path: / pathType: Prefix backend: service: {name: , port: {number: 80}} tls: - hosts: [.zb-kw.duckdns.org] secretName: zb-kw-wildcard-tls ``` En eenmalig per namespace de bijbehorende Certificate (zelfde wildcard, in deze namespace): ```yaml # services//cert.yaml apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: zb-kw-wildcard namespace: 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 ```yaml # 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**: 1. [ ] **Argo CD** (Stap 2–6) — bijna af 2. [ ] **AdGuard Home** in-cluster — mét de fallback-LXC als tweede DNS tijdens de omschakeling (DNS is je kritiekste dienst: eerst redundantie, dan migratie) 3. [ ] **Homarr** (dashboard — lichte dienst, mooie tweede) 4. [ ] **Authentik** (identiteitsprovider — de SSO-laag voor alle overige diensten; vóór de rest migreren) 5. [ ] **Renovate** (verhuist als CronJob of deployment) 6. [ ] **Immich/Nextcloud-achtige diensten** — zorgvuldig met de NFS-datamigratie (Bijlage A/D) 7. [ ] **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. ```bash # 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`:** ```yaml # 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/`): ```bash 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: ```bash # 1. Token-secret aanmaken (haal je token van duckdns.org) sudo kubectl create secret generic duckdns-token \ -n cert-manager --from-literal=token= # 2. Webhook installeren via helm helm install cert-manager-webhook-duckdns \ --namespace cert-manager \ --set 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):** ```yaml # 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: wulsspaans@gmail.com 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:** 1. **Wildcard (`*.zb-kw.duckdns.org`) — aanrader:** één Certificate-resource met `dnsNames: ["*.zb-kw.duckdns.org"]`, door al je Ingresses hergebruikt via één `secretName`. Eenvoudiger beheer en DNS-01 werkt ook voor hostnames die niet publiek routen. 2. **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 ```bash 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) ```yaml # 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: ```yaml # 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: 1. **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.yaml` naar 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. 2. **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):** ```yaml # 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) ```yaml 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. ```yaml # services/authentik/ - samengevat, gebruik de officiële Helm chart voor volledige setup # Helm chart: authentik/authentik # Belangrijkste values: authentik: secret_key: "" 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): ```yaml 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.): 1. Container-image en config uit huidige repo halen 2. Omzetten naar Deployment + Service + Ingress + PVC 3. Als Argo CD draait: gewoon yaml in de juiste `services//`-map committen → Argo CD pakt het op 4. 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: 1. CI/CD-infrastructuur buiten je cluster = je kunt het cluster altijd herbouwen vanuit Git 2. Gitea is niet zwaar, een kleine VM volstaat 3. 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): ```bash # 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..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! ```bash sudo k3s etcd-snapshot restore /var/lib/rancher/k3s/server/db/snapshots/ ``` - [ ] 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 |