Gatus is now running in K8s with:
- FluxCD-managed deployment
- Ceph PVC for data
- ingress-nginx with CrowdSec bouncer
- Let's Encrypt TLS via cert-manager
- Internal DNS via ExternalDNS (UniFi)
- External DNS via ExternalDNS (Cloudflare tunnel)
First service fully migrated from Proxmox LXC to Kubernetes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DNS for status.renner.uno is now managed by K8s ExternalDNS.
ExternalDNS will create an A record pointing to the K8s ingress VIP.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
upsert-only won't update existing records. sync will update the
existing CNAME (ingress.renner.uno) to point to the K8s ingress VIP.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The chart probe references named port http-wh-metrics which needs
to be defined on the container.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Automatically creates UniFi DNS records for all K8s Ingresses,
pointing to the ingress VIP. Replaces OpenTofu ingress_cnames.
- All Ingresses → UniFi DNS (internal, no annotation needed)
- Annotated Ingresses → Cloudflare DNS (external tunnel)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Cloudflared: egress only to ingress-nginx (80/443), DNS, and
Cloudflare tunnel endpoints (world on 7844/443)
- ingress-nginx: egress to apps namespace, CrowdSec LAPI, DNS,
and external (OCSP/ACME)
- Default deny ingress to ingress namespace from other namespaces,
allow from world (external traffic), host (health probes), and
same namespace
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cloudflared is part of the ingress path, not an application.
Add SOPS decryption to infrastructure Kustomization for the
tunnel credentials secret.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Create new tunnel 'k8s-homelab' for K8s services. The old tunnel
stays with the Caddy/LXC setup. No remote config conflicts —
local config.yaml is the only config for this tunnel.
- New tunnel ID: d2386ccd-a665-41e7-8631-603fdafcb006
- New credentials (SOPS encrypted)
- ExternalDNS default target updated to new tunnel
- Only status.renner.uno for testing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ingress-nginx redirects HTTP→HTTPS (308). Configure tunnel to
connect via HTTPS with noTLSVerify (internal cluster traffic).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ExternalDNS ignores default-targets when the Ingress has a
LoadBalancer IP. Use --force-default-targets to always use the
tunnel CNAME instead of the internal VIP.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move ExternalDNS from infrastructure to apps (not a prerequisite)
- Set --default-targets with tunnel ID so Ingresses only need
the annotation external-dns.alpha.kubernetes.io/cloudflare-proxied
- Simplify Gatus Ingress annotation (no tunnel ID needed)
- SOPS secret for Cloudflare token co-located with ExternalDNS
To expose via tunnel: add one annotation to Ingress.
To switch to NetBird-only: remove the annotation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ExternalDNS watches Ingress annotations and creates proxied CNAME
records in Cloudflare pointing to the tunnel endpoint.
- Deploy ExternalDNS with Cloudflare provider
- Annotate Gatus Ingress with tunnel target
- annotationFilter ensures only explicitly annotated Ingresses get
public DNS (opt-in, not opt-out)
- policy: upsert-only prevents deleting records not managed by K8s
To expose a service via Cloudflare tunnel, add to Ingress:
external-dns.alpha.kubernetes.io/target: <tunnel-id>.cfargotunnel.com
external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"
To switch to NetBird-only: remove the annotations. No DNS record = VPN only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace remote-managed tunnel (--token) with local config file.
Ingress rules defined in ConfigMap — GitOps managed.
status.renner.uno routes to K8s ingress (test).
Legacy services route to existing Caddy backends.
As services migrate to K8s, update the ConfigMap to point
their hostname to the K8s ingress service.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add SOPS decryption to apps Kustomization. All secrets now managed
via SOPS + age, no ESO dependency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace External Secrets Operator + Vault with SOPS-encrypted secrets
in git, decrypted by FluxCD using an age key.
- Add .sops.yaml with age public key for encryption
- Encrypt Cloudflare API token with SOPS
- Remove ESO HelmRelease and Vault ClusterSecretStore
- Add decryption config to infrastructure-crds Kustomization
- Bootstrap step: create sops-age secret from local age key
No external dependencies. Age private key lives on workstation,
injected during bootstrap via:
kubectl create secret generic sops-age -n flux-system \
--from-file=age.agekey=$HOME/.config/sops/age/keys.txt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace token-based auth with Kubernetes ServiceAccount auth.
ESO authenticates using its ServiceAccount JWT. Talos cluster
secrets stored in Vault for PKI reuse across recreations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CiliumClusterwideNetworkPolicy with empty endpointSelector and
egress rules acts as a default-deny egress for ALL namespaces,
breaking DNS resolution and inter-service communication.
Keep only namespace-scoped policies for apps (default-deny ingress
+ allow from ingress-nginx). Clusterwide policies need more careful
design.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Kubelet liveness/readiness probes come from the host network.
Add host and remote-node entities to the default-deny policy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Default deny ingress to apps namespace (only same-namespace allowed)
- Allow ingress-nginx to reach app pods
- Allow Prometheus to scrape metrics from all namespaces
- Allow all pods to reach CoreDNS for DNS resolution
Replaces the Caddy DMZ VLAN isolation with K8s-native microsegmentation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cloudflare tunnel connector as a K8s Deployment (2 replicas).
Tunnel token pulled from Vault via ExternalSecret.
All Ansible vault secrets migrated to HashiCorp Vault.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The tofu provisioner can't run Ansible with dynamic inventory in
the CI container. Use the dedicated run-ansible pipeline instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CrowdSec is in infrastructure stage which must complete before
Ceph cluster deploys. CrowdSec PVCs need Ceph. Disable PVCs
for now — CrowdSec state is ephemeral (bouncer decisions rebuild
quickly from the crowd).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace SealedSecrets with Vault-backed External Secrets:
- New LXC 240 (vault.srv.renner.uno) for HashiCorp Vault
- Ansible role + playbook for Vault with Raft storage backend
- External Secrets Operator HelmRelease in K8s
- ClusterSecretStore pointing to Vault
- ExternalSecret for Cloudflare API token
- Remove SealedSecrets from infrastructure
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the fragile kubectl-exec Job with a simple static Secret
in both crowdsec and ingress namespaces. CrowdSec LAPI pre-registers
the bouncer via BOUNCER_KEY_ingress env var. The key is for internal
cluster communication only.
Also removes RBAC resources that were needed for the Job.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move CrowdSec to infrastructure stage with a Job that:
1. Waits for CrowdSec LAPI to be ready
2. Generates a bouncer API key via cscli
3. Creates a K8s Secret in the ingress namespace
ingress-nginx HelmRelease depends on CrowdSec, uses the CrowdSec
controller image, and reads the bouncer key from the provisioned
secret. No SealedSecrets or hardcoded keys needed.
Flow: CrowdSec deploys → Job creates bouncer key → ingress-nginx
starts with bouncer plugin authenticated to LAPI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SealedSecrets were encrypted with the previous cluster's key.
Re-sealed with the current sealed-secrets controller key after
VM recreation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The bouncer init container blocks ingress startup when the secret
doesn't exist yet (CrowdSec deploys after infrastructure). Deploy
ingress-nginx as standard first; CrowdSec bouncer integration
needs to be solved as a post-deploy step.
CrowdSec still monitors logs and can ban IPs at the network level.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add optional: true to secretKeyRef so ingress pods start without
the bouncer key (CrowdSec deploys after infrastructure)
- Move ingress bouncer SealedSecret to crowdsec app (creates in
ingress namespace when CrowdSec deploys)
- Ingress starts without CrowdSec, bouncer activates after secret appears
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SealedSecret CRD doesn't exist when infrastructure kustomization
first applies. Move to post-crds which depends on infrastructure.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace hardcoded bouncer key with SealedSecrets in both crowdsec
and ingress namespaces. CrowdSec LAPI pre-registers the bouncer
via BOUNCER_KEY_ingress env var, ingress-nginx reads the same key
from its namespace's secret.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CrowdSec agents need hostPath volumes to read container logs, which
requires a privileged PodSecurity namespace. Move from apps to
dedicated crowdsec namespace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add encrypted Cloudflare DNS API token for Let's Encrypt DNS-01
challenges. Set ACME account email on ClusterIssuers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add l2announcements.enabled and externalIPs.enabled to the Cilium
helm template used for Talos bootstrap. This ensures LoadBalancer
VIPs work immediately without needing a Cilium agent restart after
FluxCD upgrades the HelmRelease.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cilium with kubeProxyReplacement=true has native LoadBalancer support
with L2 announcements. MetalLB L2 VIPs weren't reachable because
Cilium was handling the service datapath.
- Enable l2announcements and externalIPs in Cilium HelmRelease
- Add CiliumLoadBalancerIPPool (10.2.40.64-95) and L2AnnouncementPolicy
- Remove MetalLB from infrastructure
- Remove MetalLB annotation from ingress-nginx service
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First app migration to Kubernetes. Gatus monitors all homelab services
via HTTP/TCP/DNS health checks. Deployed as a Deployment with ConfigMap
for endpoints config, 1Gi ceph-block PVC for SQLite, and Ingress on
status.renner.uno with cert-manager TLS.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>