Put a VPN client in a container and you get one namespace with a tunnel inside it, not a host that routes all its traffic through a VPN. Those are separate projects, and treating them as one is how containers end up privileged by accident. This runbook covers only the first: one reviewed image, one protected profile, and a client process confined to its own network namespace.

Start from an artifact somebody already approved. The steps below assume an image that contains the openvpn executable and a complete client profile with its certificate and key material already resolved inside the mount. They do not build software, mint credentials, or cover rootless runtimes and native Windows containers, each of which changes the answer.

What the container does and does not get

Keep the profile and everything it references in a local directory with restricted permissions, mounted read-only. Relative paths inside the profile have to resolve within that mount, which is a detail people miss when a certificate sits one directory above the profile and the container never sees it.

services:
  vpn:
    image: ${VPN_CLIENT_IMAGE:?set the reviewed image reference}
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    volumes:
      - ./vpn:/vpn:ro
    working_dir: /vpn
    entrypoint: ["openvpn"]
    command: ["--config", "/vpn/client.ovpn"]

That file grants three things and asks for nothing else. The image reference should be pinned by digest if your deployment policy says so, and the profile should be the reviewed one rather than a copy somebody edited in place on the host.

Read-only here means the container cannot modify your host files; it does not mean the container cannot read the private key you mounted. That is the whole point of the mount, and it is also why file permissions, ownership and how the secret got onto the host still matter. Never pass a password on the command line or bake one into an image layer, because both survive in places you cannot clean up later.

Capabilities, entrypoint and the trap between them

Two settings are needed and they do unrelated jobs. NET_ADMIN allows interface and route manipulation inside the container's namespace. The /dev/net/tun device entry allows the process to open the tunnel device at all. Granting one without the other produces a clear failure, which is convenient: a missing TUN device fails at open time, while a missing capability fails when the client tries to configure the interface.

Neither of those justifies privileged: true. A privileged container gets every capability plus device access, and it is a much larger grant than this workload needs. The runtime privilege and capability reference spells out how far that grant reaches. If a chart or a blog post asks for it, check whether the requirement was really a missing device path or a missing capability first.

The entrypoint override is the part that bites. Invoking openvpn directly is correct for a plain image and wrong for any product image whose startup script prepares firewall rules, resolver files or credentials before execing the daemon. Overriding that script skips the preparation and leaves you debugging a tunnel that came up in a half-configured namespace.

Run the client in the foreground and let it be the container's main process. A process that daemonises exits immediately, the container restarts on a loop, and the logs you need are spread across restarts that each look identical.

Verify from inside the namespace, not from the host

Bring the stack up, confirm the client reached a connected state in the log, and then inspect the namespace that actually holds the tunnel.

docker compose up -d
docker compose logs --tail 40 vpn
docker compose exec vpn ip -br addr
docker compose exec vpn ip route

The address list should show a tunnel interface with an address from the pool, and the route table should show the pushed destinations pointing at it. If the interface is missing, the client never reached the point of creating it. If it exists but no route uses it, the push or the route options are the place to look.

Then test the destination you care about, from the namespace that is supposed to use the tunnel, using the application rather than only a ping. Name resolution is a separate failure worth testing explicitly: a pushed DNS address does not update a container's resolver by itself, and a standalone client that ignores the push will resolve names through whatever the image configured.

The disconnected state is part of the contract

Reconnect behaviour needs its own check, because a restart policy proves the process restarts and says nothing about what traffic does in between. Stop the tunnel, try the intended destination, and watch whether the connection is refused, hangs, or quietly succeeds through the container's ordinary route. The last outcome is the one that matters, and it is invisible in a healthy log.

If the workload must never use the ordinary path, design that explicitly with firewall rules and a readiness signal inside the namespace. A kill switch is a policy you write and test, not a property you inherit from running a client in a container.

Finally, remember the boundary: these routes live in the container's namespace and do not replace the host's routing table. Docker's forwarding, translation and firewall rules remain part of the outer path, and a published port is not a VPN interface.

Nuŋlɔɖi siwo ku ɖe eŋu