SSH Port Forwarding ⤳ aka SSH Tunneling — Local Port Forwarding (Part 1)
Learn how SSH local port forwarding securely tunnels TCP traffic from your local machine to remote services. Explore `ssh -L`, localhost, bind addresses, multiple port forwards, bastion hosts, and practical real-world examples.
SSH Tunneling — Part 1: Local Port Forwarding
How SSH tunnels securely forward TCP traffic between systems
SSH tunneling allows you to securely forward network traffic through an encrypted SSH connection. It is commonly used to access services that are otherwise unreachable from your local machine—such as applications bound to localhost, databases on private networks, or services protected by firewalls.
Instead of exposing those services directly to the network, SSH creates an encrypted path between your machine and an SSH server.
Typical use cases include:
- Accessing a web application bound to
localhoston a remote server - Connecting to a remote database without exposing its database port
- Reaching services on private networks through a bastion host
- Safely accessing services that are not publicly reachable
- Forwarding several internal services through a single SSH connection
How SSH Port Forwarding Works
SSH supports three main types of port forwarding:
- Local port forwarding — forwards a port on your local machine to a service reachable from the remote SSH server.
- Remote port forwarding — forwards a port on the remote side to a service reachable from your local machine.
- Dynamic port forwarding — creates a SOCKS proxy that can dynamically route connections through the SSH server.
This article focuses on local port forwarding, the most common form for accessing remote services securely.
Key Concepts
Before creating a tunnel, three concepts are important: localhost, the bind address, and the SSH server's network perspective.
What Is localhost
localhost refers to the machine on which a connection is being made. It normally resolves to the loopback interface, such as 127.0.0.1.
The important detail is that localhost is relative to the machine where it is evaluated.
For example:
Your Mac
localhost → Your Mac
Remote Server
localhost → Remote ServerThis distinction is fundamental to SSH tunneling.
If a service is listening on:
127.0.0.1:3914on a remote server, it is accessible from that server itself, but normally not directly from your computer or the public network.
SSH local forwarding lets you bridge that gap.
What Is a Bind Address
A bind address determines which local network interface accepts connections to a listening socket.
For example:
127.0.0.1:8080means the port is accessible only from the local machine.
Whereas:
0.0.0.0:8080means the service listens on all IPv4 interfaces, potentially making it reachable from other machines.
For SSH forwarding, explicitly binding to 127.0.0.1 is a good default when the forwarded service only needs to be accessed locally.
The SSH Server's Perspective
In a local forward, the destination host is reached from the SSH server's network context.
Consider:
ssh -N -L 8080:localhost:3000 user@serverHere:
8080is a port on your local machinelocalhost:3000refers to the remote server's port3000serveris the SSH endpoint
The connection effectively becomes:
Your Machine
localhost:8080
│
│ encrypted SSH tunnel
▼
Remote Server
localhost:3000
│
▼
Web ApplicationThat distinction becomes particularly useful when the destination is on a private network reachable only from the SSH server.
Local Port Forwarding
Local port forwarding creates a listening port on your machine and sends connections through SSH to a destination accessible from the remote server.
In its simplest form:
Local Port → SSH Tunnel → Remote ServiceFor example:
localhost:8080
│
▼
SSH tunnel
│
▼
remote-server:3000Your application connects to localhost:8080, while SSH transparently carries that traffic to the remote service on port 3000.
The remote service does not need to be publicly exposed.
The Basic SSH Tunneling Command
The general syntax is:
ssh -N -L [LOCAL_BIND_ADDRESS:]LOCAL_PORT:DESTINATION_HOST:DESTINATION_PORT user@REMOTE_SERVERA simple example:
ssh -N -L 3914:localhost:3914 user@REMOTE_SERVER_IPWhat the options mean
-L — Local port forwarding
Creates a listening port on the local machine and forwards connections through SSH.
-N — No remote command
Tells SSH that you do not want to execute a command or open a remote shell.
Together:
ssh -N -L 3914:localhost:3914 user@REMOTE_SERVER_IPmeans:
Listen on local port3914and forward connections through SSH to port3914onlocalhostas seen from the remote server.
Once the tunnel is established:
Your Machine Remote Server
localhost:3914 ─── SSH ───────────► localhost:3914Your application simply connects to:
localhost:3914A Practical Example: Accessing a Remote Web Application
Suppose a development application is running on a remote server:
127.0.0.1:3000Because it is bound to loopback, it cannot be reached directly from your machine.
Create a local tunnel:
ssh -N -L 8080:localhost:3000 devuser@192.168.1.100Now open:
http://localhost:8080The traffic path is:
Browser
│
▼
localhost:8080
│
│ SSH tunnel
▼
192.168.1.100
│
▼
localhost:3000
│
▼
Web ApplicationThe application remains bound to localhost:3000. You are simply creating a secure path to it.
Accessing a Remote Database
The same technique works for databases.
Suppose MySQL is running on the remote server:
127.0.0.1:3306Instead of exposing MySQL to the network, create a tunnel:
$ ssh -N -L 3306:localhost:3306 dbadmin@staging-server.comYour local machine now has:
localhost:3306You can connect to it normally:
$ mysql -h 127.0.0.1 -P 3306 -u myappuser -pThe connection path is:
MySQL Client
│
▼
127.0.0.1:3306
│
│ encrypted SSH connection
▼
staging-server.com
│
▼
127.0.0.1:3306
│
▼
MySQLFrom MySQL's perspective, the connection is coming from the remote server—not directly from your computer.
Using Different Local and Remote Ports
The local and remote ports do not have to be the same.
For example:
$ ssh -N -L 8080:localhost:3000 user@staging-server.comThis creates:
Your Machine Remote Server
localhost:8080 ───────────► localhost:3000Likewise, you can expose remote MySQL 3306 locally as 13306:
$ ssh -N -L 13306:localhost:3306 user@staging-server.comThen connect locally using:
$ mysql -h 127.0.0.1 -P 13306 -u myappuser -pThis is particularly useful when the local port is already occupied.
Forwarding Multiple Services
A single SSH connection can carry multiple local forwards.
For example:
$ ssh -N \
-L 3306:localhost:3306 \
-L 6379:localhost:6379 \
-L 8080:localhost:3000 \
user@staging-server.comThis creates three tunnels:
localhost:3306 ──► remote localhost:3306
localhost:6379 ──► remote localhost:6379
localhost:8080 ──► remote localhost:3000One SSH session can therefore provide secure local access to an entire set of remote services.
Controlling Who Can Access the Tunnel
By default, local forwarding is generally intended for access from the local machine.
You can make that explicit:
$ ssh -N -L 127.0.0.1:8080:localhost:3000 user@staging-server.comNow only the local machine can connect to port 8080.
By contrast:
$ ssh -N -L 0.0.0.0:8080:localhost:3000 user@staging-server.comasks SSH to listen on all IPv4 interfaces.
That can make the forwarded service reachable from other machines that can access your computer.
The important security distinction is this: local port forwarding does not automatically expose the remote service to the internet. The exposure risk comes from how the local listening socket is bound and who can reach it.
When local-only access is sufficient, prefer:
127.0.0.1over:
0.0.0.0Running the Tunnel in the Background
For a tunnel that should remain active without an interactive SSH session:
$ ssh -f -N \
-L 127.0.0.1:3306:localhost:3306 \
user@staging-server.comThe -f option tells SSH to move into the background after authentication.
You can verify the listening socket with:
$ ss -lntpOn macOS:
$ lsof -nP -iTCP:3306 -sTCP:LISTENKeeping Tunnels Alive
For long-running tunnels, SSH keepalives can help detect dead connections:
$ ssh -N \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-L 127.0.0.1:3306:localhost:3306 \
user@staging-server.comFor automatically reconnecting tunnels, tools such as autossh can be used:
$ autossh -M 0 -N \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-L 127.0.0.1:3306:localhost:3306 \
user@staging-server.comKeepalives detect broken connections; autossh adds automatic restart behavior when the SSH session fails.
Local Forwarding Through a Bastion Host
A particularly powerful use case is reaching services inside a private network through a jump server or bastion host.
Suppose:
Your Machine
│
▼
Jump Server
│
├──► Database Server
├──► Redis Server
└──► Application ServerThe internal services do not need to be directly accessible from your machine.
For example:
$ ssh -N \
-L 3306:db-server:3306 \
-L 6379:redis-server:6379 \
-L 8080:app-server:3000 \
user@jump-server.comThe important point is that db-server, redis-server, and app-server are resolved from the jump server's network environment.
This allows the SSH server to act as a controlled gateway into an otherwise private network.
Troubleshooting
When a tunnel does not behave as expected, increase SSH verbosity:
$ ssh -vvv -N -L 8080:localhost:3000 user@staging-server.comCheck whether the local forwarding port is listening:
$ ss -lntpOn macOS:
$ lsof -nP -iTCP:8080 -sTCP:LISTENYou can also test the local endpoint directly:
$ curl http://127.0.0.1:8080If the local port is listening but the connection fails, verify that the destination service is actually reachable from the SSH server.
For example, on the remote server:
$ curl http://127.0.0.1:3000This distinction is important:
Can my machine reach the service?
│
└── Not necessarily.
Can the SSH server reach the service?
│
└── This is what matters for -L.Security Considerations
SSH tunneling is a legitimate and powerful administrative tool, but it can also bypass intended network boundaries.
Use it deliberately.
Good practices
- Use SSH keys rather than passwords where appropriate.
- Restrict SSH access to authorized users.
- Bind local forwards to
127.0.0.1unless broader access is required. - Avoid forwarding sensitive services unnecessarily.
- Audit persistent tunnels.
- Protect private keys and SSH agent access.
- Use bastion hosts to provide controlled access to private networks.
- Remove tunnels when they are no longer needed.
A tunnel encrypts traffic between the SSH endpoints, but it does not make the destination service inherently secure. Once traffic emerges from the SSH tunnel, normal network and application security still apply.
Quick Reference
Basic local forward
$ ssh -N -L 8080:localhost:3000 user@serverExplicit local-only binding
$ ssh -N -L 127.0.0.1:8080:localhost:3000 user@serverDifferent local and remote ports
$ ssh -N -L 8080:localhost:3000 user@serverMultiple forwards
$ ssh -N \
-L 3306:localhost:3306 \
-L 6379:localhost:6379 \
-L 8080:localhost:3000 \
user@serverBackground tunnel
$ ssh -f -N -L 127.0.0.1:3306:localhost:3306 user@serverDebugging
$ ssh -vvv -N -L 8080:localhost:3000 user@serverThe Mental Model
The easiest way to remember local port forwarding is:
-L LOCAL_PORT:DESTINATION_HOST:DESTINATION_PORTThink of it as:
"Create a port here,
and send its traffic there,
through SSH."For example:
$ ssh -N -L 8080:localhost:3000 user@servermeans:
My localhost:8080
│
│ SSH
▼
Remote server's localhost:3000That simple model is the foundation for more advanced SSH networking.
What’s Next?
Local forwarding moves traffic from your machine toward a remote service.
In the next part, we reverse the direction and examine remote port forwarding (-R)—including how to expose a service running on your machine through a remote SSH server.
After that, we can take it one step further with dynamic port forwarding (-D) and SOCKS proxies.
As we wrap up this tutorial, I’d like to sincerely thank everyone who took part and contributed along the way. Your questions, ideas, and curiosity are what make learning truly valuable.
Learning is a collaborative journey, so keep exploring, keep asking questions, and don’t be afraid to dig deeper.
Thank you again for being part of it, and best of luck on the journey ahead!