Version: 2025-06-27
Author: [email protected]
License: MIT
- Quickstart for the Impatient
- What is SSH Tunneling?
- Glossary
- Types of SSH Tunnels
- Basic Tips
- Team Workflow Integration
- Development Tools Integration
- Security Best Practices for Teams
- Troubleshooting for Developers
- Advanced Patterns for Development Teams
- Windows-Specific Considerations
- TL;DR Cheat Sheet
- SSH Agent Forwarding
- Multiple Port Tunneling Strategies
Want to forward a remote database port to your local machine?
ssh -L 15432:db.internal:5432 [email protected]Now, connect your database client to localhost:15432. That's it! (Full guide continues below.)
SSH tunneling (also called "SSH port forwarding") lets you securely route network traffic from your local machine to another server or service through an encrypted SSH connection. It's like creating a secure "pipe" between your computer and a remote host.
Common use cases for development teams:
- Connecting to staging/production databases during development
- Accessing internal APIs and microservices behind firewalls
- Debugging services in remote environments (staging, production)
- Connecting to Redis, Elasticsearch, or other data stores
- Accessing container registries or internal repositories
- Secure code deployment and CI/CD pipeline access
- Testing applications against production-like data safely (Note: Tunneling only secures access, not anonymization)
- Bastion host: A server used as a gateway to access a private network from the outside.
- Jump host: Similar to bastion, a host you SSH through to reach another.
- SOCKS proxy: A proxy that routes network packets between client and server through a proxy server (see Dynamic Forwarding).
- Port forwarding: Mapping a port from your local machine to a port on a remote host, or vice versa.
- Agent forwarding: Allows using your local SSH keys on remote servers without copying keys.
-
Local Port Forwarding
Forward a local port to a remote service.
Example: Access a remote database's port from your laptop. -
Remote Port Forwarding
Forward a remote port to a local service.
Example: Expose your local web server to the internet via a remote host. -
Dynamic Port Forwarding
Acts as a SOCKS proxy, routing traffic dynamically.
Example: Route browser traffic through a remote server (poor-man's VPN).
Syntax:
ssh -L [LOCAL_PORT]:[DEST_HOST]:[DEST_PORT] [USER]@[SSH_SERVER]How it works:
- You connect to the SSH server (
[SSH_SERVER]). - Anything sent to
[LOCAL_PORT]on your local machine gets forwarded to[DEST_HOST]:[DEST_PORT]from the SSH server.
Example - Database Access:
Connect to a PostgreSQL database in staging environment:
ssh -L 15432:db-staging.internal:5432 [email protected]Now connect your local database client to localhost:15432:
postgresql://username:password@localhost:15432/database_name
Example - Multiple Services:
Access both database and Redis simultaneously:
ssh -L 15432:db.internal:5432 -L 16379:cache.internal:6379 [email protected]Syntax:
ssh -R [REMOTE_PORT]:[DEST_HOST]:[DEST_PORT] [USER]@[SSH_SERVER]How it works:
- You connect to the SSH server.
- Anything sent to
[REMOTE_PORT]on the SSH server gets forwarded to[DEST_HOST]:[DEST_PORT]from your local machine.
Example - Local Development Server:
Share your local development server with team members:
ssh -R 8080:localhost:3000 [email protected]Team members can now access your local React/Node.js app at shared-server.com:8080.
Example - Webhook Testing:
Expose local webhook endpoint for testing with external services:
ssh -R 9000:localhost:8000 [email protected]Syntax:
ssh -D [LOCAL_PORT] [USER]@[SSH_SERVER]How it works:
- SSH creates a SOCKS proxy on
[LOCAL_PORT]. - Configure your browser/app to use
localhost:[LOCAL_PORT]as a SOCKS5 proxy. - All browser traffic is tunneled through the SSH connection.
Example:
Start a SOCKS proxy on local port 1080:
ssh -D 1080 [email protected]Then set your browser to use localhost:1080 as a SOCKS5 proxy.
-N: Add this if you don't want a remote shell (just tunneling).
ssh -N -L ...-f: Run SSH in the background after authentication.
ssh -f -N -L ...- Key-based auth: Use SSH keys for convenience/security.
⚠️ Never bind remote ports to0.0.0.0(all interfaces) unless you know what you're doing.- Make sure relevant ports are allowed on the remote host/firewall.
- Ensure your SSH server has
AllowTcpForwarding yes(otherwise tunnels won't work).
Create a shared SSH config pattern for consistency across your team. Add to ~/.ssh/config (or %USERPROFILE%\.ssh\config on Windows):
# Development Environment
Host dev-tunnel
HostName bastion-dev.company.com
User your-username
LocalForward 15432 db-dev.internal:5432
LocalForward 16379 redis-dev.internal:6379
LocalForward 19200 elasticsearch-dev.internal:9200
# Staging Environment
Host staging-tunnel
HostName bastion-staging.company.com
User your-username
LocalForward 25432 db-staging.internal:5432
LocalForward 26379 redis-staging.internal:6379
LocalForward 18080 api-staging.internal:8080
# Production (Read-only access)
Host prod-tunnel
HostName bastion-prod.company.com
User your-username
LocalForward 35432 db-replica-prod.internal:5432
Usage: Simply run ssh dev-tunnel to connect with all tunnels active.
- Download PuTTY.
- In "Session", enter your host (e.g.
bastion.company.com). - Go to "Connection > SSH > Tunnels":
- Source port:
15432 - Destination:
db-dev.internal:5432 - Click "Add". Repeat for each needed port.
- Source port:
- Connect and login as usual. Your local ports are now forwarded.
Establish consistent local port ranges:
| Service Type | Dev Ports | Staging Ports | Production Ports |
|---|---|---|---|
| PostgreSQL | 15432 | 25432 | 35432 |
| MySQL | 13306 | 23306 | 33306 |
| Redis | 16379 | 26379 | 36379 |
| MongoDB | 17017 | 27017 | 37017 |
| APIs | 18000+ | 28000+ | 38000+ |
PostgreSQL (psql, pgAdmin, DBeaver):
# Connection details after tunnel is active
Host: localhost
Port: 15432 # Your local tunnel port
Database: your_database
Username: your_username
Password: your_passwordMySQL Workbench/CLI:
mysql -h localhost -P 13306 -u username -p database_name.env usage (with Docker Compose):
DATABASE_URL=postgresql://user:pass@localhost:15432/dev_db
REDIS_URL=redis://localhost:16379VS Code with Database Extensions:
- Install "PostgreSQL" or "MySQL" extension
- Create connection using
localhostand tunnel port - Save connection profile for easy access
IntelliJ/DataGrip:
- Use "SSH/SSL" tab in connection settings
- Configure SSH tunnel directly in the IDE
- Or connect to
localhost:[tunnel_port]with active tunnel
Accessing containerized databases:
# Connect to database running in Docker on remote server
ssh -L 15432:remote-server:5432 user@remote-server
# Now your local Docker can connect to remote database
# (Use host.docker.internal for Docker Desktop or localhost for Linux)
docker run -e DATABASE_URL=postgresql://user:[email protected]:15432/db myapp
⚠️ Always treat your SSH keys and tunnels as privileged access.
- Use dedicated keys: Create separate SSH keys for different environments.
- Key rotation: Regularly rotate team SSH keys (quarterly recommended).
- No password auth: Disable password authentication on jump servers.
- Key passphrases: Always use passphrases on private keys.
- SSH Certificates: Consider OpenSSH certificates and a short-lived CA for large orgs.
# Generate environment-specific keys
ssh-keygen -t ed25519 -f ~/.ssh/id_dev_env -C "dev-environment"
ssh-keygen -t ed25519 -f ~/.ssh/id_staging_env -C "staging-environment"- Log connections: Enable SSH connection logging on jump servers.
- Monitor tunnel usage: Track which developers access which services.
- Time-based access: Use
Matchdirectives for time-restricted access. - Principle of least privilege: Only tunnel to services you need.
# Bind to localhost only (secure)
ssh -L 127.0.0.1:15432:db.internal:5432 user@host
# Never do this in production (insecure - binds to all interfaces)
ssh -L 0.0.0.0:15432:db.internal:5432 user@host🚨 Common Gotchas:
- Is
AllowTcpForwarding yesset on your SSH server?- Are firewalls blocking your forwarded ports?
- Does your target service listen on the correct network interface?
- Are you using the right credentials?
"Connection refused" vs "Connection timeout":
- Connection refused: Target service is not running or port is wrong
- Connection timeout: Network/firewall issue, or wrong host
Debug steps:
# 1. Verify tunnel is active
ps aux | grep ssh
# On Windows: Get-Process | Where-Object {$_.ProcessName -eq "ssh"}
# 2. Test local tunnel port
telnet localhost 15432
# On Windows: Test-NetConnection localhost -Port 15432
# 3. Check if remote service is reachable from SSH server
ssh [email protected] 'telnet db.internal 5432'
# 4. Verify SSH connection works
ssh -v [email protected]# Check what's using a port
lsof -i :15432
# On Windows: netstat -ano | findstr :15432
# Kill existing SSH tunnels
pkill -f "ssh.*15432"
# On Windows: taskkill /F /IM ssh.exe# List active tunnels
ps aux | grep "ssh -"
# Keep tunnel alive (auto-reconnect)
while true; do
ssh -N -L 15432:db.internal:5432 user@host
echo "Reconnecting..."
sleep 5
done
# Or use autossh (install separately)
autossh -N -L 15432:db.internal:5432 user@hostDatabase connection fails after tunnel established:
- Check if database credentials are correct
- Verify database allows connections from SSH server IP
- Check if database is actually running
IDE can't connect through tunnel:
- Some IDEs require explicit 127.0.0.1 instead of localhost
- Check if IDE has built-in SSH tunnel support (use that if available)
- Verify tunnel port matches IDE configuration
Tunnel works but application doesn't:
- Check application's connection string/config
- Verify environment variables are set correctly
- Look for hard-coded IPs/hostnames in application code
# Connect to multiple environments simultaneously
ssh -N -L 15432:db-dev.internal:5432 user@dev-bastion &
ssh -N -L 25432:db-staging.internal:5432 user@staging-bastion &
ssh -N -L 35432:db-prod.internal:5432 user@prod-bastion &
# Now you can switch between environments in your applicationModern SSH environments often require hopping through multiple servers to reach your target. Here are comprehensive approaches:
# Single jump host
ssh -J user@jump-host user@target-host -L 15432:db.internal:5432
# Multiple jump hosts (chain through bastion -> jump -> target)
ssh -J [email protected],[email protected] [email protected] -L 15432:db:5432
# With different users per hop
ssh -J [email protected],[email protected] [email protected] -L 15432:db:5432# In ~/.ssh/config:
Host bastion
HostName bastion.company.com
User admin
ForwardAgent yes
Host jump-server
HostName jump.internal
User developer
ProxyJump bastion
ForwardAgent yes
Host database-server
HostName db.internal
User app-user
ProxyJump jump-server
LocalForward 15432 localhost:5432
LocalForward 16379 redis.internal:6379
# Usage: ssh database-server (automatically chains through all hops)# For older SSH versions
Host target-via-proxy
HostName target.internal
User target-user
ProxyCommand ssh -W %h:%p user@jump-host
LocalForward 15432 db.internal:5432# Real-world example: Access production database through multiple security layers
Host prod-db-tunnel
HostName prod-app-server.internal
User readonly-user
ProxyJump [email protected],[email protected]
LocalForward 35432 prod-db-cluster.internal:5432
LocalForward 36379 prod-redis-cluster.internal:6379
LocalForward 39200 prod-elasticsearch.internal:9200
ForwardAgent no # Security: don't forward keys to production
ServerAliveInterval 60
ServerAliveCountMax 3
# Usage: ssh prod-db-tunnel
# Connects through: local -> external-bastion -> internal-jump -> prod-app-server
# Provides access to: PostgreSQL, Redis, and Elasticsearch# Automatically find available local ports
ssh -J user@jump user@target \
-L 0:db1.internal:5432 \
-L 0:db2.internal:5432 \
-L 0:redis.internal:6379
# SSH will assign available ports and show them in verbose mode
ssh -v -J user@jump user@target -L 0:db.internal:5432Shell script for team onboarding:
#!/bin/bash
# dev-setup.sh
echo "Setting up development tunnels..."
# Kill existing tunnels
pkill -f "ssh.*1[5-6][0-9]{3}"
# Start development tunnels
ssh -f -N dev-tunnel # Uses SSH config
ssh -f -N staging-tunnel
echo "✅ Development environment ready!"
echo "PostgreSQL: localhost:15432"
echo "Redis: localhost:16379"
echo "Staging PostgreSQL: localhost:25432"# Round-robin between multiple database replicas
ssh -L 15432:db-replica-1.internal:5432 user@host &
ssh -L 15433:db-replica-2.internal:5432 user@host &
ssh -L 15434:db-replica-3.internal:5432 user@host &
# Application can rotate between localhost:15432, 15433, 15434# Check for active SSH processes
Get-Process | Where-Object {$_.ProcessName -eq "ssh"}
# Test port connectivity
Test-NetConnection localhost -Port 15432
# Kill SSH processes
Get-Process ssh | Stop-Process -Force- Windows 10/11: Built-in OpenSSH client available
- Alternative: Use WSL for Linux-like SSH experience
- PuTTY: Alternative SSH client with GUI tunnel configuration
C:\Users\%USERNAME%\.ssh\config
| Scenario | Command | Description |
|---|---|---|
| Database Access | ssh -L 15432:db.internal:5432 user@host |
Connect local app to remote database |
| API Testing | ssh -L 18080:api.internal:8080 user@host |
Access internal API from localhost |
| Share Local Server | ssh -R 8080:localhost:3000 user@host |
Expose local dev server to team |
| Multiple Services | ssh -L 15432:db:5432 -L 16379:redis:6379 user@host |
Tunnel multiple services at once |
| SOCKS Proxy | ssh -D 1080 user@host |
Route all traffic through remote server |
| Background Tunnel | ssh -f -N -L 15432:db:5432 user@host |
Run tunnel in background |
| Use SSH Config | ssh dev-tunnel |
Use predefined SSH config entry |
| Scenario | Command | Description |
|---|---|---|
| Agent Forwarding | ssh -A user@host |
Use local SSH keys on remote server |
| Jump Host Chain | ssh -J user@jump1,user@jump2 user@target |
Chain through multiple jump hosts |
| Multiple Ports | ssh -L 5432:db:5432 -L 6379:redis:6379 user@host |
Tunnel multiple services at once |
| Auto Port Discovery | ssh -L 0:service:port user@host |
Let SSH choose available local port |
| Background + Multiple | ssh -f -N -L 5432:db:5432 -L 6379:redis:6379 user@host |
Run multiple tunnels in background |
| Through Jump + Tunnel | ssh -J user@jump user@target -L 5432:db:5432 |
Combine jump host with port forwarding |
# Development (with agent forwarding for Git operations)
ssh -A -f -N -L 15432:db-dev:5432 -L 16379:redis-dev:6379 user@dev-host
# Staging (multiple services)
ssh -f -N \
-L 25432:db-staging:5432 \
-L 26379:redis-staging:6379 \
-L 28080:api-staging:8080 \
-L 28081:auth-staging:8081 \
user@staging-host
# Production (read-only, through jump host)
ssh -J user@bastion user@prod-host -f -N -L 35432:db-prod-replica:5432
# Multiple environments simultaneously
ssh -f -N dev-stack & # Uses SSH config
ssh -f -N staging-stack &
ssh -f -N prod-readonly &# PostgreSQL
postgresql://user:pass@localhost:15432/database
# MySQL
mysql://user:pass@localhost:13306/database
# Redis
redis://localhost:16379
# MongoDB
mongodb://localhost:17017/databaseSSH Agent Forwarding allows you to use your local SSH keys on remote servers without copying private keys to those servers. This is especially useful when chaining through multiple servers.
When you enable agent forwarding, your local SSH agent is "forwarded" to the remote server, allowing it to use your local keys for subsequent SSH connections.
Enable agent forwarding:
# Method 1: Command line flag
ssh -A user@remote-host
# Method 2: SSH config
Host jump-server
HostName jump.company.com
User your-username
ForwardAgent yesScenario 1: Database Access Through Jump Host
# Without agent forwarding (requires key on jump server)
ssh user@jump-server
# Then from jump server: ssh user@db-server
# With agent forwarding (uses your local key)
ssh -A user@jump-server
# Now you can SSH to db-server using your local keyScenario 2: Git Operations on Remote Servers
# SSH to development server with agent forwarding
ssh -A [email protected]
# Now you can clone/push using your local Git SSH keys
git clone [email protected]:company/private-repo.gitScenario 3: Deployment Scripts
# SSH config for deployment server
Host deploy-server
HostName deploy.company.com
User deployer
ForwardAgent yes
LocalForward 15432 prod-db.internal:5432
# Usage: ssh deploy-server
# Now you can access prod database AND use your keys for Git operations- Only use agent forwarding with trusted servers
- Malicious users on the remote server can use your forwarded agent
- Consider using
-o ForwardAgent=noto explicitly disable when not needed - Use time-limited keys or certificate-based authentication for sensitive environments
# Safer approach: Disable by default, enable selectively
Host *
ForwardAgent no
Host trusted-jump-server
HostName jump.company.com
ForwardAgent yesBasic Multi-Port Tunneling:
# Tunnel multiple services in one command
ssh -L 15432:db.internal:5432 \
-L 16379:redis.internal:6379 \
-L 19200:elasticsearch.internal:9200 \
-L 18080:api.internal:8080 \
[email protected]Development Environment Setup:
# Complete development stack access
ssh -N -f \
-L 15432:postgres-dev.internal:5432 \
-L 13306:mysql-dev.internal:3306 \
-L 16379:redis-dev.internal:6379 \
-L 17017:mongo-dev.internal:27017 \
-L 19200:elastic-dev.internal:9200 \
-L 18080:api-dev.internal:8080 \
-L 18081:auth-service.internal:8081 \
-L 18082:notification-service.internal:8082 \
[email protected]Sequential Port Mapping:
# Map multiple database replicas
for i in {1..5}; do
ssh -f -N -L $((15430 + i)):db-replica-$i.internal:5432 user@host
done
# Results in:
# localhost:15431 -> db-replica-1:5432
# localhost:15432 -> db-replica-2:5432
# localhost:15433 -> db-replica-3:5432
# localhost:15434 -> db-replica-4:5432
# localhost:15435 -> db-replica-5:5432Microservices Architecture:
# Tunnel entire microservices ecosystem
services=(
"user-service:8001"
"order-service:8002"
"payment-service:8003"
"inventory-service:8004"
"notification-service:8005"
)
base_port=18000
for i in "${!services[@]}"; do
service_name=${services[$i]%:*}
service_port=${services[$i]#*:}
local_port=$((base_port + i + 1))
ssh -f -N -L $local_port:$service_name.internal:$service_port user@bastion
echo "✅ $service_name available at localhost:$local_port"
doneSSH Config with Multiple Environments:
# ~/.ssh/config
Host dev-stack
HostName dev-bastion.company.com
User developer
# Database layer
LocalForward 15432 postgres-dev.internal:5432
LocalForward 13306 mysql-dev.internal:3306
LocalForward 16379 redis-dev.internal:6379
# API layer
LocalForward 18080 api-gateway-dev.internal:8080
LocalForward 18081 user-api-dev.internal:8081
LocalForward 18082 order-api-dev.internal:8082
# Monitoring
LocalForward 19090 prometheus-dev.internal:9090
LocalForward 19091 grafana-dev.internal:3000
Host staging-stack
HostName staging-bastion.company.com
User developer
# Same services, different port range (25xxx)
LocalForward 25432 postgres-staging.internal:5432
LocalForward 23306 mysql-staging.internal:3306
LocalForward 26379 redis-staging.internal:6379
LocalForward 28080 api-gateway-staging.internal:8080
LocalForward 28081 user-api-staging.internal:8081
LocalForward 28082 order-api-staging.internal:8082
Host prod-readonly
HostName prod-bastion.company.com
User readonly-user
# Production read-only access (35xxx)
LocalForward 35432 postgres-prod-replica.internal:5432
LocalForward 33306 mysql-prod-replica.internal:3306
LocalForward 36379 redis-prod-replica.internal:6379Auto-Discovery Script:
#!/bin/bash
# discover-services.sh
declare -A services=(
["postgres"]="5432"
["mysql"]="3306"
["redis"]="6379"
["mongodb"]="27017"
["elasticsearch"]="9200"
)
environment=${1:-dev}
base_port_prefix=${2:-1} # 1 for dev, 2 for staging, 3 for prod
echo "🔍 Discovering services in $environment environment..."
for service in "${!services[@]}"; do
service_host="$service-$environment.internal"
service_port=${services[$service]}
local_port="${base_port_prefix}${service_port}"
# Check if service is reachable
if ssh user@$environment-bastion "timeout 2 bash -c 'cat < /dev/null > /dev/tcp/$service_host/$service_port'" 2>/dev/null; then
ssh -f -N -L $local_port:$service_host:$service_port user@$environment-bastion
echo "✅ $service: localhost:$local_port -> $service_host:$service_port"
else
echo "❌ $service: $service_host:$service_port not reachable"
fi
doneTeam Port Allocation:
# Port allocation strategy for development teams
# Team member base ports:
# Developer 1: 10000-10999
# Developer 2: 11000-11999
# Developer 3: 12000-12999
# etc.
DEVELOPER_ID=${DEVELOPER_ID:-1}
BASE_PORT=$((10000 + (DEVELOPER_ID - 1) * 1000))
# Each developer gets their own port range
ssh -f -N \
-L $((BASE_PORT + 432)):postgres-dev.internal:5432 \
-L $((BASE_PORT + 306)):mysql-dev.internal:3306 \
-L $((BASE_PORT + 379)):redis-dev.internal:6379 \
user@dev-bastion
echo "🎯 Your development ports:"
echo "PostgreSQL: localhost:$((BASE_PORT + 432))"
echo "MySQL: localhost:$((BASE_PORT + 306))"
echo "Redis: localhost:$((BASE_PORT + 379))"Port Conflict Resolution:
#!/bin/bash
# smart-tunnel.sh - Automatically find available ports
find_available_port() {
local start_port=$1
local port=$start_port
while netstat -tuln | grep -q ":$port "; do
((port++))
done
echo $port
}
# Smart port allocation
postgres_port=$(find_available_port 15432)
redis_port=$(find_available_port 16379)
api_port=$(find_available_port 18080)
ssh -f -N \
-L $postgres_port:postgres.internal:5432 \
-L $redis_port:redis.internal:6379 \
-L $api_port:api.internal:8080 \
user@bastion
echo "🚀 Tunnels established:"
echo "PostgreSQL: localhost:$postgres_port"
echo "Redis: localhost:$redis_port"
echo "API: localhost:$api_port"
# Save to .env file for your application
cat > .env.tunnels << EOF
DATABASE_URL=postgresql://user:pass@localhost:$postgres_port/database
REDIS_URL=redis://localhost:$redis_port
API_BASE_URL=http://localhost:$api_port
EOFThis guide covers the essentials and advanced patterns for SSH tunneling in development environments. Remember:
- 🔐 Security first: Use key-based authentication and follow the principle of least privilege
- 📝 Document your setup: Share SSH config patterns with your team
- 🔄 Automate repetitive tasks: Use scripts and SSH config for complex setups
- 🐛 Debug systematically: Use the troubleshooting section when things go wrong
- 🚀 Start simple: Begin with basic port forwarding before moving to complex scenarios
Happy SSH tunneling! 🎉