A visual guide to SSH tunneling with examples and diagrams

26.06.2025 15 minutes Author: Lady Liberty

SSH tunnels are a powerful tool for secure connections between local and remote networks. In this article, we explain how SSH tunneling works, what local (Local Forwarding), remote (Remote Forwarding) and dynamic (SOCKS proxy) tunnels are. Instead of complex terms, we use intuitive diagrams, simple examples and real-world use cases.

A visual guide for beginners with examples, diagrams, and commands

SSH is another example of an old technology that is still widely used. It’s possible that learning a few SSH tricks will prove to be more beneficial in the long run than mastering a dozen cloud tools that will become obsolete next quarter.

One of my favorite parts of this technology is SSH tunnels. Using only standard tools and often just one command, you can achieve the following:

  • Access internal VPC endpoints through a public EC2 instance.

  • Open a port from the local host of the development VM in the host browser.

  • Expose any local server from your home/private network to the outside world.

But even though I use SSH tunnels every day, it always takes me a while to figure out the right command. Should it be a local or remote tunnel? What are the flags? Is it local_port:remote_port or vice versa? So I decided to finally figure it out, and the result was a series of labs and a visual cheat sheet.

Prerequisites

SSH tunnels are designed to connect hosts over a network, so each lab below involves multiple “machines,” as expected. However, running multiple virtual machines for each lab can be tedious, so I used containers to simulate network hosts. This way, a single Linux server running Docker (or similar) can be used to run all of the labs.

Each example requires a valid key pair without a passphrase on the host, which is then mounted into the containers to simplify access control. If you don’t have one, the labs provide an example of how to generate one.

Important: The SSH daemons in the containers here are for educational purposes only—the containers in this post are intended to represent full-fledged “machines” with SSH clients and servers on them. Keep in mind that it is rarely a good idea to put the SSH constructor inside real containers!

Local port forwarding

Starting with the one I use most often. Often there might be a service listening on localhost or a private interface of a machine that I can only connect to via SSH via its public IP address. And I absolutely need access to that port from the outside. A few typical examples:

  • Access your database (MySQL, Postgres, Redis, etc.) using a user-friendly UI tool from your laptop.

  • Use a browser to access a web application that is only accessible to the private network.

  • Access the container port from your laptop without publishing it on the public server interface.

All of the above use cases can be solved with a single ssh command:

ssh -L [local_addr:]local_port:remote_addr:remote_port [user@]sshd_addr

The -L flag indicates that we are starting local port forwarding. In reality, this means:

  • On your computer, the SSH client will start listening on local_port (probably localhost, but it depends on the circumstances – check the GatewayPorts settings).

  • Any traffic to this port will be redirected to the remote_private_addr:remote_port computer you are connected to via SSH.

Ось як це виглядає на схемі:

Pro Tip: Use ssh -f -N -L to start a port forwarding session in the background.

Lab 1: Using SSH Tunnels for Local Port Forwarding

Launch an online platform

The lab recreates the setup from the diagram above. First, we need to prepare a server—a machine with an SSH daemon and a simple web service listening on 127.0.0.1:80:

$ docker buildx build -t server:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3

# Install the dependencies:
RUN apk add --no-cache openssh-server curl python3
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A

# Prepare the entrypoint that starts the daemons:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail

