SSH Tunnel Explained: -L, -R and -D Port Forwarding (2026)

SSH Tunnel Explained: -L, -R and -D Port Forwarding (2026)

An SSH tunnel carries an arbitrary TCP connection through an encrypted SSH connection. It lets you reach a database, an admin panel or an internal dashboard on your server without opening a single port to the internet. There is nothing to install: if you can log into a server via SSH, you can almost always build an SSH tunnel too.

There are three directions, and they confuse almost everyone the first time: -L brings a service from the server to you, -R brings a service from you to the server, and -D turns SSH into a SOCKS proxy. This article explains all three well enough that you won’t have to look up the syntax again, and not just in theory. For this text we started a throwaway SSH server on our own host and measured what a tunnel costs, how much it carries, and at which two points it silently ignores a setting you asked for.

A glowing encrypted channel of light pierces a massive dark wall, connecting a laptop to a hidden server room

If SSH itself is still new to you, read our fundamentals article What Is SSH? first. It covers how the encryption works and why keys instead of passwords are not a matter of convenience. This article is about the next step: everything you can carry through that connection once it’s up.

What is an SSH tunnel? The principle in one sentence

An SSH tunnel is an agreement between your SSH client and the SSH server: “Whatever arrives at this port, wrap it into the encrypted connection and deliver it to that address on the other side.”

That sounds abstract, but it’s very concrete in daily use. On our server, for example, a PostgreSQL database listens on 127.0.0.1:5432. That’s deliberate: the database is unreachable from outside, only programs on the server itself can talk to it. If I still want to browse it with a graphical tool from my laptop, I have two options:

  1. Open port 5432 in the firewall and make PostgreSQL listen on all interfaces. The database is now on the internet, and every bot in the world gets to guess passwords.
  2. Build an SSH tunnel. The database stays where it is. My laptop talks to a local port, and SSH carries the packets through the connection that’s open anyway.

Option 1 is the mistake we describe with real cases in our article on IT security vulnerabilities. Option 2 is the reason SSH tunnels exist.

Three concepts appear in every tunnel command. Once you separate them, the syntax stops being a puzzle:

  • The entrance: the port where the tunnel accepts connections. With -L it’s on your machine, with -R it’s on the server.
  • The destination: the address the tunnel delivers to on the far end. Crucially, this address is resolved from the far side’s point of view. 127.0.0.1 as the destination of -L means “the server itself”, not your laptop.
  • The transport: the SSH connection to user@server that everything runs through.

The most common mistake with SSH tunnels is exactly the second point. localhost in a tunnel command always means “localhost on the side where the destination is resolved”. Once that clicks, you’ll write these commands in your sleep.

Three glowing light conduits branch from one encrypted core: one inward, one outward, one fanning out

Hussein Nasser’s video walks through local and remote forwarding with examples and is a good companion if you’d rather see the directions drawn out once.

How we tested

To avoid touching the real SSH service on our server, we started a second, temporary sshd for every experiment: its own port (2299), bound only to 127.0.0.1, its own host key, its own client key, password login off. Server and client are the same machine (12 cores, Ubuntu, OpenSSH_9.6p1). That means network latency plays no role in our numbers. What we measure is the pure overhead of SSH itself. For throughput questions that’s the more honest comparison, because real networks would bury every effect under their own latency.

The test services were a small Python web server, a minimal custom socket server, and a real Node.js service that runs on the server anyway. After the tests we killed all processes and verified that none of the test ports was still listening.

Here is the baseline of the production sshd (sshd -T), so you know which defaults you’re probably working with yourself:

allowtcpforwarding yes
gatewayports no
permitopen any
permittunnel no
clientaliveinterval 0

These are Ubuntu’s defaults. allowtcpforwarding yes means: anyone allowed to log in is also allowed to tunnel. We’ll come back to that in the security section.

Local port forwarding with -L

Local forwarding is the case you’ll need most often. You take a service that’s only reachable on the server (or only in the network behind it) and put it on a port on your own machine.

ssh -L 8080:127.0.0.1:5432 user@server

Read left to right: “Open port 8080 on my machine. Send whatever arrives there through the connection to server, and the server should hand it to 127.0.0.1:5432.” Then you point your database client at localhost:8080, and it’s actually talking to PostgreSQL on the server.

