VPN ProxyJump
Table of Contents
1. About
A Docker image that combines a VPN client (OpenConnect or OpenFortiVPN) with an OpenSSH server, designed to act as an SSH ProxyJump host through a VPN tunnel.
See this post for a more conversational write-up.
1.1. Features
- Multiple VPN backends: OpenConnect (Cisco AnyConnect, Juniper, GlobalProtect, etc.) and OpenFortiVPN (FortiGate)
- SSH ProxyJump with public key authentication
- Automatic reconnection when the VPN tunnel drops
- Graceful shutdown with proper signal handling
- Docker HEALTHCHECK to monitor VPN tunnel status
- Non-interactive password support via env var or Docker secrets file
- socat and mosh included for advanced networking and mobile shell use
- Multi-architecture:
linux/amd64andlinux/arm64
2. Quick Start
2.1. Prerequisites
- Docker (with
/dev/net/tunavailable on the host) - An SSH key pair
2.2. Build
export PUB_KEY_CONTENT=$(cat ~/.ssh/id_ed25519.pub) docker build \ --build-arg USER_PUBLIC_KEY="$PUB_KEY_CONTENT" \ --build-arg SSH_USER_NAME=jumphostuser \ --pull \ -t vpn-smart-jumphost:latest .
2.3. Run
2.3.1. OpenConnect (default)
docker run --rm -it \ --name vpn-smart-jump-container \ -e VPN_SERVER="vpn.example.com" \ -e VPN_USER="your_username" \ -e OPENCONNECT_EXTRA_ARGS="--useragent=AnyConnect" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ vpn-smart-jumphost:latest
2.3.2. OpenFortiVPN
docker run --rm -it \ --name vpn-smart-jump-container \ -e VPN_SERVER="vpn.example.com:10443" \ -e VPN_USER="your_username" \ -e VPN_TYPE="openfortivpn" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ vpn-smart-jumphost:latest
2.3.3. Using docker-compose
Copy docker-compose.yml and edit the environment variables, then:
export PUB_KEY_CONTENT=$(cat ~/.ssh/id_ed25519.pub) docker compose up --build
3. Configuration
3.1. Build Arguments
| Argument | Default | Description |
|---|---|---|
SSH_USER_NAME |
jumphostuser |
Username for SSH connections |
USER_PUBLIC_KEY |
(required) | Content of your SSH public key file |
3.2. Environment Variables
| Variable | Default | Description |
|---|---|---|
VPN_SERVER |
(required) | VPN server hostname or IP (with port for openfortivpn) |
VPN_USER |
(required) | VPN username |
VPN_TYPE |
openconnect |
VPN backend: openconnect or openfortivpn |
VPN_PASSWORD |
(empty) | VPN password via env var (see 3.3) |
OPENCONNECT_EXTRA_ARGS |
(empty) | Additional flags passed to openconnect |
FORTIGATE_EXTRA_ARGS |
(empty) | Additional flags passed to openfortivpn |
VPN_RECONNECT |
true |
Auto-reconnect when VPN disconnects |
VPN_RECONNECT_DELAY |
5 |
Seconds to wait between reconnection attempts |
3.3. VPN Password
The password is resolved in this priority order:
- File (
/run/secrets/vpn_password): Best option. Works with Docker secrets or a simple bind mount. - Environment variable (
VPN_PASSWORD): Convenient but visible indocker inspect. - Interactive: If neither is set the VPN client prompts on the terminal (requires
-it).
3.3.1. File-based example
echo "my_vpn_password" > ./secrets/vpn_password docker run --rm -it \ -v ./secrets/vpn_password:/run/secrets/vpn_password:ro \ -e VPN_SERVER="vpn.example.com" \ -e VPN_USER="your_username" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ vpn-smart-jumphost:latest
4. SSH ProxyJump Configuration
Once the container is running, connect via:
ssh jumphostuser@localhost -p 2200
For seamless access to hosts behind the VPN, add entries to ~/.ssh/config:
# Jump host (the container)
Host vpn-docker-jump
HostName 127.0.0.1
Port 2200
User jumphostuser
IdentitiesOnly yes
IdentityFile ~/.ssh/id_ed25519
# Any host behind the VPN
Host internal-host
HostName internal-host.corp.example.com
User your_username
ProxyJump vpn-docker-jump
# Wildcard for an entire domain
Host *.corp.example.com
User your_username
ProxyJump vpn-docker-jump
Then simply:
ssh internal-host
5. Advanced Usage
5.1. socat – TCP Port Forwarding
socat is included for relaying TCP connections through the VPN tunnel. For example, to expose an internal web service:
# Forward local port 8080 through the container to an internal host docker exec vpn-smart-jump-container \ socat TCP-LISTEN:8080,fork,reuseaddr TCP:internal-host:80
Then access http://localhost:8080 (after mapping the port with -p 8080:8080 at container start).
5.2. mosh – Mobile Shell
mosh provides a more resilient shell over unreliable connections. The container includes the mosh-server binary, so you can use it from the client side:
# Requires UDP ports to be exposed (mosh uses 60000-61000) docker run --rm -it \ -p 127.0.0.1:2200:22 \ -p 60000-60010:60000-60010/udp \ ... vpn-smart-jumphost:latest # Then connect with mosh mosh --ssh="ssh -p 2200" jumphostuser@localhost
5.3. HEALTHCHECK
The container includes a Docker HEALTHCHECK that verifies the VPN tunnel interface exists (tun0 for OpenConnect, ppp0 for OpenFortiVPN).
# Check health status
docker inspect --format='{{.State.Health.Status}}' vpn-smart-jump-container
The health check runs every 30 seconds with a 30-second start-up grace period.
5.4. Disabling Auto-Reconnect
By default the container re-establishes the VPN when the tunnel drops. To disable this:
-e VPN_RECONNECT="false"
The container will exit with the VPN process's exit code instead.
6. Pre-built Images
Pre-built multi-arch images are available on GHCR and Docker Hub:
# GHCR docker pull ghcr.io/haozeke/vpn-proxyjump:latest # Docker Hub docker pull haozeke/vpn-proxyjump:latest
Note: Pre-built images contain a placeholder SSH key. You should either:
- Build locally with your own
USER_PUBLIC_KEY(recommended), or Mount your own
authorized_keysfile at runtime:-v ~/.ssh/id_ed25519.pub:/home/jumphostuser/.ssh/authorized_keys:ro
7. CI/CD
The repository includes a GitHub Actions workflow (.github/workflows/build-and-push.yml) that:
- Builds multi-arch images (
linux/amd64,linux/arm64) on every push tomain - Publishes to GHCR and Docker Hub on tags matching
v* - Validates PRs with a build-only check (no push)
To enable Docker Hub publishing, set these repository secrets:
DOCKERHUB_USERNAMEDOCKERHUB_TOKEN
GHCR publishing uses the built-in GITHUB_TOKEN automatically.
8. Troubleshooting
8.1. /dev/net/tun not available
Ensure the TUN device exists on the host. On most Linux systems it is present by default. If missing:
sudo modprobe tun
And pass it to the container with --device=/dev/net/tun:/dev/net/tun.
8.2. NET_ADMIN capability
The VPN client needs to create network interfaces and modify routing tables. Always include --cap-add=NET_ADMIN.
8.3. VPN connects but SSH ProxyJump fails
Check that:
- The container's SSH daemon is listening:
docker exec <container> ss -tlnp | grep 22 - Your SSH key matches the one used at build time
- The port mapping is correct (
-p 127.0.0.1:2200:22)
8.4. Reconnection with interactive password
When VPN_RECONNECT=true and no stored password is available, the VPN client will prompt for a password again on each reconnection. This requires the container to be running with -it. For unattended operation, use a password file or environment variable.
9. Changelog
9.1. v1.0.0
- Added OpenFortiVPN support (
VPN_TYPE=openfortivpn) - Added Docker
HEALTHCHECKfor VPN tunnel monitoring - Added automatic VPN reconnection (
VPN_RECONNECT,VPN_RECONNECT_DELAY) - Added graceful shutdown with signal handling
- Added non-interactive password support (
VPN_PASSWORDenv var,/run/secrets/vpn_passwordfile) - Added
docker-compose.ymlexample - Added GitHub Actions CI/CD for multi-arch builds (GHCR + Docker Hub)
- Added
socatandmoshdocumentation - Reduced SSH
LogLevelfromDEBUG3toINFO - Locked SSH user account (public key auth only, removed
PASSbuild arg) - Changed
PasswordAuthenticationtono
10. License
MIT. See LICENSE.