SSH Port Forwarding — Local, Remote & Dynamic

Local forwarding, remote forwarding, dynamic proxying, and jump hosts — SSH tunnelling turns any encrypted SSH connection into a secure conduit for any TCP traffic.

-L local · -R remote · -D dynamic · -J ProxyJump · persistent tunnels
01 — How SSH Tunnelling Works

SSH port forwarding works by asking the SSH daemon at one end to open a socket, accept connections on it, and relay the data through the encrypted SSH channel to a destination socket at the other end. The application connecting to the tunnel sees a plain TCP socket — it has no idea its traffic is being encrypted and transported over SSH.

A tunnel is just a pipe. One end is a port on a machine you can reach; the other end is a port on a machine your SSH server can reach — which may be completely firewalled from your own network.
TypeWho opens the listening portTraffic flows
Local (-L)Your local machineLocal port → SSH server → destination
Remote (-R)The SSH serverRemote port → SSH client → destination
Dynamic (-D)Your local machine (SOCKS5 proxy)Local SOCKS port → SSH server → any destination
Prerequisites — sshd_config on the server
# Local and dynamic forwarding — no special server config required # (AllowTcpForwarding defaults to yes) # Remote forwarding — requires this in /etc/ssh/sshd_config: AllowTcpForwarding yes # must be yes (default) GatewayPorts no # "no" = tunnel binds to 127.0.0.1 on server only # "yes" = tunnel binds to 0.0.0.0 (accessible externally) # "clientspecified" = client chooses the bind address
02 — Local Port Forwarding (-L)

Local forwarding opens a port on your local machine. Connections to that local port are forwarded through the SSH server and on to a target host and port — which only the SSH server needs to be able to reach.

YOUR MACHINE SSH SERVER DESTINATION ┌─────────────────┐ ┌──────────────┐ ┌─────────────┐ │ │ │ │ │ │ │ app / browser │ │ │ │ target │ │ connects to │ ──SSH──► │ relays │ ──TCP──► │ service │ │ localhost:PORT │ encrypted│ traffic │ plain │ HOST:PORT │ │ │ │ │ │ │ └─────────────────┘ └──────────────┘ └─────────────┘
Syntax
ssh -L [bind_address:]local_port:destination_host:destination_port user@ssh_server
PartMeaning
bind_addressInterface on your local machine to listen on. Omit (or use localhost) to restrict to your own machine. Use 0.0.0.0 to share the tunnel with others on your LAN.
local_portThe port that opens on your local machine. You connect your app to this.
destination_hostWhere the SSH server should send the traffic. Can be the SSH server itself (localhost) or any host it can reach.
destination_portThe port on the destination host.
user@ssh_serverThe SSH server acting as the relay.
Examples
# Access a remote PostgreSQL database as if it were local # localhost:5432 → ssh_server → localhost:5432 (on the server) ssh -L 5432:localhost:5432 user@db-server.example.com # Access a database on a third host the SSH server can reach # localhost:5432 → ssh_server → 192.168.1.50:5432 ssh -L 5432:192.168.1.50:5432 user@ssh-server.example.com # Access an internal web app behind a firewall # localhost:8080 → ssh_server → internal-app.local:80 ssh -L 8080:internal-app.local:80 user@bastion.example.com # Use a non-conflicting local port (remote service is on 80, local 80 is taken) ssh -L 9080:localhost:80 user@webserver.example.com # Share the tunnel with your LAN (bind to all interfaces) ssh -L 0.0.0.0:5432:localhost:5432 user@db-server.example.com # Multiple tunnels in one SSH connection ssh -L 5432:localhost:5432 \ -L 6379:localhost:6379 \ -L 8080:internal-web:80 \ user@bastion.example.com
Background (persistent) local tunnel
# -N = don't execute a remote command (tunnel only) # -f = go to background after authentication ssh -fN -L 5432:localhost:5432 user@db-server.example.com # Find and kill a background tunnel ps aux | grep ssh kill
Then connect your app to the tunnel
# PostgreSQL client — connect to the local tunnel endpoint psql -h 127.0.0.1 -p 5432 -U myuser mydatabase # MySQL client through a tunnel mysql -h 127.0.0.1 -P 3306 -u root -p # Redis through a tunnel redis-cli -h 127.0.0.1 -p 6379 # Browser — just open http://localhost:8080
✔  Local forwarding is the go-to pattern for securely accessing remote databases, admin panels, and internal services without exposing those services to the internet.
03 — Remote Port Forwarding (-R)

Remote forwarding is the mirror image of local forwarding. The SSH server opens a port — connections to that remote port are forwarded back through the SSH channel to your local machine (or any host your local machine can reach). This is how you make a service running behind NAT reachable from the internet.