In our test this worked immediately. ss -tlnp showed the new entrance afterwards, twice:

LISTEN 127.0.0.1:18080   users:(("ssh",pid=2932593,fd=5))
LISTEN     [::1]:18080   users:(("ssh",pid=2932593,fd=4))

So the SSH client listens only on your loopback interface, for IPv4 and IPv6. Other devices on your Wi-Fi can’t reach the tunnel. That’s the safe default. The first request through the tunnel came back with HTTP 200 in 1.7 milliseconds.

The destination doesn’t have to be the server itself

The destination can be any machine the server can reach. That makes -L the tool for networks you can’t get into from outside at all:

ssh -L 8443:10.0.0.5:443 user@bastion

Here bastion is a jump host with a public address, and 10.0.0.5 is an internal admin panel that only lives on the private network. Your browser opens https://localhost:8443, and the bastion host passes the connection inward. Important: the hop from the bastion host to 10.0.0.5 is not encrypted by SSH. Only the path from you to the bastion is. With HTTPS destinations that doesn’t matter; with plaintext protocols you should know that the last stretch is open.

Just the tunnel, no shell

If you only want the tunnel and no command line, two flags help:

ssh -N -f -L 8080:127.0.0.1:5432 user@server
  • -N runs no command on the server. The connection exists only for the tunnel.
  • -f sends SSH to the background after login. Your terminal is free again.

With -f the tunnel keeps running until you kill the process. Find it with pgrep -af "ssh -N", stop it with kill <PID>. A control connection, shown further down, is the more elegant way.

Several tunnels in one connection

You can pass -L as often as you like. One SSH connection carries all tunnels at once:

ssh -N -L 8080:127.0.0.1:5432 -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3000 user@server

Database, monitoring and an internal dashboard, all over a single login. To the server’s firewall this is invisible: it only sees the SSH connection on port 22.

Trap 1: a busy port, and SSH keeps going anyway

Here’s the first finding from our tests, and it’s a sneaky one. We put a second tunnel on a port the first tunnel was already using. Result without extra options:

bind [127.0.0.1]:18080: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 18080
Could not request local forwarding.

That looks like an error. It’s only half of one: the SSH process kept running. Our timeout 4 had to kill it after four seconds (exit code 124). The connection is up, the login succeeded, only the tunnel you started the whole thing for doesn’t exist. With -f in the background you won’t even see the warning if the output disappears into a log somewhere. And if the busy port happens to belong to another service, you’ll connect to that and wonder about the answers.

The fix is a single option:

ssh -o ExitOnForwardFailure=yes -N -f -L 8080:127.0.0.1:5432 user@server

With ExitOnForwardFailure=yes, SSH aborted the same attempt immediately with exit code 255. A script notices that, a systemd service restarts, and you see the error. In scripts and services this option belongs in every tunnel. We’d put it in ~/.ssh/config for interactive use too.

Remote port forwarding with -R (reverse SSH tunnel)

-R is the opposite direction. The entrance is on the server, the destination is on your side. This makes a service running on your machine reachable on the server.

ssh -R 9000:127.0.0.1:3000 user@server

Read: “Open port 9000 on the server. Send whatever arrives there back through the connection to me, and my machine hands it to 127.0.0.1:3000.”

A small glowing node behind a closed gate sends a beam of light to a public tower on a hill and opens a door there

What is it for? Three typical cases:

  • A machine behind NAT or a mobile router that can’t accept incoming connections. It connects out to the server on its own, and via -R you can reach it back from there. That’s the classic reverse SSH tunnel for remote maintenance.
  • Webhooks to your local development machine. A payment provider wants to call your webhook at a public address, but your code runs on your laptop. The server accepts the request and the tunnel passes it through to you.
  • Showing something that isn’t deployed yet.

In our test, a request to the new port on the server side came back immediately with HTTP 200 from the service on the client side.

Trap 2: GatewayPorts silently drops your address

Now the second and more important finding. If you want to receive a webhook from outside, the -R entrance needs to be on the public interface, not on loopback. So you spell out the address:

ssh -R 0.0.0.0:19000:127.0.0.1:18500 user@server

We ran exactly that against our test server with Ubuntu’s default GatewayPorts no, with ExitOnForwardFailure=yes added. SSH did not abort, printed no warning, and the tunnel was up. A look at the server side:

