Migrated 2026-04-04: Replaced Traefik v39 with Istio 1.24.3 + Kubernetes Gateway API v1.2.0.
All IngressRoute/Ingress resources replaced with HTTPRoute. Per-service certs consolidated to single wildcard.
| Wave | Component | Purpose |
|---|---|---|
| 0 | gateway-api |
Gateway API v1.2.0 CRDs |
| 1 | istio-base |
Istio CRDs (VirtualService etc.) |
| 2 | istio-istiod |
Control plane (Pilot) — replaces Traefik |
| 3 | istio-gateway |
IngressGateway Deployment + Gateway resource |
| 13 | nas-ingress |
HTTPRoutes for NAS Docker apps |
| Setting | Value |
|---|---|
| Chart | metallb v0.15.2 |
| Namespace | metallb-system |
| Sync Wave | 1 |
| Mode | Layer 2 |
| VIP | 192.168.88.12 → Istio IngressGateway |
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
spec:
addresses:
- 192.168.88.10-192.168.88.30
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
Source-IP behaviour:
externalTrafficPolicyon the gateway-controller-managedhomelab-gateway-istioService isCluster. Cluster-internal traffic that traverses a different node before reaching the gateway is therefore SNATed to the chosen node's IP — CrowdSec sees192.168.88.240..249(worker node IPs) for any LAN client whose request was steered via a non-local node. The CrowdSec AppSec WAF compensates with an explicit on-match whitelist for192.168.0.0/16source IPs (see CrowdSec section below). External traffic via the Cloudflare tunnel is unaffected — the bouncer reads the client IP from the trustedX-Forwarded-Forheaders.
| Setting | Value |
|---|---|
| Chart | istio/gateway v1.24.3 |
| Namespace | istio-system |
| Sync Wave | 3 |
| External IP | 192.168.88.12 (MetalLB, same VIP as old Traefik) |
| Traffic Policy | Cluster (TODO: needs GatewayParameters to set Local) |
| Sidecar injection | Disabled — gateway-only mode |
| Resources | Requests: 100m / 128Mi |
A single shared homelab-gateway Gateway in istio-system handles all traffic:
| Listener | Port | Hostname | TLS Cert |
|---|---|---|---|
http |
80 | * | — (redirects to HTTPS) |
https-homelab |
443 | *.homelab.vyanh.uk |
homelab-wildcard-tls |
https-ntfy-public |
443 | ntfy.vyanh.uk |
ntfy-public-tls |
https-vault-public |
443 | vault.vyanh.uk |
vault-public-tls |
https-status-public |
443 | status.vyanh.uk |
status-public-tls |
https-tracker-public |
443 | tracker.vyanh.uk |
tracker-public-tls |
https-auth-public |
443 | auth.vyanh.uk |
auth-public-tls |
https-accounts-public |
443 | accounts.vyanh.uk |
accounts-public-tls |
https-dns-public |
443 | dns.vyanh.uk |
dns-public-tls |
https-wiki-public |
443 | wiki.vyanh.uk |
wiki-public-tls |
https-tasks-public |
443 | tasks.vyanh.uk |
tasks-public-tls |
All listeners have allowedRoutes.namespaces.from: All — HTTPRoutes in any namespace can attach.
Every service gets an HTTPRoute in its own namespace:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: myapp
namespace: myapp-namespace
spec:
parentRefs:
- name: homelab-gateway
namespace: istio-system
sectionName: https-homelab # which listener to attach to
hostnames:
- myapp.homelab.vyanh.uk
rules:
- backendRefs:
- name: myapp-service
port: 8080
For services with HTTPS upstreams (self-signed cert on backend), add a DestinationRule:
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: myapp-tls
namespace: myapp-namespace
spec:
host: myapp-service.myapp-namespace.svc.cluster.local
trafficPolicy:
portLevelSettings:
- port:
number: 8443
tls:
mode: SIMPLE
insecureSkipVerify: true
Services with both a *.homelab.vyanh.uk and a public domain use multiple parentRefs:
parentRefs:
- name: homelab-gateway
namespace: istio-system
sectionName: https-homelab
- name: homelab-gateway
namespace: istio-system
sectionName: https-vault-public
hostnames:
- vaultwarden.homelab.vyanh.uk
- vault.vyanh.uk
| Setting | Value |
|---|---|
| Chart | cert-manager v1.18.2 |
| Namespace | cert-manager |
| Sync Wave | 0 |
All *.homelab.vyanh.uk services now use a single wildcard cert in istio-system:
| Secret | Namespace | Covers |
|---|---|---|
homelab-wildcard-tls |
istio-system |
*.homelab.vyanh.uk + homelab.vyanh.uk |
ntfy-public-tls |
istio-system |
ntfy.vyanh.uk |
vault-public-tls |
istio-system |
vault.vyanh.uk |
status-public-tls |
istio-system |
status.vyanh.uk |
tracker-public-tls |
istio-system |
tracker.vyanh.uk |
auth-public-tls |
istio-system |
auth.vyanh.uk |
accounts-public-tls |
istio-system |
accounts.vyanh.uk |
dns-public-tls |
istio-system |
dns.vyanh.uk (DoH endpoint) |
wiki-public-tls |
istio-system |
wiki.vyanh.uk |
tasks-public-tls |
istio-system |
tasks.vyanh.uk (Vikunja) |
Previously: ~20 individual per-service Certificate CRDs across multiple namespaces.
Now: one wildcard for the internal domain plus one cert per public hostname, all inistio-system. NoReferenceGrantneeded because each cert lives in the Gateway's namespace. Add a public hostname by mirroring thewiki.vyanh.ukpattern — newCertificate, newhttps-<name>-publiclistener, then attach the HTTPRoute via a secondparentRefwith the matchingsectionName.
All HTTPS traffic passes through CrowdSec before reaching any backend. The integration uses the Envoy ext_authz gRPC protocol (replaces the old Traefik plugin).
| Setting | Value |
|---|---|
| Image | ghcr.io/kdwils/envoy-proxy-bouncer:v0.6.0 |
| Namespace | crowdsec |
| gRPC port | 8080 |
| Fail mode | fail-open (failure_mode_allow: true) |
| AppSec host | crowdsec-appsec-service.crowdsec.svc.cluster.local:7422 |
| API key | From crowdsec-bouncer-key secret (VSO-synced from kv/crowdsec/bouncer), key api_key |
| Trusted IPs | 192.168.88.0/24, 192.168.20.0/24, 192.168.100.0/24, 10.244.0.0/16 |
Note:
crowdsecurity/cs-envoy-bouncerdoes not exist on Docker Hub. The community implementationghcr.io/kdwils/envoy-proxy-bounceruses a different config schema — see config below.
server:
grpcPort: 8080
httpPort: 8081
logLevel: "info"
trustedProxies:
- "192.168.88.0/24"
bouncer:
enabled: true
# apiKey injected via ENVOY_BOUNCER_BOUNCER_APIKEY env var
lapiURL: "http://crowdsec-service.crowdsec.svc.cluster.local:8080"
tickerInterval: "10s"
waf:
enabled: true
# apiKey injected via ENVOY_BOUNCER_WAF_APIKEY env var
appSecURL: "http://crowdsec-appsec-service.crowdsec.svc.cluster.local:7422"
API keys are injected via env vars ENVOY_BOUNCER_BOUNCER_APIKEY and ENVOY_BOUNCER_WAF_APIKEY (both from the same crowdsec-bouncer-key secret, key api_key).
The AppSec component (OWASP CRS) runs as a separate crowdsec-appsec deployment. Two on-match expressions in appsec-config whitelist legitimate traffic the CRS would otherwise score-block:
on_match:
# K8s pods SNAT to a worker node IP (192.168.88.240..249) when traffic
# crosses nodes — every LAN client's request can therefore arrive with a
# 192.168.x source IP. Without this, internal apps trip CRS on common
# patterns (long URLs, multi-form bodies, etc.).
- filter: "req.RemoteAddr startsWith '192.168.'"
apply: ["allow"]
# Cluster-internal hostnames are trusted regardless of source IP.
- filter: "req.Host contains 'homelab.vyanh.uk'"
apply: ["allow"]
Field name gotcha (2026-04-28): the bouncer exposes the source IP as
req.RemoteAddr, notreq.Addr. An earlier version of the rule used the wrong field, so the whitelist silently never matched and CRS kept blocking LAN traffic.
A few services are incompatible with the ext_authz filter — typically because they use long-lived WebSockets, large request bodies, or are themselves DoH/health endpoints that must not be filtered. Each gets a small EnvoyFilter patch in istio-system that MERGEs disabled: true onto the route config:
| EnvoyFilter | Disables ext_authz for | Why |
|---|---|---|
crowdsec-ext-authz-ai-disable |
ai.homelab.vyanh.uk (Open WebUI) |
Streaming responses + long bodies |
crowdsec-ext-authz-dns-disable |
dns.vyanh.uk (DoH) |
Public DNS endpoint must not be rate-limited / blocked |
crowdsec-ext-authz-gitlab-disable |
gitlab.homelab.vyanh.uk |
Git LFS / push body sizes blow past max_request_bytes |
crowdsec-ext-authz-harbor-disable |
harbor.homelab.vyanh.uk |
Image blob uploads (Kaniko) blow past max_request_bytes |
crowdsec-ext-authz-uptime-disable |
uptime.homelab.vyanh.uk / status.vyanh.uk |
Uptime Kuma uses Socket.IO WebSockets (added 2026-04-28) |
An EnvoyFilter in istio-system inserts the ext_authz filter before the router on every request:
# core-components/istio-gateway/resources/crowdsec-authz.yaml
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: crowdsec-ext-authz
namespace: istio-system
spec:
workloadSelector:
labels:
# Gateway API creates the pod with this label — NOT "app: istio-ingressgateway"
gateway.networking.k8s.io/gateway-name: homelab-gateway
configPatches:
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
subFilter:
name: envoy.filters.http.router
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
grpc_service:
envoy_grpc:
cluster_name: outbound|8080||crowdsec-envoy-bouncer.crowdsec.svc.cluster.local
timeout: 0.5s
transport_api_version: V3
failure_mode_allow: true
with_request_body:
max_request_bytes: 8192
allow_partial_message: true
A central AuthorizationPolicy in istio-system denies requests from non-LAN IPs to sensitive services (replaces per-service ipAllowList Middleware):
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: local-only
namespace: istio-system
spec:
action: DENY
rules:
- from:
- source:
notRemoteIpBlocks:
- 192.168.88.0/24
- 192.168.20.0/24
- 192.168.100.0/24
- 10.244.0.0/16
to:
- operation:
hosts:
- nas.homelab.vyanh.uk
- portainer.homelab.vyanh.uk
- proxmox.homelab.vyanh.uk
- truenas.homelab.vyanh.uk
- omada.homelab.vyanh.uk
- wol.homelab.vyanh.uk
- dns-mikrotik.homelab.vyanh.uk
- dns.homelab.vyanh.uk
- dns2.homelab.vyanh.uk
- vault.homelab.vyanh.uk
- vault-ui.homelab.vyanh.uk
- minio.homelab.vyanh.uk
- s3.homelab.vyanh.uk
- door.homelab.vyanh.uk
- gitlab.homelab.vyanh.uk
istiod is configured with an nginx-style access log format so the crowdsecurity/nginx parser can read IngressGateway logs:
%REQ(X-FORWARDED-FOR?:DOWNSTREAM-REMOTE-ADDRESS-WITHOUT-PORT)% - - [%START_TIME%]
"%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%"
%RESPONSE_CODE% %BYTES_SENT% "%REQ(REFERER)%" "%REQ(USER-AGENT)%"
| Setting | Value |
|---|---|
| Sync Wave | 13 |
| Namespace | nas-ingress |
| Type | Service + Endpoints + HTTPRoute (+ DestinationRule for HTTPS backends) |
Each NAS Docker app gets:
homelab-gateway → https-homelab listener| App | Backend (host:port) | URL | Notes |
|---|---|---|---|
| Homepage | NAS :3000 |
homepage.homelab.vyanh.uk |
|
| Memos | NAS :5230 |
memos.homelab.vyanh.uk |
|
| IT Tools | NAS :4533 |
it-tools.homelab.vyanh.uk |
|
| Paperless-ngx | NAS :8010 |
paperless.homelab.vyanh.uk |
|
| Wiki.js | NAS :3080 |
wikijs.homelab.vyanh.uk + wiki.vyanh.uk |
public hostname added 2026-04-29 |
| Immich | NAS :2283 |
immich.homelab.vyanh.uk |
weighted 100:1 with K8s standby |
| Vaultwarden | NAS :8843 |
vaultwarden.homelab.vyanh.uk + vault.vyanh.uk |
weighted 100:0 with K8s standby |
| Uptime Kuma | RouterOS container 192.168.203.100:3001 |
uptime.homelab.vyanh.uk + status.vyanh.uk |
migrated off NAS 2026-04-28 |
| ntfy | NAS :2586 |
ntfy.vyanh.uk |
|
| Door controller | NAS :8766 |
door.homelab.vyanh.uk |
local-only |
| GitLab | NAS :3400 |
gitlab.homelab.vyanh.uk |
local-only |
| MinIO Console | NAS :9001 |
minio.homelab.vyanh.uk |
local-only |
| MinIO API (S3) | NAS :9000 |
s3.homelab.vyanh.uk |
local-only |
| Portainer | NAS :9443 |
portainer.homelab.vyanh.uk |
local-only, HTTPS upstream |
| NAS (DSM) | NAS :5001 |
nas.homelab.vyanh.uk |
local-only, HTTPS upstream |
| Proxmox | :8006 |
proxmox.homelab.vyanh.uk |
local-only, HTTPS upstream |
| TrueNAS | :443 |
truenas.homelab.vyanh.uk |
local-only, HTTPS upstream |
| WOL (UpSnap) | RouterOS container 192.168.200.100:8090 |
wol.homelab.vyanh.uk |
local-only |
| dns-mikrotik | RouterOS container :5380/8443 |
dns-mikrotik.homelab.vyanh.uk |
local-only, path: /dns-query → DoH HTTPS |
HTTPS upstream services need a DestinationRule with insecureSkipVerify: true since backends use self-signed certs.
# 1. Service (ClusterIP with Endpoints)
apiVersion: v1
kind: Service
metadata:
name: myapp
namespace: nas-ingress
annotations:
external-dns.alpha.kubernetes.io/hostname: myapp.homelab.vyanh.uk
external-dns.alpha.kubernetes.io/target: 192.168.88.12
spec:
ports:
- name: http
port: 8080
protocol: TCP
---
apiVersion: v1
kind: Endpoints
metadata:
name: myapp
namespace: nas-ingress
subsets:
- addresses:
- ip: 192.168.88.19 # NAS IP
ports:
- name: http
port: 8080
protocol: TCP
---
# 2. HTTPRoute
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: myapp
namespace: nas-ingress
spec:
parentRefs:
- name: homelab-gateway
namespace: istio-system
sectionName: https-homelab
hostnames:
- myapp.homelab.vyanh.uk
rules:
- backendRefs:
- name: myapp
port: 8080
| Setting | Value |
|---|---|
| Provider | rfc2136 against the Technitium primary ClusterIP (currently 10.99.188.56:53) |
| Sources | gateway-httproute (reads HTTPRoute hostnames) + service (reads Service annotations) |
| Domain filter | homelab.vyanh.uk only |
| Policy | upsert-only (never deletes — stale records must be removed manually) |
external-dns creates DNS A records for all HTTPRoute hostnames under homelab.vyanh.uk, pointing to the gateway VIP 192.168.88.12. Public domain records (vyanh.uk) are managed in Cloudflare separately.
Why ClusterIP, not the LB IP? Targeting
192.168.88.11(Technitium's MetalLB LoadBalancer IP) caused intermittent failures because the request had to leave the cluster, hairpin through MetalLB, and come back to the same pod. Switching to the Service ClusterIP (2026-04-20) keeps the TSIG update entirely on the pod network.
10% of requests are sampled and sent to Tempo (OTel format):
# istio-istiod/values.yaml
meshConfig:
extensionProviders:
- name: otel-tracing
opentelemetry:
service: tempo.monitoring.svc.cluster.local
port: "4317"
# istio-gateway/resources/gateway.yaml
apiVersion: telemetry.istio.io/v1
kind: Telemetry
metadata:
name: mesh-default
namespace: istio-system
spec:
tracing:
- providers:
- name: otel-tracing
randomSamplingPercentage: 10.0
kubectl get pods -n istio-system
kubectl get gateway -n istio-system homelab-gateway
kubectl get httproute -A
# Envoy bouncer should be Running
kubectl get pods -n crowdsec
# Check LAPI sees the bouncer
kubectl exec -n crowdsec deploy/crowdsec-lapi -- cscli bouncers list
# Check ext_authz EnvoyFilter is applied
kubectl get envoyfilter -n istio-system
kubectl get certificate -n istio-system
# homelab-wildcard-tls should be Ready=True
# From outside LAN — should get 403
curl -I https://portainer.homelab.vyanh.uk
# From LAN — should reach portainer
curl -I https://portainer.homelab.vyanh.uk # from 192.168.88.x
kubectl describe httproute -n <ns> <name>allowedRoutes.namespaces.from: Allkubectl logs -n istio-system -l gateway.networking.k8s.io/gateway-name=homelab-gateway