YOUR MACHINE SSH SERVER REMOTE CLIENT ┌─────────────────┐ ┌──────────────┐ ┌─────────────┐ │ │ │ │ │ │ │ local service │ ◄──SSH── │ opens port │ ◄──TCP── │ someone │ │ on HOST:PORT │ encrypted│ REMOTE_PORT │ plain │ connects │ │ │ │ │ │ │ └─────────────────┘ └──────────────┘ └─────────────┘
Syntax
ssh -R [bind_address:]remote_port:destination_host:destination_port user@ssh_server
Examples
# Expose your local web server (port 3000) on the SSH server's port 9000 # Anyone who can reach ssh_server:9000 will hit your localhost:3000 ssh -R 9000:localhost:3000 user@public-server.example.com # Expose a local development HTTPS server ssh -R 8443:localhost:443 user@public-server.example.com # Reverse SSH tunnel — expose your machine's SSH on the server's port 2222 # Useful for reaching a host behind NAT or a strict firewall ssh -R 2222:localhost:22 user@public-server.example.com # From the public server, reach your machine via the reverse tunnel: # ssh -p 2222 localuser@localhost (run this on public-server) # Expose a service on a third host your machine can reach # public-server:9000 → your machine → 192.168.1.20:80 ssh -R 9000:192.168.1.20:80 user@public-server.example.com
Bind to all interfaces on the server (requires GatewayPorts)
# By default, the remote port binds to 127.0.0.1 on the SSH server # — only processes on that server can connect to it. # To allow external connections to the remote port: # 1. Set in /etc/ssh/sshd_config on the server: GatewayPorts yes # or: clientspecified # 2. Specify the bind address explicitly in the -R flag: ssh -R 0.0.0.0:9000:localhost:3000 user@public-server.example.com # or with clientspecified: ssh -R *:9000:localhost:3000 user@public-server.example.com
Persistent reverse tunnel (background)
# Background tunnel — reconnects are handled separately (see section 08) ssh -fN -R 2222:localhost:22 user@public-server.example.com # Verify the tunnel opened on the server ssh user@public-server.example.com "ss -tlnp | grep 2222"
⚠  With GatewayPorts yes, the remote port is publicly accessible on the SSH server — anyone can connect to it. Use firewall rules on the server to restrict access to the forwarded port if needed.
04 — Dynamic Port Forwarding (-D)

Dynamic forwarding turns the SSH connection into a full SOCKS5 proxy. Instead of forwarding a single port to a single destination, the SSH server becomes a proxy — any application that speaks SOCKS5 can route its traffic through the tunnel to any destination the SSH server can reach.

YOUR MACHINE SSH SERVER INTERNET ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ │ │ │ │ SOCKS5-aware │ │ │ │ any host │ │ app connects to │ ──SSH──► │ proxies to │ ──TCP──► │ any port │ │ localhost:1080 │ encrypted│ destination │ plain │ │ │ │ │ │ │ │ └──────────────────┘ └──────────────┘ └──────────────┘
Syntax
ssh -D [bind_address:]local_port user@ssh_server
Examples
# Open a SOCKS5 proxy on localhost:1080 ssh -D 1080 user@remote-server.example.com # Background SOCKS proxy ssh -fN -D 1080 user@remote-server.example.com # Share the proxy on your LAN ssh -fN -D 0.0.0.0:1080 user@remote-server.example.com # Different local port to avoid conflicts ssh -fN -D 8888 user@remote-server.example.com
Using the SOCKS5 proxy
# curl — route a single request through the proxy curl --socks5-hostname localhost:1080 https://example.com # curl — check what IP the server sees curl --socks5-hostname localhost:1080 https://ifconfig.me # wget wget -e "use_proxy=yes" -e "http_proxy=socks5h://localhost:1080" https://example.com # Firefox — Settings → Network → Manual proxy → SOCKS Host: 127.0.0.1 Port: 1080 # Chrome via command line google-chrome --proxy-server="socks5://127.0.0.1:1080" # Any app with SOCKS5 support — point it to 127.0.0.1:1080
Route ALL system traffic through the proxy (proxychains)
# Install proxychains sudo apt install proxychains4 # /etc/proxychains4.conf — add at the bottom: socks5 127.0.0.1 1080 # Prefix any command to route it through the tunnel proxychains4 curl https://ifconfig.me proxychains4 ssh user@internal-host proxychains4 nmap -sT -Pn 192.168.1.0/24
05 — ProxyJump / Bastion Hosts (-J)

