Your app is deployed and healthy, and one of the things it needs to talk to is not on the public internet. An internal reporting API on a 10.x address. A partner’s SFTP endpoint that only answers to VPN clients. A legacy database that IT will not expose, ever, and fair enough.
On your laptop this is a solved problem: connect the corporate VPN client and everything routes. In a deployed container, installing that same client fails, usually with an error about /dev/net/tun, and it fails on every serious container platform, not just ours. What works instead: run the VPN as its own small service next to your app, in userspace, and let your app route VPN-bound traffic through it as a SOCKS5 proxy.
Here is the whole setup, in both dialects: a slipway.yaml, and the docker-compose.yml version if that is what your repo already has. Slipway deploys either one. We walked a customer through exactly this last week, using openconnect-proxy, a packaged image that combines OpenConnect with a userspace proxy:
version: 1
services:
- name: openconnect
image: ericwastakenondocker/openconnect-proxy
ports:
- port: 8222 # SOCKS5, internal-only. Never make this public.
env:
HOST: ${secret.VPN_HOST}
USERNAME: ${secret.VPN_USER}
PASSWORD: ${secret.VPN_PASS}
PROTOCOL: anyconnect # or gp, pulse, fortinet: whatever your gateway speaks
FINGERPRINT: "pin-sha256:AAAA...="
PROXY_PORT: "8222"
# optional: AUTHGROUP, if your gateway prompts for one
healthcheck:
readiness: { tcp: { port: 8222 } }
- name: app
rootDir: .
ports:
- port: 3000
public: true
dependsOn: [openconnect]
env:
SOCKS5_HOST: openconnect
SOCKS5_PORT: "8222"services:
openconnect:
image: ericwastakenondocker/openconnect-proxy
expose:
- "8222" # SOCKS5, internal-only. Never publish this port.
environment:
- HOST=${VPN_HOST}
- USERNAME=${VPN_USER}
- PASSWORD=${VPN_PASS}
- PROTOCOL=anyconnect # or gp, pulse, fortinet: whatever your gateway speaks
- FINGERPRINT=pin-sha256:AAAA...=
- PROXY_PORT=8222
# optional: AUTHGROUP, if your gateway prompts for one
app:
build: .
ports:
- "8081:3000"
depends_on:
- openconnect
environment:
- SOCKS5_HOST=openconnect
- SOCKS5_PORT=8222
restart: unless-stoppedTwo services. The first one dials the VPN and exposes the tunnel as a SOCKS5 proxy on port 8222. The second is your app, which reaches the proxy at openconnect:8222 by bare service name, because services in the same environment share a private network. Anything your app sends through that proxy comes out inside the corporate network. Everything else it does is completely unaffected.
The tabs are the same stack twice, and both files deploy. Commit the Compose version and slipway converts it on every deploy: environment becomes env, expose becomes an internal port, depends_on keeps its wait-until-ready meaning, and the ${VPN_HOST} interpolations resolve against the secret and variable stores exactly the way they resolve against a local .env, with sensitive-looking keys steered into the secret store automatically. The slipway.yaml dialect adds one refinement worth having either way: an explicit readiness probe on the proxy port, which we will get to.
The rest of this post explains why it has to be shaped this way, and the two or three details that decide whether it works on the first try.
Why can’t the container just run the VPN client?#
A normal VPN client is a kernel-space arrangement. It creates a network interface (a tun device), rewrites the routing table so traffic flows into it, and encrypts whatever arrives. Creating that interface requires /dev/net/tun and the NET_ADMIN capability, which is to say: permission to reconfigure networking on the machine.
No multi-tenant platform should hand that out, and slipway doesn’t. Your workloads run unprivileged inside hardware-isolated microVMs, and “this container may rewrite routing” is precisely the class of power that isolation exists to withhold. So the kernel-space clients are all out: WireGuard’s kernel module, OpenVPN in tun mode, the official AnyConnect client. Each of them will fail at the same wall, on slipway and on any platform you would trust with a neighbor’s workload.
OpenConnect in userspace#
OpenConnect can speak the whole tunnel protocol (AnyConnect, GlobalProtect, Pulse, Fortinet) as a normal unprivileged process. The only thing it needs the kernel for is handing decrypted packets to a network interface, and that step is replaceable: ocproxy is a small TCP/IP stack that runs inside the same process and serves the tunnel as a SOCKS5 proxy instead. No tun device, no route changes, no extra capabilities, so it runs fine in an unprivileged container. The openconnect-proxy image packages the pair and only starts the proxy listener once the tunnel is up.
One behavioral difference from the VPN client on your laptop: nothing is routed automatically. Traffic goes through the VPN only when your app explicitly sends it to the proxy. For a sidecar that’s exactly what you want: your app’s normal egress (your own APIs, package registries, webhooks) keeps going out directly, and the corporate network only sees the requests meant for it.
Setting it up on slipway#
Put the credentials in secrets. VPN_HOST, VPN_USER, and VPN_PASS go into the encrypted secret store, referenced as ${secret.VPN_HOST} and friends in the slipway.yaml dialect, or as the bare ${VPN_HOST} interpolations Compose already uses; both read the same store. They resolve at deploy time and never appear in the file, the pod spec, or the logs. Worth asking IT for a service account rather than burning a human’s login; more on why below.
Pin the gateway certificate. FINGERPRINT is the gateway’s certificate hash, the same value OpenConnect prints when it first meets a server it doesn’t trust. Setting it lets the sidecar connect non-interactively and protects against the tunnel terminating somewhere it shouldn’t. It is a public value, so it can live in the spec directly.
Keep the proxy port internal. The ports entry on the sidecar has no public: true, so the proxy gets no URL and is reachable only from sibling services in the same environment. Leave it that way: an open SOCKS5 proxy into your corporate network is not something you want the internet to find.
Gate your app on the tunnel. The proxy only starts listening once the VPN is connected, so a plain TCP readiness check on port 8222 doubles as a “tunnel is up” signal. With dependsOn: [openconnect], your app’s process does not start until that check passes. No retry loops in your startup code, no crash-looping while the tunnel negotiates. Deploying the Compose file instead? Its depends_on translates to the same readiness-gated start.
Using the proxy from your app#
Point VPN-bound requests at SOCKS5_HOST:SOCKS5_PORT. Most HTTP stacks have first-class support:
# note socks5-hostname, not socks5 (see the DNS section)
curl --socks5-hostname "$SOCKS5_HOST:$SOCKS5_PORT" https://reports.internal.corp/api/healthimport { SocksProxyAgent } from 'socks-proxy-agent';
const agent = new SocksProxyAgent(
`socks5h://${process.env.SOCKS5_HOST}:${process.env.SOCKS5_PORT}`
);
const res = await fetch('https://reports.internal.corp/api/health', { agent });# pip install requests[socks]
proxies = {"https": f"socks5h://{os.environ['SOCKS5_HOST']}:{os.environ['SOCKS5_PORT']}"}
requests.get("https://reports.internal.corp/api/health", proxies=proxies)// Go's socks5:// dialer already resolves hostnames proxy-side; no "h" variant needed
proxyURL := &url.URL{
Scheme: "socks5",
Host: net.JoinHostPort(os.Getenv("SOCKS5_HOST"), os.Getenv("SOCKS5_PORT")),
}
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
res, err := client.Get("https://reports.internal.corp/api/health")For tools you don’t control, many honor ALL_PROXY=socks5h://openconnect:8222 from the environment. And because only proxied calls take the tunnel, the rest of your app’s traffic keeps going straight out, untouched.
The DNS gotcha#
The single most common failure in this setup, and it looks exactly like the VPN being down: reports.internal.corp fails DNS resolution even though the tunnel is healthy. Internal hostnames only exist on the corporate DNS servers, which live on the far side of the tunnel. If your app resolves the name locally and then asks the proxy to connect to an IP, resolution happens on the wrong side and fails.
The fix is one letter. Use socks5h:// rather than socks5:// (curl spells it --socks5-hostname). The h sends the hostname through the proxy unresolved, so the lookup happens inside the tunnel against the DNS servers the VPN gateway pushed. If a request works by IP but not by name, this is always the reason.
Where this breaks down#
TCP only. ocproxy speaks SOCKS5 over its userspace stack, which carries TCP. If the thing behind the VPN is UDP (a SIP endpoint, a custom datagram protocol), this pattern will not carry it.
Concurrent sessions multiply. Every environment instance dials its own tunnel: the primary, each pull-request preview, each dev instance. Corporate VPNs often cap a user at one concurrent session, and the second login quietly kills the first. This is the real reason to ask IT for a service account with a sensible session limit, not someone’s personal login.
The gateway sees a cloud IP. If IT allowlists source IPs for VPN connections, they will need to admit the platform’s egress range. That conversation is easier when the request comes with this post attached.
Your image needs no changes. Worth saying explicitly, because people expect the app container to need VPN software installed: it doesn’t. The app side of this is environment variables and a proxy-aware HTTP call. The sidecar carries all of it.
The pattern composes with everything else the platform does. Open a pull request and the preview environment brings up its own tunnel next to its own copy of the app; merge and both vanish. The corporate network stays exactly as closed as IT wants it, and your deployed services reach the two endpoints they need through a proxy that only they can see.