ProxyJump SSH: Configure ~/.ssh/config to Access Multiple VPS via Bastion Host
Configure ProxyJump in ~/.ssh/config to connect to multiple internal VPS through a bastion host without exposing SSH to the internet. Step-by-step technical guide.
Exposing SSH directly on every VPS is convenient at first — you open port 22, allow it in the firewall and connect. In production this becomes a problem: the attack surface grows linearly with each added server, brute-force log entries scatter across multiple machines, and managing authorized keys on N VPS quickly becomes unsustainable.
The industry-standard solution is the bastion host (also called jump host): a single small, hardened VPS exposed publicly that serves as the sole entry point. All other VPS sit on a private network with no public port 22. You connect to the bastion, and from there you jump to the internal VPS.
This tutorial shows how to set this up properly using the native OpenSSH ProxyJump directive in the ~/.ssh/config file. Execution time: 15-20 minutes. Target persona: sysadmin with 2+ VPS in production who wants to reduce attack surface and centralize access.
Prerequisites
You need OpenSSH 7.3 or higher on the local client (check with ssh -V). Modern Linux distributions and macOS 10.15+ already ship with a compatible version. Windows 10 build 1803+ and Windows 11 also support ProxyJump via the native OpenSSH in PowerShell.
Confirm the scenario before continuing:
bastion.example.com (public IP) 10.0.0.10 (private network) 10.0.0.11 (private network) 22 (or custom) You also need: an SSH key generated (ed25519 recommended) with the public key already copied to the bastion via ssh-copy-id, and the corresponding keys copied to each internal VPS. Sudo access on the servers in case you need to adjust /etc/ssh/sshd_config.
Structure of the ~/.ssh/config file
The ~/.ssh/config is where you declare aliases and parameters for SSH hosts. It is read automatically by the OpenSSH client and lets you simplify connections — instead of typing ssh -p 2222 -i ~/.ssh/id_ed25519 [email protected], you just type ssh bastion.
Each block starts with Host <alias> followed by indented parameters. Reading is top-down: the first rule matching the host name wins. Use Host * at the end for global defaults.
Verify the file exists and has the correct permissions:
mkdir -p ~/.ssh
touch ~/.ssh/config
chmod 700 ~/.ssh
chmod 600 ~/.ssh/configWrong permissions (anything other than 600 for the config and 700 for the directory) cause the OpenSSH client to silently ignore the file on some distributions — it is the first thing to check when “my config doesn’t work”.
Open the file in your preferred editor:
nano ~/.ssh/configYou will add 3 blocks: one for the bastion host, one for each internal VPS, and (optionally) a Host * block with reasonable defaults.
Basic configuration with ProxyJump
The ProxyJump directive (shortcut -J on the command line) tells OpenSSH to open a TCP connection to the destination host through another intermediate host, all in a single invocation. The jump is transparent: you run ssh vps-app and the client connects to the bastion, opens a tunnel to the internal VPS and authenticates with it end-to-end.
Add the bastion host block:
Host bastion
HostName bastion.example.com
User deploy
Port 22
IdentityFile ~/.ssh/id_ed25519_bastion
IdentitiesOnly yesIdentitiesOnly yes is important: it forces the client to use only the key declared in IdentityFile, rather than trying all keys loaded in ssh-agent. Without this, the bastion may receive 5-6 wrong key attempts before the correct one, and setups with a low MaxAuthTries will refuse the connection.
Add the internal VPS blocks with ProxyJump:
Host vps-app
HostName 10.0.0.10
User deploy
IdentityFile ~/.ssh/id_ed25519_internal
IdentitiesOnly yes
ProxyJump bastion
Host vps-db
HostName 10.0.0.11
User deploy
IdentityFile ~/.ssh/id_ed25519_internal
IdentitiesOnly yes
ProxyJump bastionThe value bastion in ProxyJump references the Host bastion block declared earlier — it is not a literal hostname, it is the SSH alias. You can use the same key for the internal VPS (if they are on a trusted network) or separate them per host according to your security policy.
Add global defaults at the end of the file:
Host *
AddKeysToAgent yes
ServerAliveInterval 60
ServerAliveCountMax 3
HashKnownHosts yesServerAliveInterval 60 makes the client send a keepalive every 60 seconds — prevents NAT/firewall from dropping idle connections. HashKnownHosts yes masks hostnames in ~/.ssh/known_hosts, useful to avoid leaking internal topology in case the file gets exposed.
Chained multi-hop jumps
In segmented environments with more than one bastion layer (e.g., public bastion → DMZ bastion → app VPS), ProxyJump accepts a comma-separated list.
Configure a double jump:
Host vps-prod
HostName 10.10.0.50
User deploy
ProxyJump bastion,dmz-jumpThe client connects sequentially: local → bastion → dmz-jump → vps-prod. Each jump adds latency (TCP handshake + SSH authentication). In practice, 2 jumps is the limit where usage experience is still fluid — beyond that, consider a VPN.
Each jump adds the RTT between the hosts involved. If the bastion is in Brazil and the DMZ is in Europe, the total RTT to open an SSH prompt can exceed 500ms. For intensive interactive operation, consider consolidating bastions in the same region.
Harden the bastion host
The bastion becomes the concentrated target for attacks — it is worth hardening it beyond the default.
On the bastion, edit /etc/ssh/sshd_config:
sudo nano /etc/ssh/sshd_configApply these settings:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
AllowTcpForwarding yesAllowTcpForwarding yes is mandatory — without it ProxyJump breaks with the error channel 0: open failed: administratively prohibited. AllowUsers deploy restricts who can authenticate (allowlist). MaxAuthTries 3 cuts brute force early.
Reload the SSH service and test before closing the current session:
sudo sshd -t && sudo systemctl reload sshsshd -t validates the file syntax before the reload. Skipping this validation and having a syntax error leaves the service down — you get locked out.
Always open a second SSH session in a separate terminal and test login with the new settings before ending the original session. If you made a mistake (wrong key, firewall blocking, sshd didn’t come up), you still have the old session active to revert.
Verification
With everything configured, testing is simple — the client abstracts the jump.
Connect to the internal VPS using the alias:
ssh vps-appIf everything is right, you drop straight into the vps-app prompt without any intermediate interaction. You can add -v (ssh -v vps-app) to see the verbose negotiation and confirm that the jump via bastion happened — look for lines like debug1: Setting up multiplex master socket and debug1: Connecting to 10.0.0.10 [10.0.0.10] port 22.
Confirm that the SSH port on the internal VPS is really closed externally:
nmap -p 22 10.0.0.10Running this from any machine outside the private network, the result should be filtered or closed. If it shows open, your internal VPS still has port 22 publicly accessible — fix the firewall (ufw deny 22 or a security group rule at the provider).
Troubleshooting
Error: “channel 0: open failed: administratively prohibited”
The bastion has AllowTcpForwarding no in sshd_config. Edit the file on the bastion, change it to yes and run sudo systemctl reload ssh.
Error: “Permission denied (publickey)” on the second jump
The key declared in IdentityFile for the internal VPS is not authorized on it. Copy the public key to the internal VPS via the bastion: ssh-copy-id -i ~/.ssh/id_ed25519_internal.pub -o ProxyJump=bastion [email protected].
Slow connection on the first jump
Usually slow reverse DNS on the bastion. Add UseDNS no in /etc/ssh/sshd_config on the bastion and reload the service. Reduces authentication time from 5-15 seconds to sub-second.
Connection drops after a few idle minutes
Missing ServerAliveInterval. Make sure the Host * block in ~/.ssh/config has ServerAliveInterval 60 and ServerAliveCountMax 3. If the provider’s NAT is aggressive, reduce to ServerAliveInterval 30.
Next steps
With ProxyJump configured, you can evolve the setup:
- Add 2FA on the bastion via PAM + Google Authenticator to require TOTP in addition to the SSH key.
- Centralize logs from the bastion to a remote syslog for a full audit trail of all accesses.
- Implement session recording with
tlogorauditdon the bastion — useful for compliance. - Bastion failover with 2 instances and DNS round-robin to avoid a single point of failure.
- Configure an SSH certificate authority to issue short-lived certificates instead of managing long-lived keys.
If you are building this topology in production, keep in mind that every Hostini VPS already includes native IPv6 and a private network between instances in the same region — you can use the private interface as the path from the bastion to the internal VPS, avoiding unnecessary traffic over the public internet. See the VPS page for networking details.
Frequently asked questions
What is the difference between ProxyJump and ProxyCommand?
ProxyJump (introduced in OpenSSH 7.3, 2016) is the modern, simpler way to jump through an intermediate host. ProxyCommand is older, requires manually building the command with `ssh -W %h:%p`, and has more failure points. Use ProxyJump in any environment with OpenSSH ≥ 7.3.
Can I chain multiple jumps with ProxyJump?
Yes. The directive `ProxyJump bastion1,bastion2,bastion3` performs sequential jumps through each host. Useful for environments segmented into multiple zones (DMZ → app → DB). Each jump adds latency — in practice, 2 jumps is where UX starts to degrade.
Does ProxyJump work with different SSH keys on each host?
Yes. Each `Host` block in `~/.ssh/config` declares its own `IdentityFile`. The client uses the bastion key to authenticate at the jump host, then the internal VPS key to authenticate at the final destination. Recommended: disable agent forwarding and use separate keys per host.
Can the bastion host see the password or traffic of my session on the internal VPS?
No. ProxyJump uses the bastion only as a TCP tunnel (`-W` mode). The TLS negotiation and SSH session with the internal VPS are end-to-end between your client and the final VPS. The bastion forwards encrypted bytes with no ability to decrypt them.
How do I avoid typing the key passphrase on every connection?
Use `ssh-agent` together with `AddKeysToAgent yes` in `~/.ssh/config`. The passphrase is requested on the first connection and cached in agent memory until logout. On macOS, add `UseKeychain yes` to persist between sessions via the system Keychain.
Does ProxyJump break if the bastion is offline?
Yes — the bastion is a single point of failure. In critical environments, configure 2 bastions with different IPs and use `ProxyJump bastion-a,backup-bastion-b` as a manual fallback, or implement DNS failover pointing `bastion.example.com` to redundant IPs.