
September 10, 2026
11 min read
By Kokil Thapa | Last reviewed: September 2026
You need to reach a service that is not on the public internet. Maybe it is MySQL on a private subnet, Redis behind a firewall, or an admin panel bound to 127.0.0.1 on a remote server. SSH tunneling and port forwarding explained in plain terms means using an encrypted SSH connection to carry TCP traffic to a port the remote host can reach—even when your laptop cannot. On production Linux servers I administer for clients, tunnels are a daily tool for debugging, database access, and temporary integrations without opening new firewall holes.
-L for local, -R for remote, and -D for a SOCKS proxy on port 22.What is SSH tunneling and how does port forwarding work?
SSH is normally a remote shell. Port forwarding reuses that encrypted channel to move other TCP connections. Your local SSH client talks to sshd on a bastion or app server. The daemon then opens a second TCP connection to the target host and port you specify.
Nothing new listens on the public internet unless you explicitly configure remote forwarding. That is why tunnels beat “just open port 3306” on a production box. The traffic rides inside SSH, which you already hardened with keys and key-only authentication.
OpenSSH implements three forwarding modes. Each solves a different direction problem. The official OpenSSH ssh(1) manual documents every flag; the sections below translate that into commands you can run today on Ubuntu 22/24 servers.
Core terms you will see in logs and configs
- Bind address: Which local or remote interface listens. Use
127.0.0.1unless you have a reason to expose wider. - GatewayPorts: Server setting that controls whether remote forwards bind on all interfaces.
- Bastion / jump host: The SSH entry point into a private network.
- AllowTcpForwarding: Can disable forwarding entirely in
sshd_config.
How do you set up local port forwarding with SSH?
Local forwarding (-L) is the mode you will use most often. Your machine opens a listening port. Anything that connects to it is sent through SSH and delivered to a target the remote server can reach.
Syntax:
ssh -L [bind_address:]local_port:destination_host:destination_port user@ssh_server Example: reach production MySQL through a bastion without exposing 3306 publicly.
ssh -N -L 127.0.0.1:3307:10.0.2.15:3306 deploy@bastion.example.com Flags worth knowing:
-N— no remote shell; forward only.-f— background after authentication (use with care).-C— compression for slow links.-o ServerAliveInterval=60— keep NAT/firewall sessions alive.
Now point your client at the tunnel:
mysql -h 127.0.0.1 -P 3307 -u app_readonly -p On a Laravel app running locally, set DB_HOST=127.0.0.1 and DB_PORT=3307 in .env while the tunnel is up. I use this pattern when debugging slow queries on staging data without copying full dumps to a laptop.
Jump hosts in one command
When the database server is not the SSH target, chain through a bastion with ProxyJump:
ssh -N \
-J deploy@bastion.example.com \
-L 127.0.0.1:5433:127.0.0.1:5432 deploy@app-server.internal Or define it once in ~/.ssh/config:
Host prod-db-tunnel
HostName app-server.internal
User deploy
ProxyJump deploy@bastion.example.com
LocalForward 127.0.0.1:5433 127.0.0.1:5432
ServerAliveInterval 60 Then run ssh -N prod-db-tunnel. Clean configs survive context switches better than one-off shell history.
What is the difference between local, remote, and dynamic SSH port forwarding?
All three modes wrap TCP in SSH. The difference is who listens and where traffic exits.
| Mode | Flag | Who listens | Typical use | Main risk |
|---|---|---|---|---|
| Local | -L | Your laptop | DB/Redis/admin UI on private network | Binding 0.0.0.0 by mistake |
| Remote | -R | Remote server | Expose dev service to someone on server side | GatewayPorts yes on public hosts |
| Dynamic | -D | Your laptop (SOCKS) | Browse via remote egress IP | Open SOCKS on shared LAN |
Remote forwarding (-R)
Remote forwarding flips the listener. The SSH server opens a port and sends connections back toward your machine or another host you name.
ssh -N -R 127.0.0.1:8080:127.0.0.1:3000 deploy@staging.example.com Someone on the staging box can hit 127.0.0.1:8080 and reach your local Vite dev server on port 3000. Useful for webhook testing when the third party can only call a fixed server IP.
On the server, GatewayPorts defaults to no. That keeps the forward on loopback. Setting GatewayPorts yes on a public host exposes your local service to the internet. Treat that as a last resort.
Dynamic forwarding (-D) SOCKS proxy
Dynamic mode creates a SOCKS5 proxy on your machine. Applications that support SOCKS send arbitrary TCP through the tunnel.
ssh -N -D 127.0.0.1:1080 deploy@bastion.example.com Configure Firefox or curl --socks5-hostname 127.0.0.1:1080 to route traffic via the remote egress IP. Handy when a vendor allowlists office or server IPs. Not a replacement for a proper VPN on long-term access.
How do you use SSH tunnels to access MySQL or Redis safely in production?
Production databases should not accept connections from the public internet. Managed MySQL 8.4 LTS and self-hosted MySQL 9.7 instances alike belong on private subnets. SSH tunneling gives developers temporary, auditable access through an existing bastion.
A workflow I repeat on Laravel booking platforms and other client stacks:
- Ensure SSH access uses keys, not passwords. See SSH hardening with fail2ban.
- Create a read-only DB user for debugging—not the app superuser.
- Open a local forward to the private DB host.
- Run queries or migrations review through the tunnel.
- Close the tunnel when finished.
Redis and PostgreSQL examples
Redis on a private host:
ssh -N -L 127.0.0.1:6380:10.0.2.20:6379 deploy@bastion.example.com
redis-cli -h 127.0.0.1 -p 6380 PING PostgreSQL 18 on localhost of an app server:
ssh -N -L 127.0.0.1:5433:127.0.0.1:5432 deploy@app.internal
psql "host=127.0.0.1 port=5433 dbname=app user=readonly" For long-running tunnels during a deploy review, use autossh or systemd user units. A dropped tunnel mid-migration is an annoying failure mode I have seen more than once.
Server-side sshd controls
On the bastion, confirm forwarding is allowed unless policy says otherwise:
# /etc/ssh/sshd_config
AllowTcpForwarding yes
PermitOpen any
GatewayPorts no Restrict by group when needed:
Match Group tunnel-users
AllowTcpForwarding local
PermitOpen 10.0.2.15:3306 10.0.2.20:6379 Reload with sudo systemctl reload ssh. Document changes in your runbook alongside backup and support procedures.
When should you avoid SSH tunneling, and what are the security risks?
Tunnels are powerful because they piggyback on SSH trust. That same power creates abuse paths if you treat them casually.
- Binding to all interfaces:
-L 0.0.0.0:3307:...exposes the forward to your LAN or café Wi‑Fi neighbours. Always prefer127.0.0.1. - Agent forwarding: Separate from port forwarding, but often enabled in the same session. Read SSH agent forwarding risks before combining both.
- Shared bastions: Anyone with shell on the bastion may reach targets your tunnel can reach. Limit sudo and audit logins.
- Permanent tunnels: A forgotten
autosshprocess becomes undeclared infrastructure. Prefer VPN or SSO-backed database proxies for standing access. - Compliance: Tunnels may bypass IP allowlists in ways auditors dislike. Log who opened them and when.
Alternatives worth evaluating
For teams outgrowing ad-hoc tunnels, consider WireGuard site-to-site VPN, Cloudflare Tunnel, or database tools with IAM-backed access. On small client budgets in Nepal, a hardened bastion plus -L forwards still wins on cost—often Rs 0 extra beyond the existing VPS (~USD 0 incremental).
Generate strong key passphrases with a password generator tool if your team lacks a shared secrets manager. Store host aliases in version-controlled ssh_config snippets, not passwords.
When tunneling supports API integration work, pair access controls with proper API design and rate limiting on the application side. The tunnel protects transport; it does not fix weak SQL or missing auth in your app.
Debugging a tunnel that refuses connections
Run SSH in the foreground with verbose logging first:
ssh -v -N -L 127.0.0.1:3307:10.0.2.15:3306 deploy@bastion.example.com Common fixes:
channel open failed—AllowTcpForwarding noorPermitOpenblocks the target.Address already in use— another process owns the local port; pick 3308 instead.- Connection hangs — missing
ServerAliveIntervalthrough a NAT middlebox. - DB rejects login — tunnel works; credentials or
bind-addresson MySQL is the real issue.
The OpenSSH manual pages and your distro’s sshd_config(5) page are the authoritative references when logs point at server policy.
How do you automate SSH tunnels for deployment and CI?
CI runners sometimes need one-hop access to a staging database for integration tests. Prefer dedicated network paths when possible. When a tunnel is unavoidable, scope it tightly.
#!/usr/bin/env bash
set -euo pipefail
ssh -f -N -o ExitOnForwardFailure=yes \
-L 127.0.0.1:3307:127.0.0.1:3306 \
-i "$CI_SSH_KEY" deploy@staging.example.com
mysql -h 127.0.0.1 -P 3307 -u ci_readonly -p"$DB_PASS" -e "SELECT 1" Use ExitOnForwardFailure=yes so the job fails fast if the forward cannot bind. Tear down with pkill -f "3307:127.0.0.1:3306" or a known PID file. On GitLab CI pipelines I maintain alongside Deployer 7 releases, ephemeral tunnels beat permanent firewall rules for short test stages.
For local regex-heavy log parsing while debugging tunnel issues, a regex tester saves time matching debug1 lines from ssh -v output.
Key Takeaways
- Local
-Lforwarding is the default pattern for reaching private MySQL, Redis, or admin UIs through a bastion. - Bind to
127.0.0.1, use key-only auth, and close tunnels when debugging ends. - Remote
-Rand dynamic-Dsolve reverse expose and SOCKS proxy cases—both need stricter hardening. - Lock down
AllowTcpForwarding,PermitOpen, and keepGatewayPorts noon public servers. - Prefer VPN or managed database access for standing team needs; use SSH tunnels for temporary, auditable work.
- Document tunnel commands in
~/.ssh/configand test withssh -vbefore backgrounding sessions.
People Also Ask
What is the difference between SSH tunneling and a VPN?
A VPN routes broad network traffic through an encrypted interface. SSH port forwarding moves specific TCP connections through one SSH session. Tunnels are lighter and faster to set up for a single database or admin port. VPNs fit whole-team, always-on access to a private subnet.
Can SSH forwarding work through multiple jump hosts?
Yes. Use ProxyJump chains or ssh -J user@hop1,user@hop2 with your -L flag. Each hop must allow TCP forwarding to the next destination. Keepalive options help multi-hop sessions survive flaky mobile links.
Is SSH tunneling encrypted?
Yes. Payload bytes are encrypted inside the SSH transport between your client and sshd. The inner TCP connection—from server to database—is plain unless the target protocol itself uses TLS. MySQL wire protocol is not encrypted by default; the SSH outer layer protects data in transit across the public path.
Why does my SSH tunnel drop after a few minutes?
Idle NAT tables and corporate firewalls often kill silent sessions. Add ServerAliveInterval 60 and ServerAliveCountMax 3 on the client, or run autossh for automatic restart. Verify the server is not closing channels via ClientAliveInterval mismatches.
Put SSH tunneling to work on your stack
SSH tunneling and port forwarding explained boils down to one idea: reuse SSH trust to reach private ports without punching new holes in your firewall. Master local -L forwards first, harden bastions, and reach for VPN or proxy tools when access becomes daily rather than exceptional. If you want help hardening bastion hosts, CI access patterns, or Laravel stacks that depend on private data stores, see the services overview or contact us for a practical review. For more on keys and server lockdown, browse the blog archive and related SSH guides linked above.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

