OpenVPN server in a Docker container running on Kubernetes.
Pre-requisites
We are using our Kubernetes homelab in this article.
Configuration files used in this article can be found on GitHub. Clone the following repositories:
$ git clone https://github.com/lisenet/kubernetes-homelab.git $ git clone https://github.com/lisenet/docker-openvpn.git
The Goal
We want to be able to access Kubernetes homelab subnet 10.11.1.0/24 from the Internet using a VPN connection on an Android device. This would allow us to access internally hosted services like:
- https://grafana.apps.hl.test
- https://prometheus.apps.hl.test
We also want to route all traffic through the VPN server (push default gateway).
The Plan
- Build a container image for the latest version of OpenVPN.
- Generate OpenVPN configuration files and certificates.
- Deploy OpenVPN on Kubernetes.
- Configure a destination NAT rule on Mikrotik router.
- Test VPN access from an Android client.
Build OpenVPN Docker Image (Optional)
This step is optional.
TL;DR: use lisenet/openvpn:latest docker image.
At the time of writing, OpenVPN image provided by kylemanna/openvpn:2.5 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 https://github.com/lisenet/docker-openvpn.git $ cd ./docker-openvpn $ docker build -t openvpn:latest . $ docker tag openvpn:latest lisenet/openvpn:latest $ docker push lisenet/openvpn:latest
Generate OpenVPN Configuration Files and Certificates
Pull scripts from GitHub:
$ git clone https://github.com/lisenet/docker-openvpn.git $ cd ./docker-openvpn/
Create a Kubernetes namespace:
$ kubectl create namespace openvpn
Set environment variables to be used to generate OpenVPN config:
$ export VPN_HOSTNAME="vpn.example.com" $ export DNS_SERVER="10.11.1.2"
Generate basic OpenVPN config:
$ ./bin/generate-config.sh
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/add-client.sh
Set the Kubernetes secrets. Prepend with REPLACE=true to update the existing ones:
$ ./bin/set-secrets.sh
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 kubernetes.io/service-account-token 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:
- We define
initContainersto configure IP forwarding on the pod. - We use a service type of
LoadBalancerto receive an IP address from MetalLB.
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000
globalDefault: false
description: "This priority class should be used for OpenVPN service pods only."
---
apiVersion: v1
kind: Service
metadata:
name: openvpn
namespace: openvpn
labels:
app: openvpn
spec:
selector:
app: openvpn
ports:
- name: openvpn
port: 31194
protocol: TCP
targetPort: openvpn
type: LoadBalancer
loadBalancerIP: 10.11.1.53
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: openvpn
namespace: openvpn
labels:
app: openvpn
spec:
replicas: 1
selector:
matchLabels:
app: openvpn
template:
metadata:
name: openvpn
labels:
app: openvpn
spec:
initContainers:
- image: busybox:1.36
imagePullPolicy: IfNotPresent
name: init-busybox
command:
- sh
- -c
- sysctl -w net.ipv4.ip_forward=1 && sysctl -w net.ipv4.conf.all.forwarding=1
securityContext:
capabilities:
add:
- NET_ADMIN
privileged: true
containers:
- image: lisenet/openvpn:latest
imagePullPolicy: Always
name: openvpn
ports:
- 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.
readinessProbe:
tcpSocket:
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.
livenessProbe:
tcpSocket:
port: 1194
initialDelaySeconds: 15
periodSeconds: 60
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 2
resources:
limits:
cpu: 1000m
memory: 128Mi
requests:
cpu: 10m
memory: 16Mi
securityContext:
capabilities:
add:
- NET_ADMIN
volumeMounts:
- 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
volumes:
- name: ovpn0-key
secret:
defaultMode: 384
secretName: ovpn0-key
- name: ovpn0-cert
secret:
defaultMode: 420
secretName: ovpn0-cert
- name: ovpn0-pki
secret:
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 10.107.175.151 10.11.1.53 31194:31844/TCP 43m
Configure Mikrotik Router
We want to allow connections from the internet to the OpenVPN service IP 10.11.1.53. 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=10.11.1.53 \ 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 vpn.example.com:31194 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 10.8.0.1/24 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=10.8.0.2 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]10.11.1.35:15520 2022-02-16 01:37:15 10.11.1.35:15520 TLS: Initial packet from [AF_INET]10.11.1.35:15520, sid=27615aef 0df1177d 2022-02-16 01:37:15 10.11.1.35:15520 VERIFY OK: depth=1, CN=vpn.example.com 2022-02-16 01:37:15 10.11.1.35:15520 VERIFY OK: depth=0, CN=android 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_VER=3.git::d3f8b18b:Release 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_PLAT=android 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_NCP=2 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_TCPNL=1 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_PROTO=30 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_CIPHERS=AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305:AES-256-CBC 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_IPv6=0 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_AUTO_SESS=1 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_GUI_VER=net.openvpn.connect.android_3.2.6-7729 2022-02-16 01:37:15 10.11.1.35:15520 peer info: IV_SSO=webauth,openurl 2022-02-16 01:37:15 10.11.1.35:15520 WARNING: 'link-mtu' is used inconsistently, local='link-mtu 1588', remote='link-mtu 1587' 2022-02-16 01:37:15 10.11.1.35:15520 WARNING: 'comp-lzo' is present in local config but missing in remote config, local='comp-lzo' 2022-02-16 01:37:15 10.11.1.35:15520 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 10.11.1.35:15520 [android] Peer Connection Initiated with [AF_INET]10.11.1.35:15520 2022-02-16 01:37:15 android/10.11.1.35:15520 MULTI_sva: pool returned IPv4=10.8.0.2, IPv6=(Not enabled) 2022-02-16 01:37:15 android/10.11.1.35:15520 MULTI: Learn: 10.8.0.2 -> android/10.11.1.35:15520 2022-02-16 01:37:15 android/10.11.1.35:15520 MULTI: primary virtual IP for android/10.11.1.35:15520: 10.8.0.2 2022-02-16 01:37:15 android/10.11.1.35:15520 Data Channel: using negotiated cipher 'AES-256-GCM' 2022-02-16 01:37:15 android/10.11.1.35:15520 Outgoing Data Channel: Cipher 'AES-256-GCM' initialized with 256 bit key 2022-02-16 01:37:15 android/10.11.1.35:15520 Incoming Data Channel: Cipher 'AES-256-GCM' initialized with 256 bit key 2022-02-16 01:37:15 android/10.11.1.35:15520 SENT CONTROL [android]: 'PUSH_REPLY,route 10.11.1.0 255.255.255.0,dhcp-option DNS 10.11.1.2,dhcp-option DNS 10.11.1.3,comp-lzo no,route-gateway 10.8.0.1,topology subnet,ping 10,ping-restart 60,ifconfig 10.8.0.2 255.255.255.0,peer-id 0,cipher AES-256-GCM' (status=1) 2022-02-16 01:37:15 android/10.11.1.35:15520 PUSH: Received control message: 'PUSH_REQUEST' 2022-02-16 01:38:04 android/10.11.1.35:15520 Connection reset, restarting [0] 2022-02-16 01:38:04 android/10.11.1.35:15520 SIGUSR1[soft,connection-reset] received, client-instance restarting
Credits
Docker image lisenet/openvpn:latest is based on kylemanna/docker-openvpn Dockerfile.
OpenVPN Kubernetes configuration is based on a Helm chart provided by suda/k8s-ovpn-chart.
References
https://github.com/kylemanna/docker-openvpn
https://github.com/suda/k8s-ovpn-chart

Hello,
thank you for article,if we want to scale open vpn pod,How it works? I mean is it possible to know which pod will host.Then pods has connection between of them