#DevOps

SSH Tunneling and Networking Fundamentals

SSH is more than remote login. Its tunneling and port forwarding provide a developer's Swiss Army knife for reaching private services, creating secure proxies, and traversing bastion hosts. This article covers local, remote, and dynamic forwarding, ProxyJump, SSH key management, and ssh_config.

Key Points

  • Topic Focus: DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Most developers think of SSH as little more than ssh user@host. That is like buying a Swiss Army knife and using it only as a bottle opener. SSH's real power lies in tunneling (port forwarding): a single command can traverse firewalls, encrypt arbitrary TCP traffic, bring private services to your local machine, and expose local services to the outside world in reverse.

These capabilities are practically essential for developers in the cloud-native era. You work with VPCs, bastion hosts, private databases, and container networks every day. Without SSH tunneling, you are left wrestling with a collection of temporary tools and VPNs. This article explains port forwarding, bastion hosts, key management, and ssh_config in one place, so your first thought when facing a network access problem will be, "SSH can handle this."

1. SSH Is More Than Login: Port Forwarding Is an Essential Developer Skill

Start with one core mental model: an SSH tunnel carries other TCP traffic inside an encrypted SSH connection. You establish an SSH channel between your local machine and a remote host, then tell it to forward traffic from a particular port to the other end. All data travels through the encrypted SSH channel and is opaque to the intermediate network.

There are three types of port forwarding. This distinction is all you need to remember:

  • Local forwarding -L: brings a remote port to your local machine (local→remote). Use it to access private remote services.
  • Remote forwarding -R: brings a local port to the remote host (local→exposed remotely, in reverse). Use it for NAT traversal.
  • Dynamic forwarding -D: creates a SOCKS proxy that can forward traffic to arbitrary destinations. Use it as a general-purpose proxy.

One common pitfall: whether forwarding works depends on the target service's listening address on the remote machine. PG listens only on 127.0.0.1 by default, so the remote side must connect to its local port 5432. A service listening on 0.0.0.0 listens on every interface. Run ss -tlnp first to see what is actually listening instead of immediately blaming SSH.

One more point: starting with OpenSSH 7.6 (2017), -L/-R bind only to localhost by default, not 0.0.0.0. To access the forwarded port from another machine, you must specify a bind address explicitly, such as -L 0.0.0.0:5432:..., or set GatewayPorts yes in ssh_config. This is the root cause of 90% of "why can't anyone else connect to my forwarded port?" problems.

2. Local Port Forwarding (-L): Map a Remote Service Port to Your Local Machine

This is the most common form. A typical scenario is that a database sits inside a private VPC and your laptop cannot reach it directly, but you can SSH to a jump box.

bash
ssh -N -L 15432:db.internal:5432 jumpbox

Breaking down the arguments:

  • -N: do not execute a remote command; perform forwarding only. Otherwise, SSH opens a shell, and closing the terminal terminates the connection.
  • -L [bind_address:]local_port:remote_target:remote_port
  • 15432:db.internal:5432: local port 15432 → through jumpbox → db.internal:5432.

After running the command, using psql -h 127.0.0.1 -p 15432 locally is equivalent to accessing db.internal:5432 from jumpbox. Note that the "remote target" is resolved from jumpbox's perspective, not from your local machine. Therefore, db.internal must be a name that jumpbox can resolve through DNS.

The bind-address pitfall is worth repeating: -L 15432:... binds only to 127.0.0.1, so only your machine can connect. Using -L 0.0.0.0:15432:... allows other machines on the LAN to connect through your machine. Combining it with GatewayPorts yes makes this easier to manage.

Practical advice: do not use the service's default port for forwarding. Use recognizable ports such as 15432, 13306, or 16379. The extra leading digit makes forwarded ports obvious and avoids conflicts with locally installed PG, MySQL, or Redis instances. Add -f to fork into the background. The -fN combination is the standard pattern for persistent forwarding on a development machine.

bash
ssh -fNT -L 15432:db.internal:5432 jumpbox
# Then use ss -tlnp | grep 15432 to confirm that it is listening
# When finished: use ps aux | grep 'ssh -fNT' to find the PID, then kill it

3. Remote Port Forwarding (-R): Reverse Forwarding, the Original NAT-Traversal Tool

-L brings a remote service to you. -R does the opposite: it exposes a local service through a remote machine.

The classic scenario is that you have a local web service running on localhost:3000 and want a customer, colleague, or WeChat test account to preview it. However, you are at home or inside a corporate network, so it cannot be reached externally. All you need is a public VPS:

bash
ssh -N -R 8080:localhost:3000 vps.example.com

This opens port 8080 on the VPS and forwards its traffic to local port 3000. Anyone visiting http://vps.example.com:8080 reaches your local service. This is NAT traversal, something SSH has always been able to do even though tools such as frp and ngrok are better known for it.

The key pitfall is that -R 8080:... binds only to 127.0.0.1 on the VPS by default, so external users still cannot connect. Set the following in /etc/ssh/sshd_config on the VPS:

text
GatewayPorts yes