LISTEN 127.0.0.1:19000
LISTEN     [::1]:19000

The server simply ignored the 0.0.0.0 and bound the port to loopback only. The tunnel is unreachable from outside. And because, from the client’s point of view, the tunnel was set up successfully, ExitOnForwardFailure doesn’t fire either. You’ll go looking for the fault in your firewall, at the webhook provider or in your code, but not where it actually is.

For -R to listen publicly, the server has to allow it, in /etc/ssh/sshd_config:

GatewayPorts clientspecified

With clientspecified the client may choose the bind address itself. yes would make every -R tunnel public, including the ones where you didn’t want that. We recommend clientspecified, plus a restriction to just the user who needs it:

Match User tunnel
    GatewayPorts clientspecified
    AllowTcpForwarding remote
    PermitListen 0.0.0.0:9000
    X11Forwarding no
    PermitTTY no

Then sudo sshd -t (syntax check) and sudo systemctl reload ssh. And don’t forget that the port then also needs to be open in the firewall. Our guide on UFW firewall setup shows how to do that cleanly.

The mirror image on the client side

The asymmetry is interesting. We tried the same thing with -L, -L 0.0.0.0:18081:..., and there the client obeyed the address: the port listened on 0.0.0.0, i.e. for every device that can reach your machine. That’s documented (an explicit bind address takes precedence for -L), but in practice it means: on your laptop on café Wi-Fi, a single 0.0.0.0 in the command lets anyone at the next table reach your server’s database through your laptop. So for -L: don’t specify a bind address unless you explicitly want to share the tunnel with other devices.

In short: with -L your client decides, and it does what you say. With -R the server decides, and it overrules you without telling you.

Dynamic forwarding with -D (SOCKS proxy)

-L and -R always connect exactly one port to exactly one destination. -D is different: it opens a SOCKS proxy, and the application states where it wants to go for each connection.

ssh -D 1080 -N user@server

Then configure a SOCKS5 proxy at 127.0.0.1:1080 in your browser (or in curl, a database client, a package manager). Every connection that application makes now exits to the network at the server. To websites it looks as if you were sitting in the data center.

An armored glowing pipeline enters a central hub from which many fine coloured threads of light radiate into the distance

In our test:

curl --socks5-hostname 127.0.0.1:11080 http://127.0.0.1:18500/

