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
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.
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_port15432: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.
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:
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:
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:
# 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:
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.
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:
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:
# 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:
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:
# 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:
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:
- Use ed25519: it is fast, compact, and secure. Avoid RSA unless compatibility with an old system requires it.
- Always set a passphrase: it encrypts the key file at rest, so losing or leaking the file does not immediately compromise everything.
- 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:
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:
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:
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:
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:
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:
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:
# 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
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:
# 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:
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:
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:
-Lis local→remote for accessing private services,-Rexposes a local service in reverse for NAT traversal, and-Dprovides a SOCKS proxy for general-purpose routing. Remember the directions in one diagram. GatewayPortsand bind addresses cause 90% of forwarding failures. SSH binds only to127.0.0.1by default. Cross-machine access requires an explicit0.0.0.0bind orGatewayPorts 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_keysand 700 on.sshis the first troubleshooting step. - ssh_config turns complex parameters into aliases. Configure
LocalForward,ProxyJump, andServerAliveIntervalonce 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
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.