Sign inSign up

oorabona/openvpn

By oorabona

Updated 3 days ago

openvpn container

Image
1

10K+

oorabona/openvpn repository overview

🔐 OpenVPN Docker Container built from sources with advanced security features

Docker Image Version (latest semver) Docker Pulls Docker Stars GHCR

This is a simple Alpine based container with OpenVPN built from sources.

Platforms

  • amd64 Docker Image Size AMD64
  • arm64 Docker Image Size ARM64

Features

  • 🔐 Built from sources
  • Dependant library pkcs11-helper from the Alpine repository, signature-verified by apk
  • Embed Google Authenticator support

Verify this image

Every build ships a Sigstore-signed SBOM and a full Trivy scan — verify them yourself, no login required:

gh attestation verify oci://ghcr.io/oorabona/openvpn:latest --owner oorabona

Full walkthrough (SBOM payload, Trivy findings, multi-arch manifest inspection, upstream dependency tracking) → https://oorabona.github.io/docker-containers/verify-images/

Usage

Docker
docker run -d --name openvpn \
    -p 1194:1194/udp \
    -v openvpn-data:/etc/openvpn \
    --cap-drop=ALL \
    --cap-add=NET_ADMIN \
    --cap-add=SETUID \
    --cap-add=SETGID \
    --security-opt no-new-privileges \
    --device=/dev/net/tun \
    --sysctl net.ipv6.conf.all.disable_ipv6=0 \
    --sysctl net.ipv6.conf.all.forwarding=1 \
    --sysctl net.ipv4.ip_forward=1 \
    --sysctl net.ipv4.conf.all.forwarding=1 \
    -e START_EXISTING=y \
    -e AUTO_INSTALL=y \
    -e AUTO_START=y \
    oorabona/openvpn

First-run storage under cap_drop: ALL. The example uses a named volume (openvpn-data) because AUTO_INSTALL generates the PKI and server.conf into /etc/openvpn on first start, and cap_drop: ALL removes the DAC_OVERRIDE/FOWNER/CHOWN capabilities that let root bypass filesystem permissions. A named volume is created root-owned by the daemon, so that generation always succeeds. If you bind-mount a host directory instead, make sure it is writable by the container's root (uid 0) — otherwise first-run generation fails.

Docker Compose
version: '3.7'

services:
  openvpn:
    image: oorabona/openvpn
    container_name: openvpn
    # First run bootstraps the PKI + server.conf into the empty named volume
    # (AUTO_INSTALL=y) and starts the server (AUTO_START=y). START_EXISTING=y makes
    # every subsequent start bring up the existing server non-interactively, so this
    # is safe under `restart: unless-stopped` (see "Lifecycle" below). Set
    # ENDPOINT=<host> for a fully non-interactive first install.
    environment:
      - START_EXISTING=y
      - AUTO_INSTALL=y
      - AUTO_START=y
      - ENDPOINT=vpn.example.com # Set to the real public host/IP.
    cap_add:
      - NET_ADMIN
      - SETUID
      - SETGID
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    devices:
        - /dev/net/tun
    sysctls:
        - net.ipv6.conf.all.disable_ipv6=0
        - net.ipv6.conf.all.forwarding=1
        - net.ipv4.ip_forward=1
        - net.ipv4.conf.all.forwarding=1
    ports:
      - 1194:1194/udp
    volumes:
      - openvpn_config:/etc/openvpn
    restart: unless-stopped

volumes:
  openvpn_config:
Lifecycle

On an empty volume, AUTO_INSTALL=y + AUTO_START=y generates the PKI + server.conf and starts the server (set ENDPOINT=<host> for a fully non-interactive first install). START_EXISTING=y then makes every subsequent start bring up the already-installed server non-interactively — it takes precedence over AUTO_INSTALL when a config already exists — so the container is safe to run as a restart: unless-stopped service: a restart brings the VPN back up instead of re-running setup or dropping into the management menu. To manage clients (add/revoke), open the installer's management menu with a one-shot interactive Compose run against the deployed config volume while clearing START_EXISTING and AUTO_INSTALL (those would start the server instead of the menu): docker compose run --rm -e START_EXISTING= -e AUTO_INSTALL= openvpn.

START_EXISTING applies to this Alpine image and expects the installer-generated foreground config; a config carrying a daemon directive is rejected up front. Restart-safety assumes the first install completed: START_EXISTING starts whatever config exists, so if the initial bootstrap is interrupted after server.conf is written but before the PKI finishes, reset the config volume and bootstrap again rather than looping on a half-installed config. (Resolves #912.)

Configuration

OpenVPN

The container is configured to use the server.conf file located in /etc/openvpn as default configuration file. This file is generated from the script setup.sh located in /usr/local/bin and is based on the following environment variables:

  • APPROVE_INSTALL
  • IPV4_SUPPORT
  • IPV6_SUPPORT
  • PORT_CHOICE
  • PROTOCOL_CHOICE
  • DNS
  • COMPRESSION_ENABLED
  • CUSTOMIZE_ENC
  • CLIENT
  • PASS
  • CONTINUE
  • CLIENT_TO_CLIENT
  • BLOCK_OUTSIDE_DNS
  • OTP
  • EASYRSA_CRL_DAYS
  • SUBNET_IPv4
  • SUBNET_IPv6
  • SUBNET_MASKv4
  • SUBNET_MASKv6
  • ENDPOINT

For details about the meaning of each variable, please refer to the documentation.

Two more variables name the PKI. Both are optional; left unset, the installer generates a random name as before.

VariableNamesDescription
OPENVPN_SERVER_CNthe CAThe common name of the certificate authority the install creates.
OPENVPN_SERVER_NAMEthe server certificateThe name the server's own certificate is issued to. Client profiles pin it with verify-x509-name, so it is the name a client checks the server by. It cannot equal CLIENT — the server certificate and the first client would collide.

Both take effect only while the PKI is being built — that is, while /etc/openvpn/easy-rsa is not yet a directory. A fresh volume qualifies, even though /etc/openvpn itself already exists as the mount point. Once the PKI is there the names are fixed: setting either variable has no effect on it, and changing them means building a new PKI on an empty volume. The installer may print a note when it ignores one, but do not rely on seeing it.

Three variables control the container's lifecycle:

VariableDefaultDescription
AUTO_INSTALLny runs a non-interactive install (generates the PKI + server.conf) when no config exists. Left unset with a config already present, the entrypoint opens an interactive management menu instead of starting the server.
AUTO_STARTny starts the OpenVPN server after install (required for the server to actually run on this Alpine image).
START_EXISTINGny starts an already-installed server non-interactively on restart, taking precedence over AUTO_INSTALL when a config exists — makes the container safe under restart: unless-stopped. Alpine (OS=other), installer-generated foreground configs only.
Google Authenticator

The container is configured to use the google-authenticator library to generate the OTP code. The generated QR code is stored in the container on a per user basis under the /etc/openvpn/otp directory. The QR code can be retrieved using the following command:

docker exec -it openvpn cat /etc/openvpn/otp/username.png

More information can be found on the wiki.

Upgrading an OTP deployment created before this image

/etc/openvpn/otp holds one-time password secrets, and the installer checks that an existing one is private before writing into it. Earlier images created that directory with the default mode, usually 755, so on a volume carried over from one of them adding another OTP client fails with:

Refusing to write one-time password secrets: /etc/openvpn/otp must give its owner rwx and neither group nor other any access (found mode 755).

That directory was created by root, so tightening it needs no capability beyond what the container already has. Existing secrets are untouched and no reinstall is needed:

docker exec openvpn chmod 700 /etc/openvpn/otp &&
    docker exec openvpn stat -c '%U %a' /etc/openvpn/otp

Expect root 700. Anything else means the change did not take, and the cause is worth identifying rather than guessing: a read-only or root-squashed mount, a filesystem that carries no POSIX modes (some host bind mounts), or a directory whose owner is not root. That last case cannot be repaired from inside the container — the run command above drops every capability except NET_ADMIN, SETUID and SETGID, so chown is unavailable. Fix ownership on the host, or move /etc/openvpn to a named volume.

/etc/openvpn/clients, which holds client profiles and their keys, is checked the same way and can need the same treatment.

Build Arguments

The following build arguments can be passed to customize the container build:

ArgumentDefaultDescription
VERSIONlatestOpenVPN version to build
UPSTREAM_VERSION(empty)Fallback upstream version if VERSION is not specified
OS_VERSIONlatestAlpine Linux version tag — 3.24 or newer, the releases carrying pkcs11-helper
EASYRSA_VERSION3.2.6EasyRSA version for certificate management
NPROC1Number of parallel processes for compilation
PKCS11_HELPER_VERSIONRefused. The build stops if it is set: pkcs11-helper now comes from Alpine, so its version follows OS_VERSION

Build options

OpenVPN is built from sources using the following options:

  • --enable-iproute2 option to use the ip command instead of ifconfig
  • --enable-pkcs11 option to enable the pkcs11-helper library and support of PKCS#11 tokens (e.g. Yubikey)
  • --enable-plugin-auth-pam option to enable the pam authentication plugin (e.g. Google Authenticator uses this)
  • --enable-async-push option to allow asynchronous push of configuration options to the client (and not wait for a remote authentification request to be completed)
  • --enable-plugin-down-root option to allow the down-root plugin to be used (e.g. to drop privileges after the connection is established). Although this option is enabled, the down-root plugin is not used by default.
  • --enable-selinux option to enable the selinux support
  • --disable-systemd option to disable the systemd support
  • --disable-debug option to make the binary smaller
  • --disable-lzo and --disable-lz4 options to disable the lzo and lz4 compression support (prone to side-channel attacks)
  • --enable-comp-stub option to disable all compression altogether (still allow limited interoperability with compression-enabled peers)

Security

SELinux

The container is configured to run with the spc_t SELinux context. This context is configured to allow the container to access the following resources:

  • /etc/openvpn directory
  • /etc/openvpn/otp directory
  • /etc/openvpn/otp/* files
Capabilities

Run with cap_drop: ALL and only these capabilities added back:

  • NET_ADMIN — create the tun device and configure routes/firewall rules
  • SETUID / SETGID — let openvpn drop to nobody/nogroup after setup

NET_RAW is not needed — openvpn's UDP transport uses ordinary sockets, not raw ones. These caps cover the default port (1194). If you configure openvpn to bind an internal port below 1024, add NET_BIND_SERVICE as well (the initial bind happens as root before the drop, and cap_drop: ALL removes the privileged-port capability). Mapping a privileged host port to 1194 (-p 443:1194/udp) needs nothing extra.

Privileges

openvpn starts as root — creating the tun device, routes, and firewall rules genuinely requires it — then drops to the unprivileged nobody user/nogroup for the lifetime of the tunnel, provided the running server.conf specifies user nobody / group nogroup (with persist-tun / persist-key so the already-open tun survives the drop). The image's own AUTO_INSTALL generates such a config; if you mount your own server.conf, add those directives yourself — without them openvpn keeps running as root.

The SETUID/SETGID capabilities are what let that drop complete under cap_drop: ALL. They aren't optional for a user/group config: openvpn treats a failed privilege drop as fatal, so without these caps the container fails to start rather than silently running as root.

This drops the OpenVPN server worker to nobody; it does not make the whole container rootless. The entrypoint wrapper that adds and removes the iptables NAT rules around the tunnel stays alive as root for the server's lifetime, and OTP/PAM authentication (when enabled) uses a root helper. So the profile removes NET_RAW and all other capabilities, runs under no-new-privileges, and drops the server worker to nobody — a meaningful reduction, not a fully rootless container.

The setuid/setgid capability requirement was verified directly under cap_drop: ALL (setuid to nobody fails with only NET_ADMIN and succeeds once SETUID/SETGID are added back). Exercising openvpn's full end-to-end drop needs a live tunnel with a real tun device, which the image's current CI e2e does not stand up — that coverage is tracked in #910. Treat the reduced-capability profile as verified at the capability level, not yet exercised end-to-end by CI.

Security options

The default hardened profile (the run/Compose examples above) sets only no-new-privileges.

On an SELinux-enforcing host the container additionally needs host-policy- specific run options — relaxing seccomp/AppArmor and setting the container's SELinux label so it can manage the tun device and iptables under the spc_t context described above. Those settings depend on your host's policy, are not part of the image's default profile, and are not general hardening recommendations — consult your platform's SELinux + container documentation for the exact values.

Dependencies

The following third-party dependencies are pinned and monitored for updates:

DependencyVersionSourceMonitoring
EasyRSA3.2.6GitHub Release (OpenVPN/easy-rsa)Enabled

pkcs11-helper is deliberately absent from that table: it comes from the Alpine repository, so its version follows the base image and apk verifies its signature. Nothing here pins or monitors it. Alpine carries it in main from 3.24 onward, so OS_VERSION has that floor.

References

License

MIT

Other projects

Tag summary

Content type

Image

Digest

sha256:f4eb636cd

Size

15.6 MB

Last updated

3 days ago

docker pull oorabona/openvpn