IKEv2 and WireGuard running in separate Kubernetes namespaces
Published: 2026-06-12
Both VPN protocols run inside the k0s cluster — not as bare processes on the host, but as Kubernetes pods. This is unusual (most guides run VPN directly on the host), but it makes secrets management and deployment consistent with everything else.
IKEv2 / strongSwan
04-setup-vpn.sh deploys hwdsl2/ipsec-vpn-server in the vpn namespace. This image wraps strongSwan and provides auto-configuration via environment variables.
Key deployment settings:
yamlspec:
template:
spec:
hostNetwork: true
containers:
- name: vpn
image: hwdsl2/ipsec-vpn-server
securityContext:
privileged: true
capabilities:
add: ["NET_ADMIN", "SYS_MODULE"]
env:
- name: VPN_IPSEC_PSK
valueFrom:
secretKeyRef:
name: vpn-secrets
key: VPN_IPSEC_PSK
- name: VPN_USER
valueFrom:
secretKeyRef:
name: vpn-secrets
key: VPN_USER
- name: VPN_PASSWORD
valueFrom:
secretKeyRef:
name: vpn-secrets
key: VPN_PASSWORD
volumeMounts:
- name: vpn-data
mountPath: /opt/vpn-data
volumes:
- name: vpn-data
hostPath:
path: /opt/vpn-data
type: DirectoryOrCreate
Why hostNetwork: true: strongSwan must bind to the host's UDP 500 and 4500 ports for IKE negotiation. These ports must be reachable directly by clients — they can't go through a Kubernetes Service, because IKE uses the client's source IP as part of the handshake.
Why privileged: true: needed for ipsec kernel modules. This is the biggest security tradeoff: a privileged pod can escape to the host. The alternative — running strongSwan directly on the host — would require managing it outside Kubernetes, which breaks the operational model.
Generated client configs (.mobileconfig for Apple, .sswan for Android) are written to /opt/vpn-data by the init container and can be retrieved from there.
WireGuard
08-setup-wireguard.sh deploys linuxserver/wireguard in the wireguard namespace. Unlike strongSwan, WireGuard runs entirely in user space via the wireguard-go implementation in the container.
yamlspec:
template:
spec:
hostNetwork: true
containers:
- name: wireguard
image: linuxserver/wireguard
securityContext:
capabilities:
add: ["NET_ADMIN", "SYS_MODULE"]
env:
- name: PEERS
value: "phone,laptop,tablet"
- name: SERVERURL
value: "91.184.248.13"
- name: SERVERPORT
value: "51820"
- name: PEERDNS
value: "1.1.1.1"
- name: INTERNAL_SUBNET
value: "10.0.0.0/24"
volumeMounts:
- name: wg-data
mountPath: /config
volumes:
- name: wg-data
hostPath:
path: /opt/wireguard-data
The PEERS variable generates a client config and QR code for each peer automatically. Retrieve configs:
bash# Get WireGuard config for laptop peer
kubectl exec -n wireguard deploy/wireguard -- cat /config/peer_laptop/peer_laptop.conf
# Get QR code for phone peer
kubectl exec -n wireguard deploy/wireguard -- cat /config/peer_phone/peer_phone.conf | qrencode -t ansiutf8
WireGuard uses UDP 51820. Since UDP traffic can't be load-balanced by kube-proxy's iptables rules, hostNetwork: true is also needed here.
Secrets management
VPN credentials are stored as SealedSecrets:
yamlapiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: vpn-secrets
namespace: vpn
spec:
encryptedData:
VPN_IPSEC_PSK: AgB...
VPN_USER: AgB...
VPN_PASSWORD: AgB...
To rotate credentials:
- Update
.env - Re-seal:
kubectl create secret generic vpn-secrets --from-env-file=.env --dry-run=client -o yaml | kubeseal ... - Commit the new SealedSecret
- Flux reconciles and the pod restarts with new credentials
Firewall rules
Both VPN protocols require specific ports open:
bash# IKEv2
ufw allow 500/udp
ufw allow 4500/udp
# WireGuard
ufw allow 51820/udp
These are global allows (not restricted by IP) because VPN is meant for use from anywhere.
Why run VPN inside k8s at all?
| Consideration | Kubernetes pod | Bare systemd service |
|---|---|---|
| Secret management | Kubernetes Secrets (same as everything else) | Config file on host |
| Auto-restart | restartPolicy: Always |
Restart=always (can fail permanently) |
| Observability | kubectl logs, pod metrics |
journalctl + custom exporter |
| Security | Privileged pod (IKEv2) | Process on host |
| Deployment | kubectl apply + Flux |
SSH + systemctl |
The real tradeoff is privileged: true on the IKEv2 pod. WireGuard is cleaner — only NET_ADMIN and SYS_MODULE, and on Linux 5.6+ the kernel module is available so SYS_MODULE isn't even needed. This could be tightened further with a custom seccomp profile.
Checking VPN status
bash# IKEv2 status
kubectl exec -n vpn deploy/ipsec-vpn-server -- ipsec status
# WireGuard status
kubectl exec -n wireguard deploy/wireguard -- wg show
# Pod logs
kubectl logs -n vpn deploy/ipsec-vpn-server
kubectl logs -n wireguard deploy/wireguard
What can go wrong
IKEv2 pod can't bind to UDP 500/4500.
Another process on the host may already be listening on those ports. Check with ss -ulpn | grep -E "500|4500". The pod requires hostNetwork: true and the ports to be free.
WireGuard peer can't connect after pod restart.
The linuxserver/wireguard image regenerates peer configs on startup if /config is empty. If the hostPath volume was deleted or the data directory changed, peers need new configs. Back up /opt/wireguard-data/ before any infrastructure changes.
privileged: true pod won't start.
Pod Security Admission in restricted or baseline mode blocks privileged pods. The vpn namespace needs pod-security.kubernetes.io/enforce: privileged label, or the pod needs a specific exemption.
Summary
- Both IKEv2 (strongSwan) and WireGuard run as Kubernetes pods with
hostNetwork: true— necessary because VPN protocols require direct access to host-level UDP ports - IKEv2 needs
privileged: true; WireGuard needs onlyNET_ADMIN+SYS_MODULE - VPN credentials are stored as SealedSecrets and rotated by re-sealing and committing a new manifest
- WireGuard peer configs and QR codes are auto-generated from the
PEERSenv variable at startup - Firewall rules for UDP 500/4500 (IKEv2) and UDP 51820 (WireGuard) must be added to the host's ufw/iptables