A jump host (bastion) is an intermediary SSH server you must pass through to reach an internal host. ProxyJump chains SSH connections transparently — your SSH client connects to the bastion, then uses that connection as a relay to the final target, all in one command.

YOUR MACHINE BASTION HOST INTERNAL HOST ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ │ │ ssh │─SSH──►│ relays │─SSH──►│ final │ │ client │ │ connection│ │ target │ │ │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘
Single jump
# Reach internal-server via bastion in one command ssh -J user@bastion.example.com user@192.168.1.50 # Different usernames on each hop ssh -J bastion-user@bastion.example.com internal-user@192.168.1.50 # Custom port on the bastion ssh -J user@bastion.example.com:2222 user@192.168.1.50
Multiple jumps (chain)
# Chain two bastion hosts to reach the final target ssh -J user@bastion1.example.com,user@bastion2.internal user@final-host.internal
~/.ssh/config — cleaner multi-hop setup
# ~/.ssh/config Host bastion HostName bastion.example.com User admin IdentityFile ~/.ssh/id_ed25519_bastion Host db-server HostName 192.168.1.50 User dbadmin ProxyJump bastion IdentityFile ~/.ssh/id_ed25519_internal Host app-server HostName 192.168.1.60 User deploy ProxyJump bastion # Two-hop: your machine → bastion → internal-jump → final-host Host deep-internal HostName 10.10.10.20 User admin ProxyJump bastion,app-server
# Now connecting is just: ssh db-server ssh deep-internal
Port forwarding through a jump host
# Local forward through a bastion to an internal database ssh -J user@bastion.example.com \ -L 5432:192.168.1.50:5432 \ user@192.168.1.50 # Or using ~/.ssh/config — if db-server has ProxyJump set: ssh -L 5432:localhost:5432 db-server
06 — ~/.ssh/config — Persistent Tunnel Definitions

Defining tunnels in ~/.ssh/config eliminates long command lines and makes tunnels repeatable. The LocalForward and RemoteForward directives mirror the -L and -R flags exactly.

~/.ssh/config directives for forwarding
# Local forward — access remote PostgreSQL locally Host db-tunnel HostName db-server.example.com User admin IdentityFile ~/.ssh/id_ed25519 LocalForward 5432 localhost:5432 # local:5432 → server:5432 LocalForward 6379 localhost:6379 # local:6379 → server:6379 (Redis) ServerAliveInterval 60 ServerAliveCountMax 3 # Remote forward — expose local dev server on a public host Host expose-dev HostName public-server.example.com User admin IdentityFile ~/.ssh/id_ed25519 RemoteForward 9000 localhost:3000 # server:9000 → local:3000 ExitOnForwardFailure yes # Dynamic SOCKS proxy Host socks-proxy HostName proxy-server.example.com User admin DynamicForward 1080 RequestTTY no
# Connect and establish all defined tunnels: ssh db-tunnel # opens tunnels on 5432 and 6379 automatically ssh -fN db-tunnel # background, no shell # -fN is the cleanest way to establish a config-defined tunnel in the background
Useful connection options
OptionMeaning
ServerAliveInterval 60Send a keepalive every 60 seconds — prevents idle tunnels from being dropped by firewalls or NAT.
ServerAliveCountMax 3Close the connection after 3 missed keepalives.
ExitOnForwardFailure yesAbort the SSH connection if a requested tunnel port cannot be bound. Prevents silent tunnel failures.
RequestTTY noDon't allocate a terminal — useful for tunnel-only connections.
Compression yesEnable compression — helps on slow links for text-heavy traffic.
ControlMaster autoMultiplex tunnels over an existing SSH connection to the same host.
ControlPath ~/.ssh/cm-%r@%h:%pSocket path for connection multiplexing.
ControlPersist 10mKeep the master connection alive 10 minutes after the last session closes.
07 — Connection Multiplexing

SSH multiplexing reuses an existing connection for new sessions and tunnels — eliminating the authentication round-trip and the overhead of a second TCP handshake to the same server.

~/.ssh/config — enable multiplexing
Host * ControlMaster auto ControlPath ~/.ssh/cm-%r@%h:%p ControlPersist 10m
# First connection: normal SSH, creates the master socket ssh user@server.example.com # Second connection: instant — reuses the existing socket, no re-auth ssh user@server.example.com # Open a tunnel over an existing connection — no extra auth round-trip ssh -fN -L 5432:localhost:5432 user@server.example.com # Check if a master connection is active ssh -O check user@server.example.com # Stop the master connection gracefully ssh -O stop user@server.example.com
08 — Persistent & Auto-Reconnecting Tunnels

