OpenVPN Server on Kubernetes

OpenVPN server in a Docker container running on Kubernetes.


We are using our Kubernetes homelab in this article.

Configuration files used in this article can be found on GitHub. Clone the following repository:

$ git clone

The Goal

We want to be able to access Kubernetes homelab subnet from the Internet using a VPN connection on an Android device. This would allow us to access internally hosted services like:

  1. https://grafana.apps.hl.test
  2. https://prometheus.apps.hl.test

We also want to route all traffic through the VPN server (push default gateway).

The Plan

  1. Build a docker image for OpenVPN 2.5.
  2. Generate OpenVPN configuration files and certificates.
  3. Deploy OpenVPN on Kubernetes.
  4. Configure a destination NAT rule on Mikrotik router.
  5. Test VPN access from an Android client.

Build OpenVPN Docker Image (Optional)

This step is optional.

TL;DR: use lisenet/openvpn:2.5 docker image.

At the time of writing, OpenVPN image provided by kylemanna/openvpn:latest has not been updated since 2020, which makes it slightly out of date if you ask me.

We will build a new image and push it to Docker Hub.

$ git clone
$ cd ./docker-openvpn
$ docker build -t openvpn:2.5 .
$ docker tag openvpn:2.5 lisenet/openvpn:2.5
$ docker push lisenet/openvpn:2.5

Generate OpenVPN Configuration Files and Certificates

Pull scripts from GitHub:

$ git clone
$ cd ./kubernetes-homelab/openvpn/

Create a Kubernetes namespace:

$ kubectl create namespace openvpn

Set environment variables to be used to generate OpenVPN config:

$ export VPN_HOSTNAME=""
$ export DNS_SERVER=""

Generate basic OpenVPN config:

$ ./bin/

Change ownership of ovpn0 folder so that we can write to it:

$ sudo chown -R "${USER}:${USER}" ./ovpn0/

Generate a client config (can be repeated for any new client):

$ export CLIENT_NAME=android
$ ./bin/

Set the Kubernetes secrets. Prepend with REPLACE=true to update the existing ones:

$ ./bin/

Note: VPN config, certificates and keys are stored in the ovpn0 directory on the machine that was used to run the commands.

$ kubectl get cm -n openvpn
NAME                 DATA   AGE
ccd0                 0      1h
istio-ca-root-cert   1      1h
kube-root-ca.crt     1      1h
ovpn0-conf           2      1h
$ kubectl get secrets -n openvpn
NAME                  TYPE                                  DATA   AGE
default-token-wd9q8   3      1h
ovpn0-cert            Opaque                                1      1h
ovpn0-key             Opaque                                1      1h
ovpn0-pki             Opaque                                3      1h

Deploy OpenVPN on Kubernetes

Content of the file openvpn-deployment.yaml can be seen below.

Note a couple of things:

  1. We define initContainers to configure IP forwarding on the pod.
  2. We use a service type of LoadBalancer to receive an IP address from MetalLB.
kind: PriorityClass
  name: high-priority
value: 1000000
globalDefault: false
description: "This priority class should be used for OpenVPN service pods only."
apiVersion: v1
kind: Service
  name: openvpn
  namespace: openvpn
    app: openvpn
    app: openvpn
  - name: openvpn
    port: 31194
    protocol: TCP
    targetPort: openvpn
  type: LoadBalancer