returned HTTP 200, from the service on the server side. So the 127.0.0.1 in the URL was resolved on the server, not locally. That’s the --socks5-hostname variant (or socks5h://): DNS resolution goes through the tunnel too. With plain --socks5, curl resolves the name locally, and your DNS queries bypass the tunnel. In browsers the equivalent setting is usually called “proxy DNS when using SOCKS”. If you use -D to avoid exposing your traffic on the local network, you need that setting.

Typical uses:

  • Reaching several internal web interfaces in one network without building a separate -L tunnel for each.
  • Routing your browser traffic encrypted through your own server on untrusted Wi-Fi.
  • Checking what a site looks like from the data center (geo-blocking, caching, firewall rules).

A SOCKS proxy is not a VPN. It only carries what an application explicitly hands to it. Your system, other programs and UDP traffic keep going direct. If you want to connect whole devices or networks, a real VPN is the better tool. In our comparison Tailscale vs WireGuard we measured both.

The Automation Avenue video shows -R and -D step by step in a lab setup.

What does an SSH tunnel cost? Our measurement

The most common worry: does the tunnel slow everything down? We measured two things, throughput and response time.

Throughput

A 200 MB file, downloaded three times directly from the local web server and three times through an -L tunnel:

RunDirectThrough the tunnel
11.98 GB/s0.76 GB/s
21.67 GB/s0.85 GB/s
31.87 GB/s0.85 GB/s

So the tunnel keeps roughly 40 to 50 percent of loopback throughput. That sounds dramatic, but it isn’t: around 800 MB/s is a good 6 gigabits per second. That’s how fast a single SSH connection can encrypt and decrypt on one CPU core. Your real internet link is almost always far below that. On a 1 Gbit/s server uplink, the tunnel is not the bottleneck.

Worth knowing: one SSH connection encrypts on one core. Several tunnels in the same connection share that core. If you move large amounts of data in parallel, several separate connections are faster than one with many -Ls.

Response time, and the moment we almost measured it wrong

For response time we first saw a number that gave us a brief scare. 50 small requests to the Python web server:

Median95th percentile
Direct0.54 ms0.73 ms
Through the tunnel41.99 ms42.13 ms

Almost 42 milliseconds per request, on the same machine, perfectly constant. Had we stopped here, this article would say: “An SSH tunnel costs 40 ms per request.” That would have been wrong.

We repeated the same measurement against two other services:

ServiceDirectThrough the tunnel
Minimal socket server (reply in a single write)0.6–0.9 ms0.7–1.1 ms
Real Node.js service on the server0.8–1.3 ms1.0–1.8 ms

For both, the tunnel costs a few tenths of a millisecond, not 40. The difference: the Python test server (http.server, HTTP/1.0) sends headers and body in separate writes. We didn’t trace the cause down to the last packet. But the pattern matches a well-known TCP interaction exactly: Nagle’s algorithm holds back a small packet until the previous one is acknowledged, and the other side delays its acknowledgment (delayed ACK). On Linux that minimum delay is 40 milliseconds. The SSH connection is an extra TCP leg where that interaction can occur.

The practical lesson: if a service suddenly feels sluggish through a tunnel, with a suspiciously round, constant delay around 40 ms, it’s almost never the encryption. Measure against a second service before you blame the tunnel.

Running the tunnel permanently

A tunnel on the command line dies when the connection drops: Wi-Fi switch, router restart, server reboot. For anything that should stay up, you need two things: keepalives so SSH notices a dead connection at all, and a supervisor that restarts it.

A sturdy glowing cable anchored to a pulsing device whose pulse reconnects a briefly broken segment

Keepalives

Out of the box, SSH sends no keepalives. Our production server is at clientaliveinterval 0, and the client doesn’t ask by default either. A connection whose other end has quietly disappeared can stay “connected” for a very long time, and your tunnel accepts connections that never arrive anywhere. On the client side two options are enough:

ServerAliveInterval 15
ServerAliveCountMax 3

The client now checks every 15 seconds, and after three unanswered checks (about 45 seconds) it gives up and exits. That exit is what makes a restart possible in the first place.

Restarting with systemd

autossh used to be the standard tool for this. It still works, but on modern systems it’s unnecessary because systemd does exactly that. autossh isn’t even installed on our server, and we’ve never missed it. A unit file /etc/systemd/system/db-tunnel.service:

[Unit]
Description=SSH tunnel to the database
After=network-online.target
Wants=network-online.target

[Service]
User=tunnel
ExecStart=/usr/bin/ssh -N \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=15 \
  -o ServerAliveCountMax=3 \
  -o BatchMode=yes \
  -i /home/tunnel/.ssh/id_ed25519 \
  -L 127.0.0.1:15432:127.0.0.1:5432 \
  tunnel@db.example.com
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Then:

sudo systemctl daemon-reload
sudo systemctl enable --now db-tunnel
systemctl status db-tunnel

Three of these options are not decoration. ExitOnForwardFailure turns a busy port into a restart instead of a silent half-state (trap 1). ServerAlive* turns a dead line into a restart. BatchMode=yes stops SSH from asking for a password and waiting forever for input that never comes. No -f: systemd wants to keep the process in the foreground. Our guide on how to create a systemd service covers unit files in detail.

On the very first start, the destination server’s host key must already be in the tunnel user’s known_hosts. So connect once by hand as that user and confirm the fingerprint. StrictHostKeyChecking=no as a shortcut is a bad idea: the tunnel would then happily connect to an impostor server too.

Tunnels made easy in ~/.ssh/config

Nobody likes typing long tunnel commands twice. In ~/.ssh/config every tunnel gets a name:

Host db-tunnel
    HostName server.example.com
    User fabian
    IdentityFile ~/.ssh/id_ed25519
    LocalForward 15432 127.0.0.1:5432
    LocalForward 19090 127.0.0.1:9090
    ExitOnForwardFailure yes
    ServerAliveInterval 15
    ServerAliveCountMax 3
    SessionType none

After that, ssh db-tunnel is all it takes. LocalForward equals -L, RemoteForward equals -R, DynamicForward equals -D. SessionType none is the config spelling of -N (OpenSSH 8.7 and later; older versions take -N on the command line).

Tunnelling through a jump host

If the destination sits behind a bastion host, combine the tunnel with ProxyJump:

ssh -J user@bastion -L 15432:127.0.0.1:5432 user@internal

Your client first connects to the bastion host and from there opens a second, also end-to-end encrypted connection to internal. The tunnel ends on internal, and your private key never leaves your machine. That’s clearly preferable to agent forwarding with -A, for the reasons we describe in What Is SSH?.

Adding tunnels later and closing them cleanly

Two little-known tools make daily life easier. In a running interactive session, the key sequence Enter, ~, C opens a small command line where -L 8080:127.0.0.1:80 adds a tunnel without reconnecting. Since OpenSSH 9.2 that command line is disabled by default; turn it back on with EnableEscapeCommandline yes in ~/.ssh/config.

For scripts there are control connections:

ssh -f -N -M -S ~/.ssh/tunnel.sock -L 15432:127.0.0.1:5432 user@server
# ... work ...
ssh -S ~/.ssh/tunnel.sock -O exit user@server

-M -S creates a control socket, and -O exit closes exactly that tunnel without hunting for process IDs. -O check asks whether it’s still alive.

Security: what an SSH tunnel means for attackers

So far we’ve covered what you can do with a tunnel. Now the flip side: anyone who can log into your server can do the same.

With Ubuntu’s defaults AllowTcpForwarding yes and PermitOpen any, every SSH user may build tunnels to any destination the server can reach. A user you only meant to give file transfer can reach your locally bound database, Redis instances or internal admin panels. And via -D they can use your server as a proxy for arbitrary internet traffic, which then shows up with your server’s IP.

That’s no reason to panic, but it is a reason to think. Three levers:

1. Allow tunnels only where they’re needed. Turn them off globally and enable them selectively:

AllowTcpForwarding no

Match User admin
    AllowTcpForwarding yes

2. Restrict keys. In ~/.ssh/authorized_keys you can put options in front of each key. A key that’s only good for one specific database tunnel:

restrict,port-forwarding,permitopen="127.0.0.1:5432" ssh-ed25519 AAAA... backup-tunnel

restrict turns everything off (terminal, agent, X11, forwarding), port-forwarding re-enables tunnels, permitopen limits them to exactly one destination. If this key is stolen, the thief can build exactly that one tunnel and nothing else. Our guide on how to generate an SSH key shows how to create keys properly in the first place.

3. Make logins hard. A tunnel is only as secure as the login it sits on. Password login off, keys on, and against the background noise of bots, Fail2ban helps. In our SSH fundamentals article we counted 26,753 failed login attempts in 22 days on a single server.

One more note on corporate networks: an SSH tunnel can bypass firewall rules. That’s the whole point when it’s your own infrastructure. In someone else’s network it may violate acceptable-use rules. Check first whether you’re allowed to.

SSH tunnels on Windows and macOS

The commands in this article work unchanged on macOS and on Windows 10 and 11. Both ship OpenSSH; on Windows it’s ssh.exe in Terminal or PowerShell. The config file on Windows lives at C:\Users\<name>\.ssh\config.

If you prefer clicking, PuTTY has the same three variants under “Connection → SSH → Tunnels”: “Local” for -L, “Remote” for -R, “Dynamic” for -D. Many database clients (DBeaver, DataGrip, TablePlus, pgAdmin) also have a built-in “SSH tunnel” option. Internally it builds exactly one -L tunnel; you just enter server, user and key.

SSH tunnel, VPN or Cloudflare Tunnel?

An SSH tunnel isn’t the best tool for every job. Our rules of thumb from our own operations:

GoalTool
Quickly reach a single service on a server (database, dashboard)SSH tunnel with -L
Permanent link between two servers for one serviceSSH tunnel via systemd, or WireGuard
Connect whole devices or networks, including UDPVPN (WireGuard, Tailscale)
Expose a local service publicly on the internetReverse tunnel with -R on your own server, or a service like Cloudflare Tunnel
Protect browser traffic on untrusted Wi-Fi-D (SOCKS)

The big advantage of an SSH tunnel: it needs nothing new. No service, no account, no additional open port. The downside: it only carries TCP, it hangs on a single connection, and it encrypts on one core. For anything involving many devices or lots of bandwidth, a VPN is more robust.

A word on -w and PermitTunnel: OpenSSH can also tunnel real network interfaces (tun devices), i.e. a small layer-3 VPN. On our server that’s off with permittunnel no; it needs root on both ends and in practice is clearly inferior to WireGuard. We mention it only for completeness.

Common errors and what they mean

bind [127.0.0.1]:8080: Address already in use: the local entrance port is taken, by an earlier tunnel or another program. ss -tlnp | grep 8080 shows who has it. And: without ExitOnForwardFailure=yes, SSH keeps running anyway (trap 1).

channel 2: open failed: connect failed: Connection refused: the tunnel is up, but nothing is listening at the destination. Check on the far side that the service is running and listening on the address you gave as the destination. Classic: the service listens on 127.0.0.1 and you entered the public IP as the destination, or the other way round.

channel 2: open failed: administratively prohibited: open failed: the server forbids the tunnel. The cause is AllowTcpForwarding no, a permitopen that doesn’t include this destination, or restrict on the key.

-R only works from the server itself, not from outside: GatewayPorts is set to no (trap 2). No error, no warning, just a loopback port.

The tunnel works, but after a while nothing gets through: the connection is dead without SSH noticing. Set ServerAliveInterval.

SSH tunnel FAQ

Is an SSH tunnel secure?

The path between your client and the SSH server is protected by the same encryption as any SSH session. What isn’t encrypted is the hop from the SSH server to the destination, if the destination is another machine. The bigger risks lie elsewhere: a tunnel entrance accidentally listening on 0.0.0.0, and users who are allowed to build tunnels when they shouldn’t be. You can fix both with the settings from the security section.

Is an SSH tunnel a VPN?

No. An SSH tunnel forwards individual TCP ports or (with -D) the traffic of individual applications. A VPN connects whole network interfaces, including UDP and every program. For one service the tunnel is simpler; for whole devices a VPN is better.

What’s the difference between -L and -R?

With -L the entrance is on your machine and the destination is reached from the server. You bring a service from the server to you. With -R the entrance is on the server and the destination is reached from your machine. You provide a service from your side on the server.

How do I keep an SSH tunnel open permanently?

With ServerAliveInterval and ServerAliveCountMax so dead connections are detected, plus a supervisor that restarts it. The simplest is a systemd service with Restart=always, as shown above. autossh works too but isn’t necessary anymore.

How do I close an SSH tunnel?

Interactively with Ctrl+C or exit. Background tunnels (-f) you end with kill on the process ID (pgrep -af "ssh -N"), or more cleanly via a control connection with ssh -S <socket> -O exit. systemd tunnels stop with systemctl stop.

Does an SSH tunnel slow down my connection?

Barely. In our measurement on the same machine, the tunnel cost a few tenths of a millisecond per request, and throughput was around 800 MB/s, well above any typical internet connection. A strikingly constant delay of around 40 ms is usually a TCP effect in combination with the service, not the encryption.

Do I need root for an SSH tunnel?

Not for -L and -D on ports 1024 and up. Ports below 1024 (like 80 or 443) as the entrance need root on that side. For -R on public addresses, someone with root has to change GatewayPorts in sshd_config.

Can I use several tunnels at once?

Yes, in a single connection. -L, -R and -D can be given any number of times and mixed freely, and in ~/.ssh/config you simply write several LocalForward lines one below the other.

Conclusion: the SSH tunnel is the most underrated tool on your server

An SSH tunnel solves a problem that’s otherwise often solved with an open port, and solved wrong. Databases, dashboards and admin panels can stay on 127.0.0.1, and you still reach them in seconds. No new service, no new account, just the connection you already have.

From our tests we take away two rules you’ll hardly find in any tutorial. First: ExitOnForwardFailure=yes belongs in every tunnel that isn’t running right in front of your eyes; otherwise the connection is up and the tunnel is missing. Second: with GatewayPorts no, the server silently bends a -R on 0.0.0.0 back to loopback. No error, just a tunnel that can’t be reached from outside. Knowing that once saves you an hour of debugging in the wrong place.

If you’re setting up a server right now, the sensible next step is our guide on Linux server setup. It shows how firewall, SSH hardening and services fit together so that tunnels become the normal way to reach internal services and open ports the exception.