SSH tunnel manager: save, start and stop port forwards

Save each tunnel as a Host block in ~/.ssh/config with LocalForward, RemoteForward or DynamicForward, then start it by name with ssh -f -N name. Add ControlMaster, ControlPath and ControlPersist to the block and you can stop it with ssh -O exit name instead of hunting for the process. ssh -O forward and ssh -O cancel add or remove a forward while the tunnel keeps running.

Updated . Run with OpenSSH 10.0 on Debian 13 in throwaway containers: a client, a jump host and a target with a small HTTP server.

Save each tunnel in ~/.ssh/config

Retyping -L and -R options is how ports get mixed up. Give each tunnel a name instead, as its own Host block:

Host db-tunnel
  HostName 203.0.113.10
  User deploy
  LocalForward 127.0.0.1:5432 127.0.0.1:5432
  ExitOnForwardFailure yes

Host dev-share
  HostName 203.0.113.10
  User deploy
  RemoteForward 127.0.0.1:9000 127.0.0.1:3000

Host socks
  HostName 203.0.113.10
  User deploy
  DynamicForward 127.0.0.1:1080

Each line is the config form of a command-line flag: LocalForward is -L, RemoteForward is -R and DynamicForward is -D. The listen address and port come first, then a space, then the destination. As with the flags, the server resolves the destination of a local forward, so 127.0.0.1 there means the server itself. If the three kinds are new to you, start with SSH tunnels explained: local, remote and dynamic.

Use a separate name for tunnels, not the Host you log in with every day. Otherwise every login tries to open the same ports, and the second one prints Address already in use.

Start a tunnel by name

ssh -f -N db-tunnel

-N runs no remote command, so the connection only carries the forwards. -f sends ssh to the background after login, so a passphrase or password prompt still reaches you first. Your database tool can now connect to 127.0.0.1:5432.

With ExitOnForwardFailure yes, a port that is already taken stops ssh with exit code 255 instead of leaving a connection with no tunnel. This is what starting the same tunnel twice looks like:

bind [127.0.0.1]:5432: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 5432
Could not request local forwarding.

To start several saved tunnels at once:

for t in db-tunnel socks; do ssh -f -N "$t"; done

Stop a tunnel

A background tunnel is just an ssh process. Without anything else, you find it and kill it:

ps ax | grep '[s]sh -f -N db-tunnel'
pkill -f 'ssh -f -N db-tunnel'

pkill -f matches any process whose command line contains the text. In our tests it also killed the shell script that had started the tunnel, because the script contained the same words. A control socket is cleaner. Add three lines to the block:

Host db-tunnel
  HostName 203.0.113.10
  User deploy
  LocalForward 127.0.0.1:5432 127.0.0.1:5432
  ExitOnForwardFailure yes
  ControlMaster auto
  ControlPath ~/.ssh/cm-%C
  ControlPersist yes

Now the tunnel answers to commands by name:

ssh -f -N db-tunnel
ssh -O check db-tunnel
ssh -O exit db-tunnel
Master running (pid=27)
Exit request sent.

The socket also lets other ssh and scp commands to db-tunnel reuse the same connection. The details are in SSH multiplexing with ControlMaster.

Add or remove a forward without reconnecting

With a control socket, you can change a running tunnel. The connection and its other forwards stay up:

ssh -O forward -L 127.0.0.1:8081:127.0.0.1:8000 db-tunnel
ssh -O cancel -L 127.0.0.1:8081:127.0.0.1:8000 db-tunnel

cancel needs exactly the same specification you used to add the forward. It works for -R and -D as well. With -R 0:..., the server picks a free port and ssh -O forward prints it.

Keep a saved tunnel up

A tunnel started with -f lives as long as its SSH connection. Sleep, a Wi-Fi change or a server restart ends it. Add these lines to the block so ssh notices a dead connection within about 45 seconds and exits, instead of hanging:

  ServerAliveInterval 15
  ServerAliveCountMax 3

Something then has to start it again: a loop, a service manager, or autossh for a persistent SSH tunnel.

Mistakes that break saved tunnels

  • A colon instead of a space. LocalForward 5432:127.0.0.1:5432 is the command-line format, not the config format. ssh answers Missing target argument, and in our test that one bad line stopped every host in the file from connecting.
  • Listening on all interfaces. LocalForward 0.0.0.0:5432 ... or GatewayPorts yes lets other machines on your network use the tunnel. Keep 127.0.0.1 unless you mean it.
  • Two tunnels on one local port. Only the first one starts. Give each saved tunnel its own port.
  • Forwarding refused by the server. If the port opens but connections fail with administratively prohibited, the server has AllowTcpForwarding turned off for you.

Questions

How do I see which SSH tunnels are running?

Run ps ax | grep '[s]sh -f -N' to list background ssh processes with their options. For a tunnel with a control socket, ssh -O check name answers directly. Inside an interactive session, press Enter and type ~# to list its forwarded connections.

Can one command start several tunnels?

Yes. Put several LocalForward or DynamicForward lines in one Host block and they all open with one ssh -f -N name. If one port is busy and ExitOnForwardFailure is on, none of them stays open.

Why does my saved tunnel stop after sleep or a network change?

The tunnel lives inside one SSH connection, and that connection dies when the network drops. Add ServerAliveInterval so ssh notices, and restart it with a loop or with autossh.

Sources

More in Tunnels and jump hosts

All guides