for file in /tmp/ssh/*.pub; do
  cat ${file} >> /root/.ssh/authorized_keys
done
chmod 600 /root/.ssh/authorized_keys

# Minimal config for the SSH server:
sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
/usr/sbin/sshd -e -D &

python3 -m http.server --bind 127.0.0.1 ${PORT} &

sleep infinity
EOF

# Run it:
CMD ["/entrypoint.sh"]
EOD

Starting the server and recording its IP address:

$ yes no | ssh-keygen -t rsa -N "" -f ~/.ssh/id_iximiuz_lab

$ docker run -d --rm \
   -e PORT=80 \
   -v $HOME/.ssh:/tmp/ssh \
   --name server \
   server:latest

SERVER_IP=$(
  docker inspect \
    -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
  server
)

Since the web service is listening on the localhost, it will not be accessible from the outside (i.e., in this particular case, from the host system):

$ curl ${SERVER_IP}
curl: (7) Failed to connect to 172.17.0.2 port 80: Connection refused

But from inside the “server” everything works fine:

$ ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
  root@${SERVER_IP}
7b3e49181769:$# curl localhost
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
...

Here’s the trick: bind the server localhost:80 to the host localhost:8080 using local port forwarding:

ssh -i "$HOME/.ssh/id_iximiuz_lab" \
-o StrictHostKeyChecking=no \
-f -N -L 8080:localhost:80 \
remote_addr

You should now be able to access the web service on the local port of the host system:

$ curl localhost:8080
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
...

A slightly more verbose (but more explicit and flexible) way to achieve the same goal is to use the form local_addr:local_port:remote_addr:remote_port:

$ ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
  -f -N -L localhost:8080:localhost:80 root@${SERVER_IP}

Local Port Forwarding with Bastion Host

It may not be obvious at first, but the ssh -L command allows you to forward a local port to a remote port on any machine, not just the SSH server itself. Note that `remote_addrand` may or may not have the same value as sshd_addr:

ssh -L [local_addr:]local_port:remote_addr:remote_port [user@]sshd_addr

Not sure how legitimate it is to use the term “bastion host” here, but this is how I imagine this scenario:

I often use the above trick to call endpoints accessible from the bastion host but not from my laptop (e.g. using an EC2 instance with private and public interfaces to connect to an OpenSearch cluster fully deployed in a VPC).

Lab 2: Local Port Forwarding with Bastion Host

Launch an online platform

Again, the lab replicates the setup from the diagram above. First, we need to prepare a bastion host—a machine with only the SSH daemon installed:

$ docker buildx build -t bastion:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3

# Install the dependencies:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A

# Prepare the entrypoint that starts the SSH daemon:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail

for file in /tmp/ssh/*.pub; do
  cat ${file} >> /root/.ssh/authorized_keys
done
chmod 600 /root/.ssh/authorized_keys

# Minimal config for the SSH server:
sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
/usr/sbin/sshd -e -D &

sleep infinity
EOF

# Run it:
CMD ["/entrypoint.sh"]
EOD

Starting the bastion host and recording its IP address:

$ yes no | ssh-keygen -t rsa -N "" -f ~/.ssh/id_iximiuz_lab

$ docker run -d --rm \
    -v $HOME/.ssh:/tmp/ssh \
    --name bastion \
    bastion:latest

BASTION_IP=$(
  docker inspect \
    -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
  bastion
)

Now, running the target web service on a separate “machine”:

$ docker run -d --rm \
    --name server \
    python:3-alpine \
    python3 -m http.server 80

SERVER_IP=$(
  docker inspect \
    -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
  server
)

Let’s imagine that calling curl ${SERVER_IP} directly from the host is not possible for some reason (for example, there is no route from the host to that IP address). So, we need to start port forwarding:

$ ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
  -f -N -L 8080:${SERVER_IP}:80 root@${BASTION_IP}

Note that the SERVER_IP and BASTION_IP variables have different values ​​in the above command.

Checking it works:

$ curl localhost:8080
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
...

Remote port forwarding

Another popular (but rather counterintuitive) scenario is when you want to temporarily expose a local service to the outside world. Of course, you’ll need a public inbound gateway server for this. But fear not! Any public server with an SSH daemon can be used as such a gateway:

ssh -R [remote_addr:]remote_port:local_addr:local_port [user@]gateway_addr

The above command doesn’t look any more complicated than its ssh -L counterpart. But there is one catch…

By default, the above SSH tunnel will only allow the local host of the gateway to be used as the remote address. In other words, your local port will only be accessible from within the gateway server itself, and you probably don’t really need that. For example, I usually want to use the public gateway address as the remote address to expose my local services to the public internet. To do this, the SSH server needs to be configured with the GatewayPorts yes parameter.

Here’s what you can use remote port forwarding for:

  • Opening a demo access to a development service from your laptop to the public internet.

  • Hmm… I can think of some esoteric examples, but I doubt they’re worth sharing here. I’d be interested to hear what other people might use remote port forwarding for!

Here’s what remote port forwarding looks like in a diagram:

Pro Tip: Use ssh -f -N -R to start a port forwarding session in the background.

Lab 3: Using SSH Tunnels for Remote Port Forwarding

Launch an online platform

The lab recreates the setup from the diagram above. First, we need to prepare a “developer machine” – a computer with an SSH client and a local web server:

$ docker buildx build -t devel:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3

# Install dependencies:
RUN apk add --no-cache openssh-client curl python3
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh

# Prepare the entrypoint that starts the web service:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail

cp /tmp/ssh/* /root/.ssh
chmod 600 /root/.ssh/*

python3 -m http.server --bind 127.0.0.1 ${PORT} &

sleep infinity
EOF

# Run it:
CMD ["/entrypoint.sh"]
EOD

Starting the development machine:

$ yes no | ssh-keygen -t rsa -N "" -f ~/.ssh/id_iximiuz_lab

$ docker run -d --rm \
    -e PORT=80 \
    -v $HOME/.ssh:/tmp/ssh \
    --name devel \
    devel:latest

Preparing the inbound gateway server – a simple SSH server with the GatewayPorts setting: yessshd_config

$ docker buildx build -t gateway:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3

# Install the dependencies:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A

# Prepare the entrypoint that starts the SSH server:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail

for file in /tmp/ssh/*.pub; do
  cat ${file} >> /root/.ssh/authorized_keys
done
chmod 600 /root/.ssh/authorized_keys

sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
sed -i '/GatewayPorts/d' /etc/ssh/sshd_config
echo 'GatewayPorts yes' >> /etc/ssh/sshd_config

/usr/sbin/sshd -e -D &

sleep infinity
EOF

# Run it:
CMD ["/entrypoint.sh"]
EOD

Starting the gateway server and recording its IP address:

$ docker run -d --rm \
    -v $HOME/.ssh:/tmp/ssh \
    --name gateway \
    gateway:latest

GATEWAY_IP=$(
  docker inspect \
    -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
  gateway
)

Now, from inside the developer machine, run remote port forwarding:

$ docker exec -it -e GATEWAY_IP=${GATEWAY_IP} devel sh
/ $# ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
  -f -N -R 0.0.0.0:8080:localhost:80 root@${GATEWAY_IP}
/ $# exit  # or detach with ctrl-p, ctrl-q

And check if the local port of the developer machine has become open on the public interface of the gateway (from the host system):

$ curl ${GATEWAY_IP}:8080
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
...

Remote port forwarding from home/private network

Similar to local port forwarding, remote port forwarding has its own bastion host mode. But this time, a machine with an SSH client (like your developer laptop) acts as the bastion. Specifically, this allows you to open ports on your home (or private) network that your laptop has access to the outside world via an inbound gateway:

ssh -R [remote_addr:]remote_port:local_addr:local_port [user@]gateway_addr

It looks almost identical to a simple remote SSH tunnel, but the local_addr:local_port pair becomes the address of the device on the home network. Here’s how it can be represented in a diagram:

Since I usually use my laptop as a thin client and the actual development happens on a home server, I rely on this kind of remote port forwarding when I need to provide access to a development service from my home server to the public internet and the only machine with access to the gateway is my thin laptop.

Lab 4: Remote Port Forwarding from a Home/Private Network

Launch an online platform

As usual, the lab recreates the setup from the diagram above. First, we need to prepare a “thin development machine”:

$ docker buildx build -t devel:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3

# Install the dependencies:
RUN apk add --no-cache openssh-client
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh

# This time we run nothing (at first):
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail

cp /tmp/ssh/* /root/.ssh
chmod 600 /root/.ssh/*

sleep infinity
EOF

# Run it:
CMD ["/entrypoint.sh"]
EOD

Launching the “development machine”:

$ yes no | ssh-keygen -t rsa -N "" -f ~/.ssh/id_iximiuz_lab

$ docker run -d --rm \
    -v $HOME/.ssh:/tmp/ssh \
    --name devel \
    devel:latest

Starting a private development server using a separate “machine” and recording its IP address:

$ docker run -d --rm \
    --name server \
    python:3-alpine \
    python3 -m http.server 80

SERVER_IP=$(
  docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
  server
)

Preparing the inbound gateway server:

$ docker buildx build -t gateway:latest -<<'EOD'
# syntax=docker/dockerfile:1
FROM alpine:3

# Install the dependencies:
RUN apk add --no-cache openssh-server
RUN mkdir /root/.ssh && chmod 0700 /root/.ssh && ssh-keygen -A

# Prepare the entrypoint that starts the SSH daemon:
COPY --chmod=755 <<'EOF' /entrypoint.sh
#!/bin/sh
set -euo pipefail

for file in /tmp/ssh/*.pub; do
  cat ${file} >> /root/.ssh/authorized_keys
done
chmod 600 /root/.ssh/authorized_keys

sed -i '/AllowTcpForwarding/d' /etc/ssh/sshd_config
sed -i '/PermitOpen/d' /etc/ssh/sshd_config
sed -i '/GatewayPorts/d' /etc/ssh/sshd_config
echo 'GatewayPorts yes' >> /etc/ssh/sshd_config

/usr/sbin/sshd -e -D &

sleep infinity
EOF

# Run it:
CMD ["/entrypoint.sh"]
EOD

And let’s start this:

$ docker run -d --rm \
    -v $HOME/.ssh:/tmp/ssh \
    --name gateway \
    gateway:latest

GATEWAY_IP=$(
  docker inspect \
    -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
  gateway
)

Now, from within the “development machine”, run the SERVER-GATEWAY remote port forwarding:

$ docker exec -it -e GATEWAY_IP=${GATEWAY_IP} -e SERVER_IP=${SERVER_IP} devel sh
/ $# ssh -i $HOME/.ssh/id_iximiuz_lab -o StrictHostKeyChecking=no \
  -f -N -R 0.0.0.0:8080:${SERVER_IP}:80 root@${GATEWAY_IP}
/ $# exit  # or detach with ctrl-p, ctrl-q

Finally, check if the development server is available on the gateway’s public interface (from the host system):

$ curl ${GATEWAY_IP}:8080
<!DOCTYPE HTML>
<html lang="en">
<head>
...

In summary

After doing all this lab work and drawing, I noticed that:

  • The word “local” can refer to either the SSH client machine or an upstream host accessible from that machine.

  • The word “remote” can refer to either the SSH server (sshd) or the upstream host accessible from it.

  • Local port forwarding (ssh -L) means that the ssh client itself starts listening on the new port.

  • Remote port forwarding (ssh -R) means that the ssh server itself starts listening on the additional port.

  • The mnemonics are “ssh -L local:remote” and “ssh -R remote:local”, and the new port is always opened on the left line.

I hope the above materials have helped you a little to become a master of SSH tunnels.

Subscribe
Notify of
0 Коментарі
Oldest
Newest Most Voted
Found an error?
If you find an error, take a screenshot and send it to the bot.