apiVersion: apps/v1
kind: Deployment
  name: openvpn
  namespace: openvpn
    app: openvpn
  replicas: 1
      app: openvpn
      name: openvpn
        app: openvpn
      - image: busybox:1.35.0
        imagePullPolicy: IfNotPresent
        name: init-busybox
        - sh
        - -c
        - sysctl -w net.ipv4.ip_forward=1 && sysctl -w net.ipv4.conf.all.forwarding=1
            - NET_ADMIN
          privileged: true
      - image: lisenet/openvpn:2.5
        imagePullPolicy: IfNotPresent
        name: openvpn
        - containerPort: 1194
          name: openvpn
          protocol: TCP
        # The kubelet will send the first readiness probe 5 seconds after the container starts.
        # This will attempt to connect to the openvpn container on port 1194. If the probe succeeds,
        # the Pod will be marked as ready. The kubelet will continue to run this check every 30 seconds.
            port: 1194
          initialDelaySeconds: 5
          periodSeconds: 30
          timeoutSeconds: 5
          successThreshold: 1
          failureThreshold: 3
        # In addition to the readiness probe, this configuration includes a liveness probe.
        # The kubelet will run the first liveness probe 15 seconds after the container starts.
        # Similar to the readiness probe, this will attempt to connect to the container on port 1194.
        # If the liveness probe fails, the container will be restarted.
            port: 1194
          initialDelaySeconds: 15
          periodSeconds: 60
          timeoutSeconds: 5
          successThreshold: 1
          failureThreshold: 2
            cpu: 1000m
            memory: 128Mi
            cpu: 10m
            memory: 16Mi
            - NET_ADMIN
        - mountPath: /etc/openvpn/pki/private
          name: ovpn0-key
        - mountPath: /etc/openvpn/pki/issued
          name: ovpn0-cert
        - mountPath: /etc/openvpn/pki
          name: ovpn0-pki
        - mountPath: /etc/openvpn
          name: ovpn0-conf
        - mountPath: /etc/openvpn/ccd
          name: ccd0
      priorityClassName: high-priority
      restartPolicy: Always
      terminationGracePeriodSeconds: 30
      - name: ovpn0-key
          defaultMode: 384
          secretName: ovpn0-key
      - name: ovpn0-cert
          defaultMode: 420
          secretName: ovpn0-cert
      - name: ovpn0-pki
          defaultMode: 384
          secretName: ovpn0-pki
      - configMap:
          defaultMode: 420
          name: ovpn0-conf
        name: ovpn0-conf
      - configMap:
          defaultMode: 420
          name: ccd0
        name: ccd0

Deploy OpenVPN:

$ kubectl apply -f openvpn-deployment.yaml

List pods and services to verify.

$ kubectl get pods -n openvpn
NAME                      READY   STATUS    RESTARTS   AGE
openvpn-8f548449f-cbqxm   1/1     Running   0          43m
$ kubectl get svc -n openvpn
NAME      TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)           AGE
openvpn   LoadBalancer    31194:31844/TCP   43m 

Configure Mikrotik Router

We want to allow connections from the internet to the OpenVPN service IP In this case, we have to configure a destination address translation rule on our Mikrotik router.

Create a destination NAT rule:

/ip firewall nat add chain=dstnat \
  action=dst-nat \
  in-interface=ether1_isp \
  dst-port=31194 \
  to-addresses= \
  to-ports=31194 \
  protocol=tcp \
  comment="Allow public to OpenVPN"

At this point, assuming that the DNS record for VPN_HOSTNAME points to the IP address of the router, we should be able to access the OpenVPN server on from the Internet.

Test VPN Access from Android Device

Download OpenVPN Connect client from Google Play.

Import ./ovpn0/android.ovpn client configuration file and connect to the VPN server.

Check pod logs.