Then restart sshd with systemctl restart sshd and use -R 0.0.0.0:8080:localhost:3000 vps so the port actually listens publicly. Security warning: always add authentication when exposing a service publicly, or allow only specific IP addresses. Never expose an unauthenticated administrative interface on 0.0.0.0.

-R also supports a more advanced pattern: reverse SSH access into a private network. A target machine behind NAT initiates an outbound connection to your public server:

bash
# Run on the machine inside the private network and start it at boot
ssh -fNR 2222:localhost:22 vps.example.com
# Later, run ssh -p 2222 localhost on the VPS to enter the private machine

This is commonly used for IoT devices, home servers, and remote access to a corporate network. Reliability can be an issue because NAT devices may terminate long-lived SSH connections. In production, use autossh for automatic reconnection:

bash
autossh -M 0 -fN -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" \
  -R 2222:localhost:22 vps.example.com

-M 0 disables the older monitoring port and uses ServerAliveInterval for heartbeats instead. This is the modern way to use autossh.

4. Dynamic Port Forwarding (-D): A General-Purpose SOCKS Proxy

The previous two methods map one port to another. -D gives you a SOCKS5 proxy that can forward traffic to any destination.

bash
ssh -D 1080 -N vps.example.com

After running the command, local port 1080 becomes a SOCKS5 proxy, and all traffic sent through it exits from the VPS. Configure SwitchyOmega in your browser to use 127.0.0.1:1080, or use the command line:

bash
curl --socks5 127.0.0.1:1080 https://ifconfig.me
# You will see the VPS's public IP

Use cases include accessing a collection of internal corporate domains without public DNS, temporarily routing a tool through a particular egress IP, and debugging geo-restricted services. It is much lighter than running a VPN and can be started or stopped whenever needed.

An advanced technique is to turn the SOCKS proxy into a system-wide transparent proxy:

bash
# proxychains forces any command to use SOCKS
echo "socks5 127.0.0.1 1080" >> ~/.proxychains/proxychains.conf
proxychains curl https://internal.company.local/api
proxychains npm install   # Corporate internal npm registry

Note that -D also binds only to 127.0.0.1 by default. To share it with colleagues, bind it to 0.0.0.0 with -D 0.0.0.0:1080. However, SOCKS has no authentication by default, so share it carefully. Alternatively, use -D with SSH's built-in authentication. Because SOCKS traffic runs through the SSH connection itself, the authentication is effectively your SSH key.

5. Bastion Hosts: Traverse a Jump Host with One ProxyJump (-J) Command

The old approach was nested SSH: ssh -t jump 'ssh internal'. It is awkward, SCP does not support it, and rsync requires extra work. Starting with OpenSSH 7.3+ (2016), the official -J / ProxyJump option handles this in one line:

bash
ssh -J jump.internal target.internal

This first connects to jump.internal, then connects through it to target.internal. SCP, rsync, and port forwarding all work normally:

bash
# Transfer a file through the jump host
scp -J jump.internal file.tar target:/tmp/
# Create local forwarding through the jump host to access PG on target
ssh -J jump.internal -L 15432:localhost:5432 target.internal

Multiple jump hosts are also supported, separated by commas:

bash
ssh -J jump1,jump2,internal.target target

Each hop is parsed in user@host:port format, such as -J admin@jump:2222,root@jump2. Combined with the ssh_config shown below, everyday usage becomes as simple as ssh target. Stop nesting connections with -t; that approach belongs to an earlier era.

6. SSH Keys: Generation, Upload, ssh-agent, and Passphrases

Keys are central to SSH. Follow three hard rules:

  1. Use ed25519: it is fast, compact, and secure. Avoid RSA unless compatibility with an old system requires it.
  2. Always set a passphrase: it encrypts the key file at rest, so losing or leaking the file does not immediately compromise everything.
  3. Separate keys: use different keys for different environments, such as one for work, one for personal systems, and one for production. This allows precise revocation after an incident.

Generate a key:

bash
ssh-keygen -t ed25519 -C "work@codenodex-2026" -f ~/.ssh/id_ed25519_work
# Before accepting all defaults, make sure you enter a passphrase

Upload the public key to the target machine by appending the contents of ~/.ssh/id_ed25519_work.pub to ~/.ssh/authorized_keys on that machine:

bash
ssh-copy-id -i ~/.ssh/id_ed25519_work.pub user@host
# If an older system does not support it, use cat:
cat ~/.ssh/id_ed25519_work.pub | ssh user@host 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'

authorized_keys must have permission 600, and the .ssh directory must have permission 700. SSH silently rejects keys when these permissions are wrong, with "Authentication refused: bad ownership" in the logs. This is the first thing to check when an SSH key does not work.

ssh-agent caches passphrases so you do not have to enter them every time:

bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_work
# This key can now be used without re-entering its passphrase in the current session

On macOS, ssh-add --apple-use-keychain ~/.ssh/... stores the passphrase in Keychain so it survives a restart. On Linux, the setup depends on the distribution; typically, AddKeysToAgent yes in ssh_config adds keys automatically. In production, managing ssh-agent with a systemd user service is a clean approach.

