Featured image of post Wireguard VPN server on k8s

Wireguard VPN server on k8s

Until recently I had been using a Wireguard VPN server running on my router for remote access to my home network. I decided to move the VPN server to my Kubernetes to

  1. Free my router from the burden of running the VPN server
  2. Enable automatic failover (although currently the router is still SPOF for internet access)

Since my router is not particularly powerful and there is a lot of network traffic going on in my home network, I wanted to run as little workload on it as possible.

By running VPN server separately from the router, there is the additional benefit of future-proofing. If I decide to change my router configuration, the VPN server would not be affected at all as long as the new router forwards VPN traffic correctly.

Environment

  1. Router: TP-Link ER605
  2. Kubernetes: v1.33.4+k3s1
  3. ArgoCD: v3.0.6
  4. Wireguard: linuxserver/wireguard:1.0.20250521 (to be set up)

Kubernetes Deployment

For container image, I decided to use the popular linuxserver/wireguard image. It is well maintained and has rich configuration options through environment variables.

deployment.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wireguard
  namespace: vpn
spec:
  replicas: 1
  selector:
    matchLabels:
      app: wireguard
  template:
    metadata:
      labels:
        app: wireguard
    spec:
      containers:
      - name: wireguard
        image: linuxserver/wireguard:1.0.20250521
        securityContext:
          capabilities: # required for wireguard to function properly
            add:
            - NET_ADMIN
            - SYS_MODULE
          privileged: true
        env:
        - name: PUID
          value: "1000"
        - name: PGID
          value: "1000"
        - name: TZ
          value: "America/Denver"
        - name: SERVERURL
          value: "junyi.me"
        - name: SERVERPORT
          value: "51234" # port to be exposed on router
        - name: PEERS
          value: "1"
        - name: PEERDNS
          value: "10.0.69.253"
        - name: INTERNAL_SUBNET
          value: "10.0.100.0"
        - name: ALLOWEDIPS
          value: "10.0.0.0/16"
        ports:
        - containerPort: 51820
          protocol: UDP
        volumeMounts:
        - name: wireguard-config
          mountPath: /config
      volumes:
      - name: wireguard-config
        persistentVolumeClaim:
          claimName: wireguard-config

The SERVERURL and SERVERPORT environment variables should match the router’s public IP / hostname and the exposed port respectively.

important

SERVERURL and SERVERPORT are used by wireguard to generate client config files, which means they are used for incoming connections. Therefore they must be publicly accessible.

service.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
  name: wireguard
  namespace: vpn
spec:
  type: LoadBalancer
  ports:
  - port: 51234 # port that router forwards to
    targetPort: 51820
    protocol: UDP
  selector:
    app: wireguard
  loadBalancerIP: 10.0.69.238

For storage, I decided to use CephFS because

  1. It allows RWX access mode, which allows faster failover
  2. It’s easier to copy files like client config
cert.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
apiVersion: v1
kind: PersistentVolume
metadata:
  name: wireguard-config
  namespace: vpn
spec:
  storageClassName: cephfs-sdvault-sc
  accessModes:
  - ReadWriteMany
  capacity:
    storage: 1Gi
  csi:
    driver: cephfs.csi.ceph.com
    nodeStageSecretRef:
      name: csi-cephfs-secret
      namespace: ceph-csi-cephfs
    volumeAttributes:
      "fsName": "sdvault"
      "clusterID": "[redacted]"
      "staticVolume": "true"
      "rootPath": /wireguard
    volumeHandle: wireguard-config
  persistentVolumeReclaimPolicy: Retain
  volumeMode: Filesystem

---

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wireguard-config
  namespace: vpn
spec:
  accessModes:
  - ReadWriteMany
  resources:
    requests:
      storage: 1Gi
  storageClassName: cephfs-sdvault-sc
  volumeMode: Filesystem
  volumeName: wireguard-config

Testing

After running kubectl apply -f to spin up wireguard, I used my phone to test the VPN connection.

The client config was in

1
/config/peer1/peer1.conf

and QR code was in

1
/config/wireguard/peer1/peer1.png

On the Wireguard app on my phone, I scanned the QR code and connected to the VPN. Worked like a champ.

Wireguard connected on phone

ArgoCD application

The last step was to set up ArgoCD application.

argocd-wireguard.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: vpn-server
  namespace: argocd
  annotations:
    notifications.argoproj.io/subscribe.slack: production
spec:
  destination:
    namespace: network
    server: https://kubernetes.default.svc
  project: default
  source:
    path: kube/vpn
    repoURL: git@git.junyi.me:home/homelab.git
    targetRevision: master
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

I pushed the changes, and handed the management responsibility to ArgoCD.

Notification

Built with Hugo
Theme Stack designed by Jimmy