There might come a time where you can only access a remote server by logging in to an intermediate server (firewall/jump host, often called a bastion) first. The server could be in a private or isolated network that is only reachable from the intermediate server. When accessing the server, you first need to ssh to the intermediate server before doing another ssh to the destination server. If there is another remote host that can only be accessible from the second server, the chain can be long.
In this guide, we will learn how to simplify the process using the options that the ssh client provides: the -J flag, ProxyJump and ProxyCommand, then how to copy files, open tunnels and debug the errors you will hit along the way.
Every command below was tested with OpenSSH 10 against Debian 12 servers: a jump host reachable from the laptop, and a database server on an internal network that only the jump host can reach.
Related content:
- SSH – Generating and Working with ssh keys
- How to work with SFTP client in Linux – 10 sftp commands
- Configure Fail2ban for SSH Protection on Debian 12 – worth running on the jump host, since it is the one exposed to the internet
The SSH Scenario
This is how the ssh set up is.
+--------------+ +------------+ +-----------+
| Local machine| -> | JumpServer | -> | Dbserver |
+--------------+ +------------+ +-----------+
203.0.113.10 10.0.1.20
The DB server can only be accessed by logging in to the intermediate server – Jump Server. First login to the jumpserver
ssh user@jumpserver
Then from the jump server we can login to the db server
ssh user@dbserver
That works, but it has two problems: you type two commands every time, and the second ssh needs a private key on the jump server. The options below fix both. Your key stays on your laptop; the jump host only relays an encrypted TCP stream and never sees the key or the session contents.
Using the -J option (ProxyJump)
The -J option (OpenSSH 7.3 and later, which is every supported distro today) lets you specify which host to use as a jump host. This is the format
ssh -J user@jumpserver user@dbserver
This is how to connect to the remote using a public jump server
ssh -J rocky@203.0.113.10 ubuntu@10.0.1.20
The target address (10.0.1.20) is resolved and connected to by the jump host, so you can use private IPs or internal DNS names that your laptop cannot resolve.
If you have to specify an ssh key, use this format:
ssh -i ~/.ssh/id_server_key -J rocky@203.0.113.10 ubuntu@10.0.1.20
The -i key is used for the final server. The jump host connection uses your default keys or whatever ~/.ssh/config says for that host, so if the two need different keys, put them in the config file (see below).
If the jump host listens on a non-standard port, add it to the jump spec:
ssh -J rocky@203.0.113.10:2222 ubuntu@10.0.1.20
Chaining multiple jump hosts
-J takes a comma-separated list. The hosts are visited in order, each one connecting to the next:
ssh -J rocky@203.0.113.10,ubuntu@10.0.1.20 ubuntu@10.0.2.30
Here the laptop reaches 203.0.113.10, which reaches 10.0.1.20, which reaches 10.0.2.30. Your key is used for authentication at every hop, and none of the intermediate hosts need it.
Using ProxyCommand when the -J option is not available
In older versions of openssh (before 7.3) the -J is not available. So use the following syntax:
ssh -o ProxyCommand="ssh -W %h:%p user@jumpserver" user@dbserver
This is how I use it on my local machine
ssh -o ProxyCommand="ssh -W %h:%p rocky@203.0.113.10" ubuntu@10.0.1.20 -i ~/.ssh/id_rsa
-W %h:%p tells the jump host to forward standard input and output to the target host (%h) and port (%p). This is exactly what -J does under the hood, so behaviour is identical. ProxyCommand is still useful when the hop is not ssh at all, for example aws ssm start-session or a corporate proxy.
For the oldest clients that don’t support -W option
In this case use the ssh -tt command. Instead of typing two ssh commands, I can type the following all-in-one command:
ssh -tt user@jumpserver ssh -tt user@dbserver
Usage:
ssh -A -tt rocky@203.0.113.10 ssh -tt ubuntu@10.0.1.20
Where:
- The
-toption passed to the ssh command forces pseudo-tty allocation. This can be used to execute arbitrary screen-based programs on a remote machine. Multiple-ttoptions force tty allocation, even if ssh has no local tty. - The
-Aoption forwards your ssh agent so the second ssh can authenticate with your key. Without it (or a private key stored on the jump host), the inner ssh falls back to a password prompt.
Why -J is safer than agent forwarding
Agent forwarding (-A or ForwardAgent yes) is what many old guides tell you to use for bastions. Avoid it when -J is available: while you are connected, anyone with root on the jump host can use your forwarded agent socket to log in to any server your key opens. With -J/ProxyJump the jump host only carries encrypted bytes; there is nothing on it to hijack. If you truly need an agent on a remote host (for example to git pull there), forward it only to that host and consider ssh-add -c so each use asks for confirmation.
Saving the configuration in the ~/.ssh/config file
You can define the configuration options in the ~/.ssh/config file so that ssh dbserver just works. This is the recommended setup:
Host jumpserver
Hostname 203.0.113.10
User rocky
IdentityFile ~/.ssh/id_ed25519
Host dbserver
Hostname 10.0.1.20
User ubuntu
ProxyJump jumpserver
IdentityFile ~/.ssh/id_ed25519
Now connect with just
ssh dbserver
You can also use ProxyCommand instead of ProxyJump:
Host dbserver
Hostname 10.0.1.20
User ubuntu
ProxyCommand ssh -W %h:%p jumpserver
IdentityFile ~/.ssh/id_ed25519
You can also recursively chain multiple jump hosts. Each host names the one before it:
Host jumpserver
Hostname 203.0.113.10
User rocky
Host dbserver2
Hostname 10.0.1.20
User ubuntu
ProxyJump jumpserver
Host dbserver3
Hostname 10.0.2.30
User ubuntu
ProxyJump dbserver2
Doing this will proxy through the other hosts
ssh dbserver3
To route a whole private subnet through the bastion, use a wildcard. Keep the jump host itself out of the pattern so it does not try to jump through itself:
Host 10.0.*
ProxyJump jumpserver
Copying files through the jump host (scp, rsync, sftp)
scp and sftp accept the same -J flag:
scp -J rocky@203.0.113.10 backup.sql ubuntu@10.0.1.20:/tmp/
sftp -J rocky@203.0.113.10 ubuntu@10.0.1.20
rsync has no -J of its own; pass it through the ssh command with -e:
rsync -av -e "ssh -J rocky@203.0.113.10" backup.sql ubuntu@10.0.1.20:/tmp/
If you set up the ~/.ssh/config entries above, all three tools use the ProxyJump automatically and you only need the alias:
scp backup.sql dbserver:/tmp/
scp dbserver:/tmp/backup.sql ./
rsync -av backup.sql dbserver:/tmp/
sftp dbserver
The file goes straight from your laptop to the target over the tunnel; it is never written to the jump host’s disk.
Reaching a private service with a tunnel (-L)
Often you do not want a shell on the DB server, you want its port on your laptop: a database, an admin UI, a Kubernetes API. Combine -L with the jump:
ssh -N -L 5432:localhost:5432 -J rocky@203.0.113.10 ubuntu@10.0.1.20
Then point your client at localhost:5432 on the laptop. -N means “no remote command, just forward”. localhost in the middle is resolved on the final host, so this reaches a service bound only to the DB server’s loopback interface.
If the service lives on yet another private host that the jump server can reach, you do not need to log in to that host at all. Forward through the jump server directly:
ssh -N -L 5432:10.0.1.20:5432 jumpserver
Add -f to send the tunnel to the background once it is connected, and stop it later with kill or by closing the terminal.
Troubleshooting
Add -v to any command to see which hop fails. The errors below are what OpenSSH prints for the common cases.
channel 0: open failed: connect failed: Name or service not known followed by stdio forwarding failed: the jump host could not resolve the target name. The name is resolved on the jump host, not your laptop, so use an IP or a name in the jump host’s DNS or /etc/hosts.
channel 0: open failed: administratively prohibited: open failed: the jump host’s sshd forbids forwarding. On the jump host set this in /etc/ssh/sshd_config (or a Match User block for just the bastion users) and reload sshd:
AllowTcpForwarding yes
channel 0: open failed: connect failed: Connection refused: the jump host reached the target, but nothing listens on that port. sshd is stopped on the target, or it runs on another port (set Port for that host in ~/.ssh/config).
Connection timed out during banner exchange (or a long hang): the jump host accepted your connection but could not reach the target at all. Check the target IP and the security group or firewall between the jump host and the target. Test from the jump host itself (netcat-openbsd package on Debian/Ubuntu):
nc -vz -w 3 10.0.1.20 22
user@host: Permission denied (publickey,password).: look at which host it names. With -J, each hop authenticates separately, so the jump host may accept your key while the target rejects it (wrong User, the key is not in the target’s ~/.ssh/authorized_keys, or a different key is needed). Put a per-host User and IdentityFile in ~/.ssh/config rather than relying on -i, which only applies to the final host.
ssh: Could not resolve hostname before anything connects: the failure is on your laptop, usually a typo in the jump host name or a missing Host alias.