All articles🇬🇧 English
8 min

SSH Port Forwarding — Secure Server Access

How to access pgAdmin, Grafana, and admin panels via SSH tunnel without exposing ports to the internet. Practical examples and automation.

sshsecurityport-forwardingtunnelingserver

SSH Port Forwarding (tunneling) routes network traffic through an encrypted SSH connection. It lets you access pgAdmin, Grafana, RabbitMQ, and other services on your server without opening their ports to the internet. A single command replaces VPN setup, firewall rules, and additional authentication layers.

If SSH keys aren't configured on your server yet — start with the SSH keys setup guide. It also covers ~/.ssh/config, which you'll need for tunnel automation later in this article.

When SSH Tunnels Beat Alternatives

SSH tunnels aren't the only way to keep admin panels off the internet. But in most cases they're optimal:

SolutionWhen it fitsWhen it's overkill
SSH Port Forwarding1–3 developers, one serverLarge team, permanent access
VPN (WireGuard)Entire team, many servicesOne-time access for one person
Bastion HostDozens of servers, corporate infraSingle VPS
Cloudflare TunnelWeb access without VPN for teamCLI tools and databases

In practice it's simpler than it sounds: for a single VPS with a Docker stack, an SSH tunnel is up in 10 seconds, requires no additional software, and works with any protocol — HTTP, PostgreSQL, Redis, AMQP.

Types of SSH Port Forwarding

Local Port Forwarding (-L) — Primary Use Case

Local forwarding maps a port from the remote server to your local machine. Used in 90% of cases — when you need to open a browser or connect a client to a service running on the server.

Syntax:

ssh -L [local_port]:[destination_host]:[destination_port] user@server

Traffic flow:

Your computer (localhost:8080) → SSH tunnel → Server (localhost:3001)

Remote Port Forwarding (-R) — For Demos and Webhooks

Remote forwarding works in the opposite direction: maps a port from your local machine to the server. Use cases — showing a client your local dev server or receiving a webhook from an external service.

ssh -R [remote_port]:[local_host]:[local_port] user@server

Dynamic Port Forwarding (-D) — SOCKS Proxy

Creates a SOCKS5 proxy on your local machine. All traffic routed through the proxy goes through the SSH connection. Used for accessing the server's entire private network rather than a specific port.

ssh -D 1080 user@server

Practical Examples

Accessing pgAdmin

pgAdmin is deployed in a Docker container on server port 5050. The port isn't open in the firewall — and shouldn't be:

ssh -L 5050:localhost:5050 deployer@YOUR_SERVER_IP

After connecting, http://localhost:5050 in the browser opens pgAdmin. Traffic is encrypted, port is closed from the internet.

Direct PostgreSQL Connection

For working via DBeaver, DataGrip, or psql — forward port 5432:

ssh -L 5432:localhost:5432 deployer@YOUR_SERVER_IP

Connection parameters in the client:

Host: localhost
Port: 5432
Database: your_database
User: your_user

I once worked on a project where a colleague connected to the database through public port 5432, open in the firewall, for an entire week. PostgreSQL logs accumulated over 12,000 brute-force attempts from bots during that time. An SSH tunnel closed the issue in a minute.

Multiple Services Simultaneously

Admin panel on port 3001, pgAdmin on 5050, Grafana on 3000 — all in one command:

ssh -L 8080:localhost:3001 \
    -L 5050:localhost:5050 \
    -L 3000:localhost:3000 \
    deployer@YOUR_SERVER_IP

After connecting:

ServiceLocal address
Admin panelhttp://localhost:8080
pgAdminhttp://localhost:5050
Grafanahttp://localhost:3000

Forwarding Through a Jump Server (Bastion)

Application in a private network (10.0.0.5:3001), accessible only through an intermediate server:

Your PC → Jump Server (public IP) → Private Server (10.0.0.5:3001)
ssh -L 8080:10.0.0.5:3001 deployer@jump-server.com

The SSH tunnel is built to jump-server, and from there traffic goes to 10.0.0.5:3001. Locally everything is available at http://localhost:8080.

Background Mode and Automation

Tunnel Without an Interactive Session

Flags -f and -N start the tunnel in the background without executing commands on the server:

ssh -f -N -L 8080:localhost:3001 deployer@YOUR_SERVER_IP

To terminate:

# Find PID
ps aux | grep "ssh -f"