SSH tunnels drop when the network hiccups, the server reboots, or an idle timeout fires. For long-lived tunnels — especially reverse tunnels for remote access — you need automatic reconnection.

Option A — AutoSSH
# Install autossh sudo apt install autossh # Debian / Ubuntu sudo dnf install autossh # RHEL / Fedora # AutoSSH monitors the tunnel and restarts it on failure # AUTOSSH_GATETIME=0 prevents autossh from giving up on first failure AUTOSSH_GATETIME=0 autossh -M 0 -fN \ -o "ServerAliveInterval 30" \ -o "ServerAliveCountMax 3" \ -L 5432:localhost:5432 \ user@db-server.example.com # Reverse tunnel that auto-reconnects AUTOSSH_GATETIME=0 autossh -M 0 -fN \ -o "ServerAliveInterval 30" \ -o "ServerAliveCountMax 3" \ -R 2222:localhost:22 \ user@public-server.example.com
Option B — systemd service (recommended for servers)
# /etc/systemd/system/ssh-tunnel-db.service [Unit] Description=SSH local tunnel to database server After=network.target Wants=network-online.target [Service] User=tunnel Environment="AUTOSSH_GATETIME=0" ExecStart=/usr/bin/autossh -M 0 -N \ -o "ServerAliveInterval=30" \ -o "ServerAliveCountMax=3" \ -o "ExitOnForwardFailure=yes" \ -o "StrictHostKeyChecking=accept-new" \ -i /home/tunnel/.ssh/id_ed25519 \ -L 5432:localhost:5432 \ tunnel@db-server.example.com Restart=always RestartSec=10 [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable --now ssh-tunnel-db sudo systemctl status ssh-tunnel-db
Option C — systemd service for a reverse tunnel
# /etc/systemd/system/ssh-reverse-tunnel.service [Unit] Description=Reverse SSH tunnel to public server After=network-online.target Wants=network-online.target [Service] User=tunnel Environment="AUTOSSH_GATETIME=0" ExecStart=/usr/bin/autossh -M 0 -N \ -o "ServerAliveInterval=30" \ -o "ServerAliveCountMax=3" \ -o "ExitOnForwardFailure=yes" \ -o "StrictHostKeyChecking=accept-new" \ -i /home/tunnel/.ssh/id_ed25519 \ -R 2222:localhost:22 \ tunnel@public-server.example.com Restart=always RestartSec=15 [Install] WantedBy=multi-user.target
ℹ  Create a dedicated system user (tunnel) with its own key pair and no shell (nologin) for running persistent tunnel services. Never run them as root or your personal user account.
09 — Real-World Scenarios
Scenario A — access a remote database securely
# Problem: PostgreSQL on db-server is not publicly exposed. # You need to query it from your laptop. # Solution: local tunnel ssh -fN -L 5432:localhost:5432 admin@db-server.example.com # Now connect your local psql / pgAdmin / DBeaver to: # Host: 127.0.0.1 Port: 5432 psql -h 127.0.0.1 -U appuser -d mydb
Scenario B — share a local dev server with a colleague
# Problem: your dev server runs on localhost:3000 behind NAT. # A colleague needs to review your work from their machine. # Solution: remote tunnel to a shared public server ssh -fN -R 9000:localhost:3000 admin@shared-server.example.com # Tell your colleague: http://shared-server.example.com:9000 # (requires GatewayPorts yes on shared-server)
Scenario C — access an internal admin panel via bastion
# Problem: Grafana at internal-monitoring:3000 is only reachable # from inside the company network, behind a bastion host. # Solution: local tunnel through a jump host ssh -J admin@bastion.company.com \ -fN -L 3000:internal-monitoring.local:3000 \ admin@internal-monitoring.local # Open http://localhost:3000 in your browser
Scenario D — reach a host stuck behind a double NAT
# Problem: a Raspberry Pi or home server is behind two layers of NAT # (ISP + home router). You can't port-forward at either level. # Solution: persistent reverse tunnel from the Pi to a VPS # Run on the Pi: autossh -M 0 -fN \ -o "ServerAliveInterval=30" \ -R 2222:localhost:22 \ pi@your-vps.example.com # SSH into your Pi from anywhere via the VPS: ssh your-vps.example.com # then on the VPS: ssh -p 2222 pi@localhost
Scenario E — encrypted tunnel for a non-TLS service
# Problem: an old internal service talks plain TCP, no encryption. # You need to reach it over the internet securely. # Solution: wrap it in an SSH tunnel ssh -fN -L 9999:legacy-service.internal:9999 admin@bastion.example.com # Connect your app to localhost:9999 — traffic is encrypted over SSH
10 — Security Considerations