List the keys currently loaded in the agent:

bash
ssh-add -l

7. ssh_config: Put Frequently Used Host Settings in a File

Remembering IP addresses, ports, keys, and jump hosts is not a job for human memory. ~/.ssh/config is the answer:

sshconfig
Host bastion
    HostName bastion.prod.codenodex.cn
    User deploy
    Port 2222
    IdentityFile ~/.ssh/id_ed25519_work
    ForwardAgent yes

Host db-prod
    HostName 10.0.1.20
    User postgres
    ProxyJump bastion
    LocalForward 15432 127.0.0.1:5432
    ServerAliveInterval 30

Host *.internal
    User ubuntu
    ProxyJump bastion
    IdentityFile ~/.ssh/id_ed25519_work
    StrictHostKeyChecking accept-new

# Global defaults
Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    AddKeysToAgent yes
    IdentitiesOnly yes

The result:

bash
ssh bastion           # = ssh -p 2222 -i ~/.ssh/... deploy@bastion...
ssh db-prod           # Automatically uses the bastion jump host and forwards remote 5432 to local 15432
ssh web1.internal     # Matches the wildcard, uses the jump host automatically, and selects the correct key

Once LocalForward is in the config, forwarding starts automatically on every connection. This is how smooth daily use should be. ServerAliveInterval 30 addresses connections being terminated by NAT devices. IdentitiesOnly yes forces SSH to use only the specified key, preventing a collection of keys in the agent from triggering the server's MaxAuthTries limit, which disconnects after six failed attempts by default.

Configuration-file hierarchy: ~/.ssh/config is user-level, while /etc/ssh/ssh_config is system-level. Both are loaded, with the former taking precedence. A team can maintain an /etc/ssh/ssh_config template and distribute it consistently through Ansible or SaltStack.

8. Practical Scenarios

Scenario 1: Connect to a Remote PostgreSQL Instance Locally

This is the classic case. PG is inside a private network, and you can only SSH to a bastion host:

bash
# Temporary
ssh -fNT -L 15432:db.internal:5432 bastion
psql -h 127.0.0.1 -p 15432 -U postgres

# For long-term use, put it in config as shown in the db-prod example above, then run:
ssh -fN db-prod
psql -h 127.0.0.1 -p 15432

In DataGrip or DBeaver, set the host to 127.0.0.1 and the port to 15432, then connect directly. GUI tools also support built-in SSH tunnels, but an OS-level tunnel is more controllable and easier to debug because ss -tlnp immediately shows what is listening.

Scenario 2: Access Multiple Private Machines Through a Bastion Host

bash
ssh -J bastion web1.internal
ssh -J bastion web2.internal
# Or rely directly on the ssh_config wildcard:
ssh web1.internal   # Automatically uses ProxyJump bastion

Debug ports through the jump host as well:

bash
# Access the service on web1's private port 8080 locally
ssh -L 18080:localhost:8080 web1.internal   # Uses ProxyJump from the config
curl http://127.0.0.1:18080/health

Scenario 3: Temporarily Expose a Local Service

Demonstrate a locally running Next.js application on localhost:3000 to a customer:

bash
ssh -R 0.0.0.0:8080:localhost:3000 vps.example.com
# Make sure GatewayPorts yes is set on the VPS, then share this link:
# http://vps.example.com:8080

Need HTTPS? Use Caddy on the VPS to reverse-proxy to port 8080 with one line and obtain a Let's Encrypt certificate automatically. It is free, controllable, and has no traffic limit, unlike ngrok.

Scenario 4: Route Everything Through the Corporate Egress to Access Internal Domains

The company has many internal *.corp.local domains that your local DNS cannot resolve:

bash
ssh -D 1080 -fN bastion
proxychains curl https://wiki.corp.local
proxychains git clone [email protected]:team/repo.git

Summary

  • The essence of the three forwarding modes: -L is local→remote for accessing private services, -R exposes a local service in reverse for NAT traversal, and -D provides a SOCKS proxy for general-purpose routing. Remember the directions in one diagram.
  • GatewayPorts and bind addresses cause 90% of forwarding failures. SSH binds only to 127.0.0.1 by default. Cross-machine access requires an explicit 0.0.0.0 bind or GatewayPorts yes.
  • ProxyJump replaces all nested SSH connections. It supports scp, rsync, and forwarding, so stop using ssh -t 'ssh ...'.
  • Use ed25519 keys with passphrases and ssh-agent. Isolate keys by environment. Checking permission 600 on authorized_keys and 700 on .ssh is the first troubleshooting step.
  • ssh_config turns complex parameters into aliases. Configure LocalForward, ProxyJump, and ServerAliveInterval once and benefit from them indefinitely.

Once you learn SSH tunneling, you will find that half of the "network access problems" in the cloud-native era do not require another tool. One line of ssh -fNT -L is ten times faster than wrestling with a VPN, changing routing tables, or installing proxy software. The goal is not to have more tools, but to master the ones you have. SSH is the most useful Swiss Army knife in a developer's networking toolbox, so do not leave it gathering dust.

Related

Related Content