# Terminate
kill <PID>

autossh — Automatic Reconnection

SSH tunnels break on unstable connections. autossh monitors the state and reconnects:

sudo apt install autossh

autossh -M 0 -f -N -L 8080:localhost:3001 deployer@YOUR_SERVER_IP

The -M 0 flag disables the separate monitoring port — instead it uses ServerAliveInterval / ServerAliveCountMax, which are already configured in ~/.ssh/config from the SSH keys guide.

~/.ssh/config — Tunnels With One Command

Instead of long commands — a config on the local machine:

Host myproject-admin
    HostName YOUR_SERVER_IP
    User deployer
    IdentityFile ~/.ssh/id_ed25519
    LocalForward 8080 localhost:3001
    LocalForward 5050 localhost:5050
    LocalForward 3000 localhost:3000
    ServerAliveInterval 60
    ServerAliveCountMax 3

Now:

# Was
ssh -L 8080:localhost:3001 -L 5050:localhost:5050 -L 3000:localhost:3000 deployer@YOUR_SERVER_IP

# Became
ssh myproject-admin

More on ~/.ssh/config, wildcard rules, and managing multiple servers — in the SSH keys article.

SSH Tunnel Security

Binding to localhost

By default -L binds the forwarded port to 127.0.0.1 — accessible only from your machine. Explicitly specifying 0.0.0.0 opens the port to everyone on the local network:

# Bad — accessible to all devices on the network
ssh -L 0.0.0.0:8080:localhost:3001 user@server

# Correct — local only
ssh -L 8080:localhost:3001 user@server

Few people pay attention to this flag, but it turns an SSH tunnel from a secure tool into a vulnerability — anyone on your Wi-Fi network gains access to the forwarded service.

Don't Leave Tunnels Open

Background tunnels (ssh -f -N) run until the process is explicitly terminated. Check active tunnels:

ps aux | grep "ssh -f"

For production servers there's a nuance worth keeping in mind: an unused tunnel is an open SSH connection which, if the local machine is compromised, gives an attacker a direct path to server services.

Additional Measures

An SSH tunnel is only as secure as the SSH access itself. Basic measures — disabling passwords, ed25519 keys, fail2ban, changing the default port — are covered in SSH keys setup and production server preparation.

Troubleshooting

"Address already in use"

The local port is already occupied by another process:

# Find what's using the port
sudo lsof -i :8080

# Terminate the process
kill <PID>

# Or use a different local port
ssh -L 8081:localhost:3001 deployer@YOUR_SERVER_IP

"Connection refused"

Three causes and diagnostics:

  1. Application not running on the server
  2. Application listening on a specific IP, not localhost
  3. Docker container bound to 172.x.x.x instead of 0.0.0.0
# On server — check that service is listening
sudo ss -tlnp | grep :3001

# If listening on 172.17.0.2:3001 — forward through that IP
ssh -L 8080:172.17.0.2:3001 deployer@YOUR_SERVER_IP

Tunnel Disconnects

Add to ~/.ssh/config:

Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    TCPKeepAlive yes

If disconnects are frequent — autossh with automatic reconnection (described above).

FAQ

How is an SSH tunnel different from a VPN?

An SSH tunnel forwards specific ports through an encrypted SSH connection. A VPN creates a virtual network interface and routes all traffic (or a subnet) through a secure channel. For accessing 2–3 services as a single developer, an SSH tunnel is up with one command. For the whole team with a dozen services — WireGuard VPN is more convenient.

Is it safe to forward the PostgreSQL port?

Yes, if the tunnel is bound to 127.0.0.1 (the default). Traffic goes through an encrypted SSH connection, port 5432 isn't opened in the firewall, external connections are impossible. This is safer than opening 5432 in UFW and hoping for a strong password.

How to forward a port to a Docker container?

If the container publishes a port to the host (-p 5050:5050 in docker-compose), forward localhost:5050. If the container is in an isolated Docker network without published ports, forward the container IP: ssh -L 5050:172.17.0.3:5050 user@server. Container IP — docker inspect container_name | grep IPAddress.

Can tunnel startup be automated?

Yes. Two approaches: LocalForward in ~/.ssh/config (tunnel is created on ssh host-alias) and autossh for background tunnels with auto-reconnection. For a systemd server you can create a unit file that starts autossh at system boot.