$ kubectl -n openvpn logs $(kubectl -n openvpn get pods -o name)
Checking IPv6 Forwarding
Sysctl error for disable_ipv6, please run docker with '--sysctl net.ipv6.conf.all.disable_ipv6=0'
Sysctl error for default forwarding, please run docker with '--sysctl net.ipv6.conf.default.forwarding=1'
Sysctl error for all forwarding, please run docker with '--sysctl net.ipv6.conf.all.forwarding=1'
Running 'openvpn --config /etc/openvpn/openvpn.conf --client-config-dir /etc/openvpn/ccd '
2022-02-16 01:36:44 DEPRECATED OPTION: --cipher set to 'AES-256-CBC' but missing in --data-ciphers (AES-256-GCM:AES-128-GCM). Future OpenVPN version will ignore --cipher for cipher negotiations. Add 'AES-256-CBC' to --data-ciphers or change --cipher 'AES-256-CBC' to --data-ciphers-fallback 'AES-256-CBC' to silence this warning.
2022-02-16 01:36:44 OpenVPN 2.5.4 x86_64-alpine-linux-musl [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [MH/PKTINFO] [AEAD] built on Nov 15 2021
2022-02-16 01:36:44 library versions: OpenSSL 1.1.1l  24 Aug 2021, LZO 2.10
2022-02-16 01:36:44 Diffie-Hellman initialized with 2048 bit key
2022-02-16 01:36:44 Outgoing Control Channel Authentication: Using 384 bit message hash 'SHA384' for HMAC authentication
2022-02-16 01:36:44 Incoming Control Channel Authentication: Using 384 bit message hash 'SHA384' for HMAC authentication
2022-02-16 01:36:44 TUN/TAP device tun0 opened
2022-02-16 01:36:44 /sbin/ip link set dev tun0 up mtu 1500
2022-02-16 01:36:44 /sbin/ip link set dev tun0 up
2022-02-16 01:36:44 /sbin/ip addr add dev tun0
2022-02-16 01:36:44 Could not determine IPv4/IPv6 protocol. Using AF_INET
2022-02-16 01:36:44 Socket Buffers: R=[87380->87380] S=[16384->16384]
2022-02-16 01:36:44 Listening for incoming TCP connection on [AF_INET][undef]:1194
2022-02-16 01:36:44 TCPv4_SERVER link local (bound): [AF_INET][undef]:1194
2022-02-16 01:36:44 TCPv4_SERVER link remote: [AF_UNSPEC]
2022-02-16 01:36:44 GID set to nogroup
2022-02-16 01:36:44 UID set to nobody
2022-02-16 01:36:44 MULTI: multi_init called, r=256 v=256
2022-02-16 01:36:44 IFCONFIG POOL IPv4: base= size=252
2022-02-16 01:36:44 MULTI: TCP INIT maxclients=1024 maxevents=1028
2022-02-16 01:36:44 Initialization Sequence Completed
2022-02-16 01:37:15 Outgoing Control Channel Authentication: Using 384 bit message hash 'SHA384' for HMAC authentication
2022-02-16 01:37:15 Incoming Control Channel Authentication: Using 384 bit message hash 'SHA384' for HMAC authentication
2022-02-16 01:37:15 TCP connection established with [AF_INET]
2022-02-16 01:37:15 TLS: Initial packet from [AF_INET], sid=27615aef 0df1177d
2022-02-16 01:37:15 VERIFY OK: depth=1,
2022-02-16 01:37:15 VERIFY OK: depth=0, CN=android
2022-02-16 01:37:15 peer info: IV_VER=3.git::d3f8b18b:Release
2022-02-16 01:37:15 peer info: IV_PLAT=android
2022-02-16 01:37:15 peer info: IV_NCP=2
2022-02-16 01:37:15 peer info: IV_TCPNL=1
2022-02-16 01:37:15 peer info: IV_PROTO=30
2022-02-16 01:37:15 peer info: IV_CIPHERS=AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305:AES-256-CBC
2022-02-16 01:37:15 peer info: IV_IPv6=0
2022-02-16 01:37:15 peer info: IV_AUTO_SESS=1
2022-02-16 01:37:15 peer info: IV_GUI_VER=net.openvpn.connect.android_3.2.6-7729
2022-02-16 01:37:15 peer info: IV_SSO=webauth,openurl
2022-02-16 01:37:15 WARNING: 'link-mtu' is used inconsistently, local='link-mtu 1588', remote='link-mtu 1587'
2022-02-16 01:37:15 WARNING: 'comp-lzo' is present in local config but missing in remote config, local='comp-lzo'
2022-02-16 01:37:15 Control Channel: TLSv1.3, cipher TLSv1.3 TLS_AES_256_GCM_SHA384, peer certificate: 2048 bit RSA, signature: RSA-SHA256
2022-02-16 01:37:15 [android] Peer Connection Initiated with [AF_INET]
2022-02-16 01:37:15 android/ MULTI_sva: pool returned IPv4=, IPv6=(Not enabled)
2022-02-16 01:37:15 android/ MULTI: Learn: -> android/
2022-02-16 01:37:15 android/ MULTI: primary virtual IP for android/
2022-02-16 01:37:15 android/ Data Channel: using negotiated cipher 'AES-256-GCM'
2022-02-16 01:37:15 android/ Outgoing Data Channel: Cipher 'AES-256-GCM' initialized with 256 bit key
2022-02-16 01:37:15 android/ Incoming Data Channel: Cipher 'AES-256-GCM' initialized with 256 bit key
2022-02-16 01:37:15 android/ SENT CONTROL [android]: 'PUSH_REPLY,route,dhcp-option DNS,dhcp-option DNS,comp-lzo no,route-gateway,topology subnet,ping 10,ping-restart 60,ifconfig,peer-id 0,cipher AES-256-GCM' (status=1)
2022-02-16 01:37:15 android/ PUSH: Received control message: 'PUSH_REQUEST'
2022-02-16 01:38:04 android/ Connection reset, restarting [0]
2022-02-16 01:38:04 android/ SIGUSR1[soft,connection-reset] received, client-instance restarting


Docker image lisenet/openvpn:2.5 is based on kylemanna/docker-openvpn Dockerfile.

OpenVPN Kubernetes configuration is based on a Helm chart provided by suda/k8s-ovpn-chart.


Leave a Reply

Your email address will not be published.