Right, let’s talk about getting past the gatekeepers. You’ve got a server, let’s call it app-server.internal, sitting pretty on a private network. The only way in is through a single, heavily fortified machine—the bastion host (or jump host). It’s the digital equivalent of a drawbridge. You can’t just SSH directly to your app server; you have to go through the drawbridge first.

Now, you could do this manually. SSH to the bastion, then from there, SSH again to the app server. It works, but it’s clunky. You’re juggling two terminals, and doing anything like SCP or forwarding ports becomes a tedious, multi-step nightmare. This is where SSH itself becomes your best friend and automation engineer. We have two primary, brilliant ways to handle this: the older, more versatile Swiss Army knife (-L and -D tunneling) and the newer, cleaner purpose-built tool (-J or ProxyJump).

The Old Guard: SSH Local Forwarding

Let’s start with the foundational magic trick: SSH Local Forwarding (-L). It creates a tunnel. Specifically, it says: “Listen on a port on my local machine, and forward any traffic that comes into it through this SSH connection to a specific port on a remote machine.”

The syntax is its own little puzzle: ssh -L <local_port>:<target_host>:<target_port> <bastion_host>

So, to get to a web service on app-server.internal:80 via our bastion bastion.mycompany.com, you’d run:

ssh -L 8080:app-server.internal:80 user@bastion.mycompany.com

Now, open your browser and go to http://localhost:8080. Boom. Your browser’s traffic is being securely routed through the bastion host to port 80 on the app server. Why is this brilliant? Because the app server only needs to allow SSH connections from the bastion host, not from every developer’s laptop. You’ve effectively made your laptop part of the trusted internal network for this one connection.

The most common pitfall? That <target_host> is resolved from the perspective of the bastion host, not your local machine. If the bastion host doesn’t know how to resolve app-server.internal, this fails spectacularly. You must use a hostname or IP that the bastion can understand.

The New Hotness: ProxyJump

While -L is powerful, SSH eventually introduced a feature specifically for this jump host scenario: ProxyJump (or its command-line flag -J). This is the modern, canonical way to do it and it’s much, much cleaner.

Instead of building tunnels yourself, you just tell the SSH client: “Hey, to get to this final host, you need to hop through this other one first.” The client handles all the messy authentication and connection chaining for you.

The syntax is beautifully simple:

ssh -J <bastion_user>@<bastion_host> <final_user>@<final_host>

For our example:

ssh -J user@bastion.mycompany.com devuser@app-server.internal

You run this command from your laptop. It will first authenticate you to the bastion, then seamlessly use that connection to authenticate you to the app server. All in one go. You’re directly in your app server’s shell. No extra steps. It’s glorious.

Making It Permanent: Your ~/.ssh/config File

You are not typing those flags every time. Let’s be serious. This is where the ~/.ssh/config file elevates you from a mere user to a wizard. You define these relationships once, and then SSH just Does The Right Thing™.

Here’s how you’d configure it for our example:

# ~/.ssh/config

# Define the bastion host
Host bastion
    HostName bastion.mycompany.com
    User user
    IdentityFile ~/.ssh/id_ed25519_bastion

# Define the internal app server and tell it to jump via the bastion
Host app-server
    HostName app-server.internal
    User devuser
    IdentityFile ~/.ssh/id_ed25519_internal
    ProxyJump bastion

# Want to use the bastion for a whole range of hosts? Use a wildcard.
Host *.internal
    User devuser
    IdentityFile ~/.ssh/id_ed25519_internal
    ProxyJump bastion

With this config, the command to access your app server shrinks down to pure elegance:

ssh app-server

The client reads the config, sees that app-server requires a jump through the bastion host, and sets up the entire connection chain automatically. It also uses the correct keys for each hop. This isn’t just convenient; it’s professional-grade infrastructure management.

The Power Combo: Tunneling Through a Jump

Here’s where you combine both concepts for pure power. Let’s say you need a local tunnel to a database (db-server.internal:5432), but you have to jump through a bastion. The config file handles this with ease.

ssh -L 5432:db-server.internal:5432 app-server

Wait, what? Yes. This command says: “SSH to app-server (which, from our config, we know uses a jump). Then, set up a local tunnel through that entire connection chain.” The tunnel’s target (db-server.internal:5432) is resolved from the perspective of the final app-server host. This is incredibly powerful for debugging or accessing any service on the remote network as if it were local.

The key insight is that the -L tunnel is established after the SSH connection (including any jumps) is made. So the tunnel runs through the secure pipe you just built.

The major pitfall to scream about here? You will forget your tunnels are running. You’ll close your terminal window, but the SSH process might stay alive in the background, holding onto that local port. Then, when you try to run the command again, it’ll fail because localhost:5432 is already in use. Always know how to find and kill rogue SSH processes (ps aux | grep ssh and then kill -9 <pid> is the nuclear option). It’s a small price to pay for this much power.