Skip to content
shellmap

Forward a local port over SSH

Tunnel TCP traffic from a port on your local machine to a service reachable from the remote SSH host — bypass NAT, expose an internal database to your laptop, route browser traffic through a jump host, etc. The canonical "ssh -L 5432:localhost:5432 bastion" pattern used to reach private services without VPNing.

How to forward a local port over ssh in each shell

Bashunix
ssh -L 5432:db.internal:5432 user@bastion

Canonical local-forward: `-L <local-port>:<remote-host>:<remote-port> <ssh-target>`. Reads as "bind localhost:5432 on MY machine; forward connections through `bastion`; on the far side, connect to `db.internal:5432`". `db.internal` is resolved FROM THE BASTION, not from your laptop — that's the whole point. To run in background WITHOUT a remote shell: `-N -f` (`-N` = no remote command, `-f` = background after auth). To bind to all local interfaces (LAN-accessible): `-L 0.0.0.0:5432:db.internal:5432` (default binds 127.0.0.1 only — for security). Add `-o ServerAliveInterval=30 -o ServerAliveCountMax=3` so the tunnel survives lossy networks. Variants: `-R 8080:localhost:3000 host` (REVERSE — bind 8080 on the REMOTE, forward back to MY localhost:3000; useful for exposing a local dev server through a public bastion); `-D 1080 host` (DYNAMIC SOCKS5 proxy on local port 1080 — point a browser at it and ALL traffic routes through the remote).

Zshunix
ssh -L 5432:db.internal:5432 user@bastion
Fishunix
ssh -L 5432:db.internal:5432 user@bastion

No fish-side wrinkle — `ssh -L` parses arguments the same way regardless of shell. CAVEAT: if you background the tunnel with `-N -f`, fish does NOT print a job ID (unlike bash). To track + later kill it: `ssh -N -f -L 5432:db.internal:5432 user@bastion; and pgrep -f "ssh -N -f -L 5432"` — fish-idiomatic. Or use `ssh -fN -L 5432:db.internal:5432 -M -S ~/.ssh/cm-%h-%p-%r user@bastion` then close with `ssh -S ~/.ssh/cm-%h-%p-%r -O exit user@bastion` (control-master socket).

PowerShellwindows
ssh -L 5432:db.internal:5432 user@bastion