SSH tunnels are powerful — but that power cuts both ways. A tunnel that forwards a port into an internal network is an attack surface if not controlled carefully.

RiskMitigation
Tunnel left open indefinitelySet ClientAliveCountMax and ClientAliveInterval on the server to reap idle connections.
Remote port publicly exposedDefault GatewayPorts no binds remote tunnels to 127.0.0.1. Only change if you need external access, then add a firewall rule.
Unauthorised users opening tunnelsSet AllowTcpForwarding no globally in sshd_config and re-enable per user with a Match User block.
SOCKS proxy abused as open proxyBind dynamic forward to 127.0.0.1 only (default). Never bind to 0.0.0.0 on a shared machine.
Tunnel bypasses firewall controlsAudit outbound SSH rules. Legitimate tunnels should use dedicated keys and dedicated system users.
Private key compromiseUse per-tunnel key pairs with no passphrase only for automated service accounts. Rotate keys regularly.
Restrict forwarding per user in sshd_config
# Disable forwarding globally AllowTcpForwarding no GatewayPorts no # Re-enable only for specific users or groups Match User tunnel-user AllowTcpForwarding yes GatewayPorts clientspecified Match User sftp-only ForceCommand internal-sftp AllowTcpForwarding no X11Forwarding no
11 — Troubleshooting
SymptomCause & Fix
bind: Address already in useAnother process is on that local port. Pick a different local_port or kill the occupying process: ss -tlnp | grep PORT.
channel 3: open failed: connect failedThe SSH server cannot reach the destination host/port. Verify the destination is up and reachable from the server side.
Remote port not accessible externallyGatewayPorts is no on the server — remote tunnels bind to 127.0.0.1 only by default. Set GatewayPorts yes and bind to 0.0.0.0.
Tunnel drops after a few minutes idleFirewall or NAT is killing idle connections. Add ServerAliveInterval 60 to ~/.ssh/config.
Permission denied opening tunnelAllowTcpForwarding no on the server. Check sshd_config.
-fN: command not found or hangsAuthentication is failing (passphrase prompt with -f). Add the key to ssh-agent first: ssh-add ~/.ssh/id_ed25519.
autossh exits immediatelySet AUTOSSH_GATETIME=0 — without it, autossh gives up if the first connection attempt fails.
ProxyJump connection refusedThe bastion cannot reach the final target. Test from the bastion directly: ssh user@internal-host.
SOCKS proxy connects but traffic doesn't flowApplication is not doing DNS through SOCKS — use socks5h (not socks5) in curl/apps to resolve DNS on the server side.
Diagnostic commands
# See verbose tunnel negotiation ssh -vvv -L 5432:localhost:5432 user@server # Check what ports are listening locally ss -tlnp | grep ssh # Check the remote tunnel port is open on the server ssh user@server "ss -tlnp | grep 2222" # Verify traffic is flowing through the tunnel # (run on local after opening the tunnel) curl -v http://localhost:8080 # Test SOCKS proxy curl --socks5-hostname localhost:1080 https://ifconfig.me # Show all active SSH tunnel processes ps aux | grep "ssh.*-[LRD]"
12 — Quick Reference
CommandWhat it does
ssh -L 5432:localhost:5432 user@hostLocal tunnel — access remote PostgreSQL on localhost:5432
ssh -L 8080:internal:80 user@bastionLocal tunnel — reach an internal web app via bastion
ssh -fN -L 5432:localhost:5432 user@hostSame, but run in background (tunnel only, no shell)
ssh -R 9000:localhost:3000 user@hostRemote tunnel — expose local:3000 as server:9000
ssh -R 2222:localhost:22 user@hostReverse SSH — reach this machine via server port 2222
ssh -fN -R 2222:localhost:22 user@hostSame, background
ssh -D 1080 user@hostSOCKS5 proxy on localhost:1080
ssh -fN -D 1080 user@hostSame, background
ssh -J bastion user@internalJump through a bastion host
ssh -J b1,b2 user@finalChain two bastion hops
ssh -L 5432:db:5432 -J bastion user@dbLocal tunnel through a jump host
curl --socks5-hostname localhost:1080 URLRoute a curl request through SOCKS5 proxy
autossh -M 0 -fN -L ... user@hostAuto-reconnecting local tunnel
ssh -O check user@hostCheck if a multiplexed master connection is active
✔  The single most useful habit: put all your tunnel definitions in ~/.ssh/config with LocalForward, RemoteForward, ProxyJump, and ServerAliveInterval. Then opening any tunnel is just ssh -fN tunnel-name.