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.
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.
Grafana, Portainer, admin panels, Postgres, MySQL, Redis, and other services that should not sit open on a public port.
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.1or 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.
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@serverExample:
ssh -L 8080:127.0.0.1:3000 ubuntu@your-serverThat means:
- Your laptop listens on
localhost:8080 - Traffic is encrypted over SSH to
your-server - The remote server connects to
127.0.0.1:3000on 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-serverKeep that SSH session open. Then on your local machine, visit:
http://localhost:3000If 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-serverThen 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-serverNow connect with a local database client as if Postgres were on your own machine:
psql -h 127.0.0.1 -p 15432 -U myapp myappdbFor 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 -pFor Redis:
ssh -N -L 6379:127.0.0.1:6379 ubuntu@your-server
redis-cli -h 127.0.0.1 -p 6379This 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.
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:5432Then start the tunnel with:
ssh -N prod-grafanaOr:
ssh -N prod-postgresThis 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.
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.
