SSH Tunneling & Port Forwarding

    Securely access remote services, bypass firewalls, and create encrypted tunnels

    SSH tunneling (also called SSH port forwarding) lets you create encrypted connections between machines, allowing you to securely access services that aren't directly exposed to the internet — like database admin panels, internal APIs, or development servers.

    What You'll Learn

    Local forwarding — access remote services locally
    Remote forwarding — expose local services to the VPS
    Dynamic SOCKS proxy for browsing through your VPS
    Jump hosts for multi-hop access
    Persistent tunnels with autossh and systemd
    Security best practices for tunnels

    Prerequisites

    1. How SSH Tunneling Works

    An SSH tunnel creates an encrypted channel between your local machine and your VPS. Traffic sent through this channel is forwarded to a destination that may not be directly accessible. There are three types:

    TypeFlagDirectionUse Case
    Local-LLocal → RemoteAccess a remote database from your laptop
    Remote-RRemote → LocalExpose a local dev server to the internet via your VPS
    Dynamic-DSOCKS proxyRoute all browser traffic through your VPS

    💡 Key concept: The tunnel is always established over your existing SSH connection. The traffic inside the tunnel is encrypted end-to-end by SSH, even if the forwarded service itself uses plain HTTP or an unencrypted protocol.

    2. Local Port Forwarding (-L)

    Local forwarding binds a port on your local machine and forwards traffic through the SSH connection to a destination reachable from the remote server. This is the most common type of tunnel.

    Syntax
    ssh -L [local_address:]local_port:destination_host:destination_port user@vps

    Example: Access a Remote Database

    Your VPS runs MariaDB on port 3306, but the firewall blocks external access. Forward it to your laptop:

    Forward remote MariaDB to localhost:3306
    ssh -L 3306:localhost:3306 deploy@your-vps-ip

    Now connect your database client to localhost:3306 — traffic flows through the encrypted SSH tunnel to MariaDB on the VPS.

    Example: Access a Web Admin Panel

    Many admin panels (phpMyAdmin, Adminer, Grafana) should not be exposed to the internet. Use a tunnel instead:

    Forward a remote web panel
    # Forward remote port 8080 (e.g., Grafana) to local port 9090
    ssh -L 9090:localhost:8080 deploy@your-vps-ip
    
    # Now open http://localhost:9090 in your browser

    Example: Access a Service on Another Host

    The destination doesn't have to be the VPS itself — it can be any host the VPS can reach:

    Forward through VPS to a different server
    # Access a PostgreSQL database on 10.0.0.5 (private network)
    # through your VPS as the SSH hop
    ssh -L 5432:10.0.0.5:5432 deploy@your-vps-ip
    
    # Connect to localhost:5432 — traffic goes:
    # laptop → VPS (encrypted) → 10.0.0.5:5432 (private network)

    Useful Flags

    Common options
    # Run in background (no interactive shell)
    ssh -L 3306:localhost:3306 -f -N deploy@your-vps-ip
    
    # -f  Backgrounds the SSH process after authentication
    # -N  Don't execute a remote command (tunnel only)
    
    # Bind to all local interfaces (not just 127.0.0.1)
    ssh -L 0.0.0.0:3306:localhost:3306 -f -N deploy@your-vps-ip
    
    # Use a specific SSH key
    ssh -L 3306:localhost:3306 -i ~/.ssh/mykey -f -N deploy@your-vps-ip

    ⚠️ Port conflict: If the local port is already in use (e.g., you have a local MySQL running on 3306), pick a different local port: ssh -L 13306:localhost:3306 deploy@vps, then connect to localhost:13306.

    3. Remote Port Forwarding (-R)

    Remote forwarding does the opposite of local forwarding: it binds a port on the remote VPS and forwards traffic back to your local machine. This is useful for exposing a local development server to the internet.

    Syntax
    ssh -R [remote_address:]remote_port:destination_host:destination_port user@vps

    Example: Expose a Local Dev Server

    Expose local port 3000 on VPS port 8080
    # Your app runs on localhost:3000 on your laptop
    # Make it accessible at your-vps-ip:8080
    ssh -R 8080:localhost:3000 deploy@your-vps-ip

    Example: Webhook Development

    Need to receive webhooks from an external service (Stripe, GitHub, etc.) while developing locally:

    Receive webhooks through your VPS
    # Forward VPS port 9000 to your local webhook handler
    ssh -R 9000:localhost:9000 -f -N deploy@your-vps-ip
    
    # Configure Nginx on your VPS to proxy a domain to port 9000
    # Point the webhook URL to https://webhooks.yourdomain.com

    💡 Important: By default, remote forwarded ports only listen on 127.0.0.1 on the VPS. To make them accessible externally, you need GatewayPorts yes in /etc/ssh/sshd_config on the VPS, or use Nginx as a reverse proxy.

    Enable GatewayPorts on the VPS (if needed)
    # /etc/ssh/sshd_config
    GatewayPorts yes
    # or for more control:
    GatewayPorts clientspecified
    Reload SSH after config change
    sudo systemctl reload sshd

    4. Dynamic Forwarding (SOCKS Proxy)

    Dynamic forwarding creates a local SOCKS proxy that routes all traffic through your VPS. Unlike local forwarding (which forwards a single port), a SOCKS proxy can handle any destination.

    Create a SOCKS5 proxy
    # Create a SOCKS5 proxy on local port 1080
    ssh -D 1080 -f -N deploy@your-vps-ip
    
    # Or bind to all interfaces (careful!)
    ssh -D 0.0.0.0:1080 -f -N deploy@your-vps-ip

    Using the Proxy

    Route traffic through the SOCKS proxy
    # curl through the proxy
    curl --socks5-hostname localhost:1080 https://ifconfig.me
    
    # wget through the proxy
    https_proxy=socks5h://localhost:1080 wget -qO- https://ifconfig.me
    
    # Git through the proxy
    git -c http.proxy=socks5h://localhost:1080 clone https://github.com/user/repo

    Browser configuration: In Firefox, go to Settings → Network Settings → Manual proxy → SOCKS Host: 127.0.0.1, Port: 1080, SOCKS v5. Check "Proxy DNS when using SOCKS v5". In Chrome, use a proxy extension or launch with --proxy-server="socks5://127.0.0.1:1080".

    💡 Tip: Use socks5h:// (note the h) instead of socks5:// to perform DNS lookups on the remote side. This prevents DNS leaks and ensures the remote server resolves hostnames.

    5. Jump Hosts / ProxyJump

    If you need to reach a server that's only accessible from another server (e.g., a private network behind a bastion host), use SSH's jump host feature:

    Using -J (ProxyJump)
    # Connect to internal-server through bastion
    ssh -J deploy@bastion-ip deploy@internal-server-ip
    
    # Multiple jumps
    ssh -J deploy@bastion1,deploy@bastion2 deploy@final-server
    
    # With port forwarding through a jump host
    ssh -J deploy@bastion -L 5432:localhost:5432 deploy@db-server

    Legacy Method: ProxyCommand

    On older SSH versions without -J support:

    Using ProxyCommand
    ssh -o ProxyCommand="ssh -W %h:%p deploy@bastion-ip" deploy@internal-server

    6. SSH Config File

    Instead of typing long SSH commands, save your tunnel configurations in ~/.ssh/config:

    ~/.ssh/config
    # Simple VPS connection
    Host myvps
        HostName 203.0.113.10
        User deploy
        IdentityFile ~/.ssh/id_ed25519
    
    # Database tunnel — just run: ssh db-tunnel
    Host db-tunnel
        HostName 203.0.113.10
        User deploy
        IdentityFile ~/.ssh/id_ed25519
        LocalForward 3306 localhost:3306
        LocalForward 5432 localhost:5432
        RequestTTY no
        ExitOnForwardFailure yes
    
    # SOCKS proxy — just run: ssh socks-proxy
    Host socks-proxy
        HostName 203.0.113.10
        User deploy
        IdentityFile ~/.ssh/id_ed25519
        DynamicForward 1080
        RequestTTY no
        ExitOnForwardFailure yes
    
    # Jump host setup
    Host internal-db
        HostName 10.0.0.5
        User deploy
        ProxyJump myvps
        LocalForward 5432 localhost:5432
    
    # Expose local dev server on VPS
    Host expose-dev
        HostName 203.0.113.10
        User deploy
        RemoteForward 8080 localhost:3000
        RequestTTY no
    Using the config
    # Now instead of remembering the full command, just:
    ssh db-tunnel
    ssh socks-proxy
    ssh expose-dev
    
    # All options from the config are applied automatically

    Useful Config Directives

    DirectiveDescription
    LocalForwardEquivalent of -L
    RemoteForwardEquivalent of -R
    DynamicForwardEquivalent of -D
    ProxyJumpEquivalent of -J
    ExitOnForwardFailure yesExit if the tunnel port can't be bound
    ServerAliveInterval 60Send keepalive every 60 seconds
    ServerAliveCountMax 3Disconnect after 3 missed keepalives

    7. Persistent Tunnels

    SSH tunnels drop when the connection is interrupted. For tunnels that need to stay up permanently, use autossh with a systemd service.

    Install autossh

    Install autossh
    # Debian / Ubuntu
    sudo apt install autossh
    
    # AlmaLinux / RHEL
    sudo dnf install autossh

    Manual autossh

    Run autossh
    # autossh monitors the connection and reconnects automatically
    autossh -M 0 -f -N -L 3306:localhost:3306 deploy@your-vps-ip
    
    # -M 0  Disable autossh's built-in monitoring port (use SSH keepalives instead)
    # -f    Background after connecting
    # -N    No remote command

    systemd Service for Persistent Tunnel

    For production reliability, create a systemd service:

    /etc/systemd/system/ssh-tunnel-db.service
    [Unit]
    Description=SSH Tunnel to Database
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    User=deploy
    ExecStart=/usr/bin/autossh -M 0 -N     -o "ServerAliveInterval=30"     -o "ServerAliveCountMax=3"     -o "ExitOnForwardFailure=yes"     -o "StrictHostKeyChecking=accept-new"     -i /home/deploy/.ssh/id_ed25519     -L 3306:localhost:3306     deploy@203.0.113.10
    Restart=always
    RestartSec=10
    
    [Install]
    WantedBy=multi-user.target
    Enable and start
    sudo systemctl daemon-reload
    sudo systemctl enable --now ssh-tunnel-db
    
    # Check status
    sudo systemctl status ssh-tunnel-db
    
    # View tunnel logs
    sudo journalctl -u ssh-tunnel-db -f

    📖 Related: For more on creating and managing systemd services, see Managing systemd Services.

    8. Security Considerations

    Use key-based authentication only

    Tunnels should always use SSH keys, never passwords. See our SSH Keys Guide.

    Restrict tunnel-only accounts

    If a user only needs tunnel access (no shell), restrict their account:

    Restrict a user to forwarding only
    # In /etc/ssh/sshd_config:
    Match User tunnel-user
        AllowTcpForwarding yes
        X11Forwarding no
        AllowAgentForwarding no
        ForceCommand /usr/sbin/nologin
        PermitTTY no

    Bind to localhost only

    By default, local forwarded ports bind to 127.0.0.1. Avoid using 0.0.0.0 unless you specifically need other machines on your network to access the forwarded port.

    Be cautious with GatewayPorts

    Enabling GatewayPorts yes on the VPS allows remote forwarded ports to be accessible from the internet. Use a firewall to restrict access. See UFW Basics.

    Disable forwarding if not needed

    If your VPS doesn't need tunneling at all, disable it in sshd_config:

    Disable all forwarding
    # /etc/ssh/sshd_config
    AllowTcpForwarding no
    AllowStreamLocalForwarding no

    9. Troubleshooting

    "Address already in use"

    Find and kill the process using the port
    # Find what's using the port
    sudo lsof -i :3306
    # or
    sudo ss -tlnp | grep 3306
    
    # Kill the process
    kill <PID>
    
    # Or pick a different local port
    ssh -L 13306:localhost:3306 deploy@vps

    "channel 0: open failed: connect failed"

    This means SSH connected to the VPS, but the VPS can't reach the destination. Check:

    Diagnose connection failures
    # Is the destination service running on the VPS?
    sudo systemctl status mariadb
    
    # Is the destination port open?
    ss -tlnp | grep 3306
    
    # Can the VPS reach the destination host? (for third-party forwarding)
    nc -zv 10.0.0.5 5432

    Tunnel Drops After Inactivity

    Add keepalives
    # On the command line
    ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -L 3306:localhost:3306 deploy@vps
    
    # Or in ~/.ssh/config (apply to all hosts)
    Host *
        ServerAliveInterval 60
        ServerAliveCountMax 3

    Verbose Debugging

    Debug SSH connection
    # Add -v for verbose output (up to -vvv for maximum detail)
    ssh -v -L 3306:localhost:3306 deploy@vps
    
    # Check the VPS-side SSH logs
    sudo journalctl -u sshd -n 50

    📖 Related: For more diagnostic tools, see Linux Log Files Explained and Basic Resource Monitoring.

    Next Steps

    You now know how to create encrypted tunnels for secure access to remote services. Here are some related topics: