Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

SSH Tunneling and Port Forwarding Explained

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.

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.

SSH Tunnel OverviewYour Laptop127.0.0.1:3307Bastion Hostsshd on port 22Encrypted SSHPrivate DB10.0.2.15:3306App connects to localhost; SSH carries bytes to the remote portFirewall sees only SSH — not MySQL
SSH tunneling and port forwarding explained: one encrypted SSH session carries traffic to a private database port.

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.1 unless 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:

  1. -N — no remote shell; forward only.
  2. -f — background after authentication (use with care).
  3. -C — compression for slow links.
  4. -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.

ModeFlagWho listensTypical useMain risk
Local-LYour laptopDB/Redis/admin UI on private networkBinding 0.0.0.0 by mistake
Remote-RRemote serverExpose dev service to someone on server sideGatewayPorts yes on public hosts
Dynamic-DYour laptop (SOCKS)Browse via remote egress IPOpen SOCKS on shared LAN
Three Port Forwarding ModesLocal -LListen here, exit remoteRemote -RListen remote, back to youDynamic -DSOCKS proxy on laptopAll traffic encrypted inside SSH port 22sshd forwards TCP to destination host:portPick mode by where the listening socket must liveLocal for DB access; remote for reverse expose; dynamic for proxy
Local, remote, and dynamic SSH port forwarding compared by listen location and traffic direction.

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:

  1. Ensure SSH access uses keys, not passwords. See SSH hardening with fail2ban.
  2. Create a read-only DB user for debugging—not the app superuser.
  3. Open a local forward to the private DB host.
  4. Run queries or migrations review through the tunnel.
  5. Close the tunnel when finished.
Production Debug Workflow1. SSH key2. -L tunnel3. RO user4. Query5. CloseExample: Laravel .env during tunnel sessionDB_HOST=127.0.0.1 DB_PORT=3307php artisan tinker / mysql client / TablePlusNever commit .env with tunnel ports to git
Safe production database access via SSH local port forwarding with read-only credentials.

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 prefer 127.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 autossh process 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.
Tunnel or Alternative?Need private service?Temporary debugUse ssh -LTeam daily accessVPN or DB proxyPublic webhook testCareful ssh -RAlways: 127.0.0.1 bind, key auth, PermitOpen limitsAvoid: GatewayPorts yes on public hostsRotate keys; use strong passphrases from a generator
Decision guide: when SSH port forwarding fits versus VPN, database proxy, or cautious remote forward.

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 failedAllowTcpForwarding no or PermitOpen blocks the target.
  • Address already in use — another process owns the local port; pick 3308 instead.
  • Connection hangs — missing ServerAliveInterval through a NAT middlebox.
  • DB rejects login — tunnel works; credentials or bind-address on 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 -L forwarding 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 -R and dynamic -D solve reverse expose and SOCKS proxy cases—both need stricter hardening.
  • Lock down AllowTcpForwarding, PermitOpen, and keep GatewayPorts no on public servers.
  • Prefer VPN or managed database access for standing team needs; use SSH tunnels for temporary, auditable work.
  • Document tunnel commands in ~/.ssh/config and test with ssh -v before 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

SSH tunneling wraps TCP traffic inside an encrypted SSH session. Port forwarding lets your SSH client reach remote ports—private databases, Redis, admin panels—through sshd on a bastion without exposing those services publicly.

Run ssh -N -L 127.0.0.1:local_port:destination_host:destination_port user@ssh_server. The -N flag skips a remote shell; forwarding only. Example: ssh -N -L 127.0.0.1:3307:10.0.2.15:3306 deploy@bastion.example.com reaches private MySQL through the bastion. Point your client at 127.0.0.1 and the local port—mysql -h 127.0.0.1 -P 3307. Bind to 127.0.0.1 unless you have a specific reason not to. Add -o ServerAliveInterval=60 on flaky networks. Store repeatable setups in ~/.ssh/config with LocalForward entries.

All three wrap TCP in SSH but differ by who listens and where traffic exits. Local -L: your machine listens; traffic exits on the remote side to a target the server can reach—ideal for private MySQL or Redis. Remote -R: the SSH server listens and sends connections toward your machine or another host—useful for webhook testing. Dynamic -D: your machine runs a SOCKS5 proxy; apps route arbitrary TCP through the tunnel. Remote forwards carry the highest risk if GatewayPorts is enabled on public hosts.

Production MySQL 8.4 LTS or self-hosted MySQL 9.7 should sit on private subnets, not the public internet. On bastions I administer, I repeat this workflow: use key-only SSH auth, create a read-only DB user for debugging, open a local forward with ssh -N -L 127.0.0.1:3307:10.0.2.15:3306 deploy@bastion, then connect via 127.0.0.1:3307. For Laravel locally, set DB_HOST=127.0.0.1 and DB_PORT=3307 in .env while the tunnel runs. Close the tunnel when finished—standing access belongs on VPN or IAM-backed database proxies, not forgotten forwards.

A VPN routes broad network traffic through an encrypted interface. SSH port forwarding moves specific TCP connections through one SSH session—lighter and faster for a single database or admin port.

SSH tunnels piggyback on SSH trust, which creates abuse paths when used casually. Avoid binding local forwards to 0.0.0.0, which exposes them to your LAN or café Wi-Fi neighbours. Do not leave autossh processes running permanently—they become undeclared infrastructure. Treat GatewayPorts yes on public hosts as a last resort; it can expose your local service to the internet. Shared bastions mean anyone with shell access may reach targets your tunnel reaches. For daily team access, prefer WireGuard VPN, Cloudflare Tunnel, or IAM-backed database proxies. Tunnels may also bypass IP allowlists in ways auditors dislike.

Idle NAT tables and corporate firewalls often kill silent SSH sessions. Add ServerAliveInterval 60 and ServerAliveCountMax 3 on the client side to send periodic keepalives. For long-running work during deploy reviews, wrap the session with autossh or a systemd user unit—a dropped tunnel mid-migration is a failure mode I have seen more than once. If drops persist, check for ClientAliveInterval mismatches on the server. Multi-hop ProxyJump chains through bastions benefit from the same keepalive settings on flaky mobile links.

Yes. Payload bytes are encrypted inside the SSH transport between your client and sshd. The inner TCP hop from server to database is plain unless the target protocol uses TLS.

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 by symptom: channel open failed means AllowTcpForwarding is disabled or PermitOpen blocks your target port—check /etc/ssh/sshd_config on the bastion. Address already in use means another process owns the local port; try 3308 instead. Connection hangs often needs ServerAliveInterval through a NAT middlebox. If the DB rejects login, the tunnel may actually work—the problem is credentials or MySQL bind-address. Consult OpenSSH manual pages and sshd_config(5) when logs point at server policy.

Remote forwarding flips the listener: the SSH server opens a port and sends connections back toward your machine or another host you name. Example: ssh -N -R 127.0.0.1:8080:127.0.0.1:3000 deploy@staging.example.com lets someone on staging hit 127.0.0.1:8080 and reach your local Vite dev server on port 3000. This suits webhook testing when a third party can only call a fixed server IP. By default GatewayPorts is no, keeping 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 mode creates a SOCKS5 proxy on your local machine. Run ssh -N -D 127.0.0.1:1080 deploy@bastion.example.com, then configure Firefox or curl with --socks5-hostname 127.0.0.1:1080 to route traffic via the remote egress IP. This helps when a vendor allowlists office or server IPs. It is not a replacement for a proper VPN on long-term access. Binding to 127.0.0.1 keeps the SOCKS listener off your LAN. Use dynamic forwards when you need flexible destination ports, not just one fixed database or service endpoint.

When the database server is not your direct 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 with Host prod-db-tunnel, ProxyJump deploy@bastion.example.com, and LocalForward 127.0.0.1:5433 127.0.0.1:5432, then run ssh -N prod-db-tunnel. Each hop must allow TCP forwarding to the next destination. Clean configs survive context switches better than one-off shell history. Add ServerAliveInterval 60 for multi-hop sessions on unstable links.

On Ubuntu 22/24 bastions, check /etc/ssh/sshd_config before relying on forwards. AllowTcpForwarding yes enables forwarding unless policy disables it entirely. PermitOpen restricts which destination host:port pairs are allowed—useful with Match Group tunnel-users to limit targets like 10.0.2.15:3306 or 10.0.2.20:6379. GatewayPorts no keeps remote forwards on loopback only; yes binds on all interfaces. Reload with sudo systemctl reload ssh after changes and document them in your runbook. These server-side controls matter as much as client commands when hardening production entry points.

CI runners sometimes need one-hop staging database access for integration tests. When a tunnel is unavoidable, scope it tightly. Use a script with 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 so the job fails fast if the forward cannot bind. Run mysql -h 127.0.0.1 -P 3307 against the tunnel, then tear down with pkill -f or a PID file. On GitLab CI pipelines I maintain alongside Deployer 7 releases, ephemeral tunnels beat permanent firewall rules for short test stages.

For teams outgrowing ad-hoc tunnels, evaluate WireGuard site-to-site VPN, Cloudflare Tunnel, or database tools with IAM-backed access. On small client budgets in Nepal, a hardened bastion plus local -L forwards still wins on cost—often Rs 0 extra beyond the existing VPS, roughly USD 0 incremental. VPNs fit whole-team, always-on private subnet access. SSH tunnels fit temporary, auditable debugging: one database port, key-only auth, closed when work ends. The tunnel protects transport; it does not fix weak SQL credentials or missing application auth.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: