OpenConnect VPN client in Docker: route containers through ocserv/AnyConnect servers, fail-closed
4.9K
OpenConnect VPN client in a Docker container that routes other containers'
traffic through an ocserv / Cisco AnyConnect-compatible server, with a
dual-stack fail-closed kill switch. Built as the companion client for
azinchen/ocserv-server: same
configuration style, and a "route other containers through
network_mode: service:vpn" workflow. Since it runs the plain openconnect
client it also connects to GlobalProtect, Pulse/Ivanti, Fortinet and other
servers via PROTOCOL.
Full documentation lives in the project wiki.
network_mode: service:vpn transparently send all traffic through the
tunnel.inet table drops both
address families by default; only the VPN endpoint itself and explicitly
whitelisted local networks may leave via eth0. The firewall is installed
before openconnect is allowed to start and is never removed on disconnect.URL accepts a ;-separated server list; the
client advances to the next entry after repeated failures.https://host:port/?secret); it is redacted in all log output.services:
vpn:
image: azinchen/openconnect-client:latest
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
environment:
- URL=https://vpn.example.com:8443/?mysecret
- USER=alex
- PASS=secret
- CA_FILE=/openconnect-client/ca.pem
- NETWORK=192.168.1.0/24
volumes:
- ./config:/openconnect-client:ro
ports:
- "8080:80" # app UI published on the vpn service
app:
image: nginx:alpine
network_mode: "service:vpn"
depends_on:
- vpn
Everything the app container sends now goes through the tunnel — or
nowhere. See the wiki's Docker Compose Examples for complete
compositions of both modes.
A single URL variable carries host, port and camouflage secret, matching
openconnect's own positional argument and ocserv's camouflage semantics:
URL=https://vpn.example.com # port 443, no camouflage
URL=https://vpn.example.com:8443/?mysecret # custom port + camouflage
URL=vpn.example.com # bare host, https:// implied
URL=https://[2001:db8::1]:8443 # IPv6 literal
URL=https://a.example.com;https://b.example.com:8443/?s2 # ordered failover
Only https is accepted; the port (default 443) applies to both TCP and
DTLS; each list entry is a complete URL with its own port and secret. The
query string is passed to openconnect verbatim and redacted in logs (note:
like any environment variable it remains visible in docker inspect).
Grouped by feature; every variable is one line here — the Configuration Reference has the full descriptions.
| Variable | Default | Description |
|---|---|---|
URL | — (required) | https://host[:port][/][?camouflage_secret]; bare host accepted; ;-list for ordered failover. |
CONNECT_FAMILY | auto | Control-channel address family: auto | ipv4 | ipv6. auto prefers IPv6 when eth0 has a global IPv6 route. |
PROTOCOL | anyconnect | openconnect protocol: anyconnect | gp | pulse | fortinet | nc | array (details). |
DTLS | on | off disables the UDP data channel (it uses the URL's port). |
SPLIT_TUNNEL | false | Honor server-pushed split routes instead of forcing full-tunnel. |
OPENCONNECT_OPTS | — | Extra raw openconnect arguments, appended verbatim. |
| Variable | Default | Description |
|---|---|---|
USER | — | Username for password auth. |
PASS | — | Password; piped to openconnect on stdin, never on the command line. |
PASS_FILE | — | Read the password from a file (Docker secret); PASS wins if both are set. |
CERT_FILE | — | Client certificate: a .p12/.pfx bundle, or a PEM certificate with KEY_FILE. |
KEY_FILE | — | PEM private key when CERT_FILE is a PEM certificate. |
CERT_PASS | — | Passphrase for the key or bundle. |
CA_FILE | — | CA certificate to trust (preferred for self-hosted servers). |
SERVERCERT | — | pin-sha256:... server certificate pin — no CA file needed. |
INSECURE | false | Skip server certificate verification (loudly logged, unsafe). |
| Variable | Default | Description |
|---|---|---|
MTU | (auto) | Override the tun MTU; otherwise the server-provided value is used (details). |
MSS | (unset) | Hard-cap the TCP MSS of the control connection to the server (e.g. 1300) when the path MTU is small and PMTUD is broken; unset clamps to the path MTU. |
TTL_SET | (unset) | Rewrite the TTL/hop-limit of traffic leaving through the tunnel (e.g. 64); hides every hop behind this container from client traceroutes (details). |
| Variable | Default | Description |
|---|---|---|
IPV6_MODE | auto | Tunnel IPv6 data plane: auto (use if pushed, never leak) | require (reconnect until dual-stack) | off. |
| Variable | Default | Description |
|---|---|---|
NETWORK | — | ;-list of IPv4 LAN CIDRs allowed to reach the container via eth0 (return routes + firewall). |
NETWORK6 | — | Same for IPv6 CIDRs. |
| Variable | Default | Description |
|---|---|---|
GATEWAY_MODE | false | Act as a forwarding/NAT gateway for other-netns containers and LAN hosts. |
FORWARD_FROM | eth0 subnets | ;-list of IPv4 source CIDRs allowed to use the gateway. |
FORWARD_FROM6 | eth0 subnets | Same for IPv6. |
GATEWAY_NAT6 | true | Masquerade IPv6 (NAT66); false for pure routing when the server routes the client subnet. |
GATEWAY_DNS | redirect | Gateway-client DNS interception: redirect | local | forward | off (details). |
GATEWAY_DNS_SERVER | — | External resolver IP(s) for GATEWAY_DNS=forward (one IPv4 and/or one IPv6); reached directly, not through the tunnel. |
| Variable | Default | Description |
|---|---|---|
DNS | server-pushed | Override the DNS servers (;-list, IPv4/IPv6 mixed); 127.0.0.1 uses a co-located resolver. |
| Variable | Default | Description |
|---|---|---|
RECONNECT_DELAY | 5 | Base delay in seconds between reconnect attempts (exponential backoff, capped at 300 s). |
HEALTH_CHECK_ENABLED | false | true = the Docker HEALTHCHECK probes the tunnel instead of always reporting healthy. |
CHECK_CONNECTION_URL | https://www.google.com | Probe URL(s), ;-separated, requested through the tunnel. |
| Variable | Default | Description |
|---|---|---|
NETWORK_DIAGNOSTIC_ENABLED | false | Dump addresses, routes and the nftables ruleset after firewall install and connect. |
TZ | UTC | Container time zone (e.g. Europe/Amsterdam); affects log timestamps. |
Mirrors ocserv's auth modes: password (USER + PASS/PASS_FILE, piped via
--passwd-on-stdin), certificate (CERT_FILE as .p12/.pfx with optional
CERT_PASS, or PEM CERT_FILE + KEY_FILE), or both simultaneously
(ocserv auth = certificate + enable-auth = plain). Mount cert material
into /openconnect-client.
Server trust, in order of preference: CA_FILE (mount the ocserv-server CA),
SERVERCERT (pin-sha256: pin for self-signed setups), a publicly valid
certificate, or the INSECURE=true escape hatch.
Co-located containers (network_mode: service:vpn) share the VPN
container's network stack, so tunnel routes and the kill switch protect them
identically. Publish the app's ports on the vpn service and list the LAN
subnets that need to reach those ports in NETWORK/NETWORK6. This is the
recommended mode for compose stacks.
GATEWAY_MODE=true turns the container into a NAT gateway for clients that
keep their own network namespace: containers on the same Docker network and
LAN hosts with a route (or macvlan default gateway) pointing at the
container. Requires forwarding sysctls from the runtime:
sysctls:
- net.ipv4.ip_forward=1
- net.ipv6.conf.all.forwarding=1
- net.ipv6.conf.all.disable_ipv6=0
FORWARD_FROM/FORWARD_FROM6 limit which sources may use the gateway
(default: the container's own eth0 subnets). IPv6 is masqueraded (NAT66) by
default because ocserv assigns a single client address; set
GATEWAY_NAT6=false if your server routes the client subnet.
Gateway-client DNS is handled without any daemon in the image, chosen by
GATEWAY_DNS:
redirect (default) — client port-53 traffic is DNAT-ed to the
tunnel-pushed resolvers (updated atomically on every reconnect). Queries
deliberately aimed at an address inside this netns pass untouched.local — all client port-53 traffic (including hardcoded public DNS
on smart TVs and the like) is DNAT-ed to the container itself, for a
full-featured resolver (AdGuard Home, Pi-hole, unbound) running
co-located in network_mode: service:vpn. The resolver's upstream
traffic follows the tunnel and the kill switch automatically. This is the
recommended setup for full-time gateway use — see the wiki's
Docker Compose Examples.forward — all client port-53 traffic is DNAT-ed to an external
resolver (GATEWAY_DNS_SERVER, e.g. an AdGuard Home on the LAN) reached
directly over eth0, not through the tunnel.off — no interception; clients use whatever resolver they are
configured with, routed through the tunnel like ordinary traffic.Caveat (all modes): compose containers using Docker's embedded DNS
(127.0.0.11) resolve via the host, bypassing the gateway; give such
clients an explicit dns: pointing at a routable IP.
Both families are dropped by default in one nftables table, so an
IPv6-enabled Docker network cannot leak around an IPv4-only firewall. The
tunnel's IPv6 data plane requires the server to push an IPv6 address (the
IPV6_* variables of azinchen/ocserv-server); IPV6_MODE controls what
happens when it doesn't: auto runs IPv4-only and keeps IPv6 dropped,
require treats it as a connection failure, off never asks for IPv6.
Connectivity to the server over IPv6 additionally needs an IPv6-enabled
Docker network (enable_ipv6: true); the tunnel's IPv6 works regardless
once the session is up over IPv4.
The image ships a Docker HEALTHCHECK (60s interval) that is neutral until
HEALTH_CHECK_ENABLED=true. When enabled it verifies the tun interface and
its addresses, then probes CHECK_CONNECTION_URL through the tunnel
(IPv4, plus IPv6 when IPV6_MODE=require). Combine with an
autoheal-style restarter to recycle an unhealthy container.
--cap-add=NET_ADMIN and /dev/net/tunContent type
Image
Digest
sha256:c2460076b…
Size
18.4 MB
Last updated
8 days ago
docker pull azinchen/openconnect-client