How to Use SSH Port Forwarding for Secure Access to Private Web UIs and Databases

Reach admin dashboards and databases through SSH tunnels instead of exposing them publicly, so you can keep private tools private by default.

SSH tunnelingPrivate admin accessDatabase safety
What you learn

How local port forwarding with ssh -L works, how to tunnel to private web interfaces and databases, and how to verify that the service stays reachable only through your SSH session.

Best for

Grafana, Portainer, admin panels, Postgres, MySQL, Redis, and other services that should not sit open on a public port.

Risk to watch

Binding a sensitive service to the public internet is usually easier than tunneling, but it creates a much larger attack surface than most small setups need.

Before you begin

  • SSH access to the server already working.
  • A private service running on the server, often on 127.0.0.1 or an internal container port.
  • A local terminal on your laptop or workstation.
  • A second shell session in case you need to inspect the remote service while the tunnel is active.

This guide focuses on local port forwarding with ssh -L. That is the most useful everyday pattern for small operators who need temporary access to private tools without making them public.

Warning: If a service is currently bound to 0.0.0.0 and exposed publicly, tunneling alone does not make it private. You still need to bind it to localhost or restrict firewall access.

Step 1: Understand what ssh -L actually does

Local forwarding means SSH listens on a port on your local machine and sends that traffic through the encrypted SSH connection to a destination reachable from the remote server.

The basic pattern is:

ssh -L LOCAL_PORT:DESTINATION_HOST:DESTINATION_PORT user@server

Example:

ssh -L 8080:127.0.0.1:3000 ubuntu@your-server

That means:

  • Your laptop listens on localhost:8080
  • Traffic is encrypted over SSH to your-server
  • The remote server connects to 127.0.0.1:3000 on its own side

After that, opening http://localhost:8080 in your browser is like standing on the server and browsing http://127.0.0.1:3000.

Step 2: Tunnel a private web UI such as Grafana or Portainer

Assume Grafana is running only on the server’s localhost at port 3000. Start the tunnel from your local machine:

ssh -L 3000:127.0.0.1:3000 ubuntu@your-server

Keep that SSH session open. Then on your local machine, visit:

http://localhost:3000

If you do not want an interactive shell, use:

ssh -N -L 3000:127.0.0.1:3000 ubuntu@your-server

-N tells SSH not to run a remote shell. It is useful when the tunnel itself is the whole point.

If your local port is already in use, pick a different one:

ssh -N -L 9000:127.0.0.1:3000 ubuntu@your-server

Then browse to http://localhost:9000.

Verification: On the server, confirm the app listens only where you expect:

ss -tulpn | grep ':3000'

A safe result often shows 127.0.0.1:3000 instead of 0.0.0.0:3000.

Step 3: Tunnel to a private database

The same pattern works for databases. Suppose Postgres listens on the server at 127.0.0.1:5432. Start the tunnel locally. Using an alternate local port avoids collisions if Postgres is already running on your own machine:

ssh -N -L 15432:127.0.0.1:5432 ubuntu@your-server

Now connect with a local database client as if Postgres were on your own machine:

psql -h 127.0.0.1 -p 15432 -U myapp myappdb

For MySQL or MariaDB:

ssh -N -L 3306:127.0.0.1:3306 ubuntu@your-server
mysql -h 127.0.0.1 -P 3306 -u myapp -p

For Redis:

ssh -N -L 6379:127.0.0.1:6379 ubuntu@your-server
redis-cli -h 127.0.0.1 -p 6379

This approach is often simpler and safer than opening database ports to your home IP, especially if your IP changes or you work from multiple locations.

Expected outcome: Your database client works through localhost while the real database remains unexposed to the public internet.

Step 4: Make the tunnel repeatable with SSH config

If you use the same tunnel often, add it to ~/.ssh/config:

Host prod-grafana
  HostName your-server
  User ubuntu
  IdentityFile ~/.ssh/id_ed25519_prod_admin
  IdentitiesOnly yes
  LocalForward 3000 127.0.0.1:3000

Host prod-postgres
  HostName your-server
  User ubuntu
  IdentityFile ~/.ssh/id_ed25519_prod_admin
  IdentitiesOnly yes
  LocalForward 5432 127.0.0.1:5432

Then start the tunnel with:

ssh -N prod-grafana

Or:

ssh -N prod-postgres

This is especially useful when paired with clear aliases from a clean SSH config workflow.

Advanced SSH forwarding types such as remote forwarding (-R) and dynamic SOCKS proxies (-D) exist, but most day-to-day admin work only needs local forwarding. Learn that first and keep the default mental model simple.

Step 5: Verify the private-by-default result

By the end, you should have:

  • A working SSH tunnel from your local machine to a private remote service
  • A web UI or database client connecting through localhost
  • The remote service still bound privately or restricted by firewall rules
  • A repeatable command or SSH alias for future use

This is a very practical pattern for admin interfaces, internal APIs, and database maintenance tasks that should not be exposed all the time.

Troubleshooting common SSH tunnel problems

Browser says connection refused on localhost.
Check whether the tunnel command is still running, and confirm you opened the correct local port.

The SSH tunnel connects, but the remote service still does not load.
On the server, test the target directly with something like curl http://127.0.0.1:3000 or ss -tulpn. The service itself may not be listening where you think.

Local port already in use.
Pick another local port, such as 9000:127.0.0.1:3000, and connect to that instead.

The database client connects to the wrong database.
Make sure no local database service is already listening on the same port, and verify the tunnel points to the correct remote host and port.

The service is still publicly reachable.
Fix the service binding or firewall rules. SSH forwarding is an access method, not a firewall replacement.

Caution: Do not leave long-lived tunnels running casually on shared or untrusted machines. They create a temporary local doorway into private systems, which is useful but still worth treating carefully.

What to do next

Once private access is cleaner, the next infrastructure pain point is often outgrowing the disk where containers store data. Continue with Move Docker Data or App Storage to a Larger Disk Without Losing Services.