pwsh ships OpenSSH — same `-L`/`-R`/`-D` flags. To background on Windows, the Unix `-f` flag works but feels alien — pwsh-native: `Start-Process ssh -ArgumentList "-L 5432:db.internal:5432 -N user@bastion" -WindowStyle Hidden` returns a Process object you can kill later. WinRM-native alternative for Windows-on-Windows: `New-PSSession -ComputerName remote -UseSSL` (no port-forward semantics — that's ssh-specific; PSRemoting tunnels CMDLET INVOCATION, not arbitrary TCP). For Windows clients reaching Linux services, ssh -L is the right answer. Useful pwsh wrapper: define a function in `$PROFILE` — `function Tunnel-DB { ssh -N -L 5432:db.internal:5432 prod-bastion }` then `Tunnel-DB &` (the `&` in pwsh 7+ runs as a job).

cmd.exewindows
ssh -L 5432:db.internal:5432 user@bastion

Same OpenSSH binary as pwsh. cmd has no native background — `start /b ssh -N -L 5432:db.internal:5432 user@bastion` starts the tunnel in a background-attached process; use `taskkill /F /IM ssh.exe` to stop (kills ALL ssh.exe — not selective). Legacy PuTTY: `plink.exe -L 5432:db.internal:5432 user@bastion -N -i C:\keys\id.ppk -batch`; for "tunnel-only no-shell" PuTTY also has a GUI checkbox under Connection → SSH → Tunnels. CRITICAL for cmd users: Windows firewall may prompt the first time ssh.exe binds a listening port; auto-allow if it's your trusted dev machine.

Equivalents listed for Bash, Zsh, Fish, PowerShell, cmd.exe.

Gotchas & notes

  • **`-L` (local) vs `-R` (remote) vs `-D` (dynamic) — three forwarding modes, three different use cases.** `-L <local>:<host>:<remote-port>` (local-forward): you connect to YOUR localhost:<local>; ssh tunnels to <host>:<remote-port> as seen from the SSH server. Use to REACH services on or near the bastion (databases, internal HTTP, etc.). `-R <remote>:<host>:<port>` (remote-forward): the BASTION binds <remote>; ssh tunnels back to <host>:<port> as seen from YOUR machine. Use to EXPOSE local services to the bastion side (e.g., share localhost:3000 dev server with a colleague who can ssh to the bastion). For -R to accept non-local connections on the remote, the remote `sshd_config` must set `GatewayPorts yes` (default `no`). `-D <local-port>` (dynamic SOCKS5): your localhost:<local-port> becomes a SOCKS5 proxy; any app pointed at it routes all TCP through the SSH connection. Use for "browse as if I were on the remote network" or `curl --socks5 localhost:1080 https://internal-only.example.com`.
  • **Background + persistence: `-N -f` vs autossh vs systemd.** For a quick interactive tunnel: `ssh -L 5432:db:5432 bastion` and just leave the terminal open. To run in background without occupying a terminal: `ssh -N -f -L 5432:db:5432 bastion` (`-N` no remote command, `-f` background after auth) — survives terminal close but DIES if the SSH connection drops. For survive-anything resilience: `autossh -M 0 -N -f -L 5432:db:5432 -o ServerAliveInterval=30 -o ServerAliveCountMax=3 bastion` — autossh monitors and re-establishes on drop (`-M 0` disables autossh's own monitoring port, relying on ServerAlive keepalives). For boot-time tunnels on Linux: create a systemd `--user` unit: `[Service]\nExecStart=/usr/bin/autossh -M 0 -N -L 5432:db:5432 -o ServerAliveInterval=30 prod-bastion`/`Restart=always` — `systemctl --user enable --now tunnel-db.service`. Windows: create a Scheduled Task that runs `ssh -N -L …` at logon with "Restart on failure".
  • **ControlMaster: speed up repeated connections via socket reuse.** Setting up an SSH connection takes ~200–800ms (TCP + KEX + auth). For workflows that ssh to the same host repeatedly, configure `~/.ssh/config`: `Host prod`/`ControlMaster auto`/`ControlPath ~/.ssh/cm-%h-%p-%r`/`ControlPersist 600` — first connection sets up a control socket; subsequent ssh/scp/rsync invocations to the same host reuse it (sub-millisecond connect, no re-auth). `ControlPersist 600` keeps the master alive for 600 seconds after the last client exits. Manual control: `ssh -O check prod` (is the master alive?), `ssh -O exit prod` (close it). HUGE win for rsync-over-many-files: each file traditionally opens its own SSH session — with ControlMaster, all share one. On older OpenSSH (< 7.4), the control socket path length is limited by `sun_path` — keep `ControlPath` short (`~/.ssh/cm-%C` uses a hash, shorter).
  • **`-J` jump host shortcut and multi-hop tunnels.** Often the target is not directly reachable — you ssh to bastion, then from bastion ssh to db. Old syntax used `-A` agent forwarding + nested ssh. Modern: `-J jumpuser@bastion db-host` (OpenSSH 7.3+). For forwarding through a jump: `ssh -L 5432:localhost:5432 -J bastion db-host` — opens a local port that routes through bastion to db-host, reaching its `localhost:5432`. Better: encode in `~/.ssh/config`: `Host db`/`HostName db.internal`/`ProxyJump bastion` — then `ssh -L 5432:localhost:5432 db` just works. Multi-hop: `ProxyJump bastion1,bastion2` chains them. CAVEAT: `-J` requires the JUMP HOST to allow agent forwarding OR your key to be in its `authorized_keys`. If you can't modify the jump host, use `-o ProxyCommand="ssh -W %h:%p bastion"` (lower-level — works on older OpenSSH too).
  • **Local-port binding: 127.0.0.1 only vs all interfaces, and the security implication.** By DEFAULT, `ssh -L 5432:db:5432 host` binds your local listener on `127.0.0.1:5432` — only YOUR machine can connect (good — nobody else on the WiFi gets DB access through your laptop). To bind on all interfaces (LAN-accessible): `ssh -L 0.0.0.0:5432:db:5432 host` OR set `GatewayPorts yes` in your local `~/.ssh/config`. NEVER do this on a shared / public network — anyone on the LAN gets unauth'd access to whatever was behind the tunnel. For the legitimate case (sharing a dev tunnel with one teammate on the same LAN), prefer `-L 192.168.x.y:5432:db:5432` to bind to a SPECIFIC interface IP, not all of them. The same logic on the `-R` side: server-side `GatewayPorts yes` lets the bastion accept connections on the forwarded port from non-local sources — gate carefully.

Related tasks