OpenConnect VPN client in Docker: route containers through ocserv/AnyConnect servers, fail-closed
5.6K
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 puts a container behind the tunnel (details);-separated server list in URL, next entry after repeated failures (details)PROTOCOL (details)HEALTHCHECK probes real egress through the tunnel (details)NETWORK / NETWORK6 add return routes and firewall holes (details)TTL_SET hides the hops behind the container from traceroutes (details)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 intercepted without any daemon in the image.
GATEWAY_DNS picks the mode: redirect (default) sends client DNS to the
tunnel-pushed resolvers, local to a co-located resolver such as AdGuard Home
or Pi-hole, forward to an external resolver reached directly over eth0, and
off leaves it alone. Containers using Docker's embedded DNS (127.0.0.11)
bypass the gateway; give them an explicit dns:. See Gateway DNS
for the details of each mode.
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:be671eb17…
Size
16.3 MB
Last updated
about 17 hours ago
docker pull azinchen/openconnect-client