Exposing Local Services Securely with Rathole: SSH, Ollama, and MCP Behind NAT
Your home server runs Ollama with a 4090, but you’re on a laptop at a coffee shop. Your lab SSH server sits behind NAT with no public IP. You want remote access, but port forwarding exposes services to the internet, ngrok routes traffic through third parties who can inspect it, and WireGuard requires client-side configuration everywhere you want access.
Rathole provides a self-hosted alternative: a Rust-based reverse tunnel that multiplexes multiple services over a single WebSocket connection to your VPS. Unlike SSH’s -R tunnels, it handles reconnection automatically, supports per-service token authentication, and hides behind nginx’s TLS termination—scanners see a JSON API, legitimate clients get a tunnel.
This guide walks through the complete setup: nginx configuration for TLS and WebSocket proxying, rathole server and client configuration, systemd integration for automatic reconnection, and security hardening including token rotation, fail2ban for SSH protection, and GeoIP blocking. You’ll expose SSH, Ollama, and MCP servers securely without cloud dependency.
Problem Statement
Services running on machines behind NAT often require remote access:
- SSH to home desktops or lab servers
- Ollama API for remote LLM inference
- MCP servers for Claude Code integrations
- Development servers requiring access from any location
Traditional solutions present various limitations:
| Approach | Limitation |
|---|---|
| Port forwarding | Requires router access, static IP, direct port exposure |
| ngrok/Cloudflare Tunnel | Third-party dependency, traffic inspection concerns |
| WireGuard VPN | Both ends require configuration, excessive for single services |
| SSH reverse tunnel | Fragile, requires keepalive configuration, one tunnel per service |
Rathole addresses these limitations: a fast, secure, Rust-based tunnel that multiplexes services over a single WebSocket connection with per-service authentication.
Architecture
graph LR
Laptop["Your Laptop<br/>ssh -p 2222<br/>curl localhost:11434/api/..."]
Nginx["nginx :443<br/>TLS Termination"]
RatServer["rathole server<br/>:8443"]
RatClient["rathole client"]
SSH["sshd :22"]
Ollama["ollama :11434"]
MCP["mcp :3000"]
HomeMachine["Home Machine<br/>Behind NAT"]
Laptop -->|TLS| Nginx
Nginx -->|WebSocket| RatServer
RatServer -->|Maps :2222| RatClient
RatServer -->|Maps :11434| RatClient
RatServer -->|Maps :3000| RatClient
RatClient --> SSH
RatClient --> Ollama
RatClient --> MCP
SSH -.-> HomeMachine
Ollama -.-> HomeMachine
MCP -.-> HomeMachine
Data flow:
- Client connects to
localhost:2222(SSH) orlocalhost:11434(Ollama) - Traffic routes to VPS nginx over TLS
- nginx proxies WebSocket to rathole server
- rathole forwards to the appropriate client tunnel
- Home machine receives traffic on local service
Components
1. VPS Server
A small VPS (1 vCPU, 512MB RAM sufficient) running:
- nginx: TLS termination, WebSocket proxying, decoy responses
- rathole server: Accepts client connections, multiplexes services
- certbot: Let’s Encrypt certificates
2. Home Machine (Client)
- rathole client: Maintains persistent connection to server
- Local services: SSH, Ollama, MCP servers, etc.
Server Setup
Install Dependencies
1
2
3
4
5
6
7
8
# Arch Linux
pacman -S nginx certbot certbot-nginx
# Download rathole
curl -LO https://github.com/rapiz1/rathole/releases/download/v0.5.0/rathole-x86_64-unknown-linux-gnu.zip
unzip rathole-*.zip
mv rathole /usr/local/bin/
chmod +x /usr/local/bin/rathole
Rathole Server Configuration
Create /etc/rathole/server.toml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[server]
bind_addr = "127.0.0.1:8443"
[server.transport]
type = "websocket"
[server.transport.websocket]
tls = false # TLS handled by nginx
# SSH tunnel
[server.services.ssh]
token = "generate-a-strong-random-token-here"
bind_addr = "127.0.0.1:2222"
# Ollama API tunnel
[server.services.ollama]
token = "another-strong-random-token"
bind_addr = "127.0.0.1:11434"
# MCP server tunnel
[server.services.mcp]
token = "yet-another-strong-token"
bind_addr = "127.0.0.1:3000"
Generate secure tokens:
1
2
# Generate random tokens
openssl rand -hex 32 # For each service
Nginx Configuration
Create /etc/nginx/nginx.conf:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
worker_processes auto;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 1024;
}
http {
# Detect WebSocket upgrades
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
map $http_upgrade $is_websocket {
default 0;
~*^websocket$ 1;
}
upstream rathole {
server 127.0.0.1:8443;
}
# HTTP -> HTTPS redirect
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
# Main HTTPS server
server {
listen 443 ssl;
server_name your-domain.com;
# TLS (certbot will fill these in)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Security headers
add_header Strict-Transport-Security "max-age=31536000" always;
server_tokens off;
# Root: serve decoy for non-WebSocket, proxy WebSocket to rathole
location = / {
# Non-WebSocket requests get a decoy response
if ($is_websocket = 0) {
add_header Content-Type application/json;
return 200 '{"status":"ok","version":"1.0.0"}';
}
# WebSocket requests go to rathole
proxy_pass http://rathole;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# Health check
location /health {
return 200 '{"status":"healthy"}';
add_header Content-Type application/json;
}
}
}
Systemd Service for Server
Create /etc/systemd/system/rathole-server.service:
1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
Description=Rathole Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/rathole /etc/rathole/server.toml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Obtain TLS Certificate
1
certbot --nginx -d your-domain.com
Start Services
1
systemctl enable --now rathole-server nginx
Client Setup
Rathole Client Configuration
Create /etc/rathole/client.toml on the home machine:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[client]
remote_addr = "wss://your-domain.com/"
[client.transport]
type = "websocket"
# SSH - expose local SSH
[client.services.ssh]
token = "same-token-as-server-ssh"
local_addr = "127.0.0.1:22"
# Ollama - expose local Ollama API
[client.services.ollama]
token = "same-token-as-server-ollama"
local_addr = "127.0.0.1:11434"
# MCP - expose local MCP server
[client.services.mcp]
token = "same-token-as-server-mcp"
local_addr = "127.0.0.1:3000"
Systemd Service for Client
Create /etc/systemd/system/rathole-client.service:
1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
Description=Rathole Client
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/rathole /etc/rathole/client.toml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Start the service:
1
systemctl enable --now rathole-client
Accessing Services
Once connected, home services are accessible through the VPS:
SSH
1
2
3
4
5
# From anywhere
ssh -p 2222 your-user@your-domain.com
# Or via localhost if another tunnel/VPN to the VPS exists
ssh -p 2222 your-user@localhost
Ollama API
1
2
3
4
5
# Direct API call
curl https://your-domain.com:11434/api/tags
# Or configure MCP/Claude Code to use the tunnel
# In MCP config, point to localhost:11434 (if port-forwarded)
SSH Port Forwarding for Local Access
To access services on localhost:
1
2
3
4
5
# Create local forwards through the VPS
ssh -L 11434:localhost:11434 -L 3000:localhost:3000 user@your-domain.com
# Now localhost:11434 reaches home Ollama
curl localhost:11434/api/tags
Security Hardening
Token Rotation
Rotate tokens periodically without downtime:
Server-side rotation script (/usr/local/bin/rotate-rathole-tokens.sh):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
set -euo pipefail
CONFIG="/etc/rathole/server.toml"
PENDING="/etc/rathole/pending-tokens.json"
# Generate new tokens
new_ssh=$(openssl rand -hex 32)
new_ollama=$(openssl rand -hex 32)
# Save pending tokens for clients to fetch
cat > "$PENDING" << EOF
{
"ssh": "$new_ssh",
"ollama": "$new_ollama",
"valid_until": "$(date -u -d '+1 hour' +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
chmod 600 "$PENDING"
echo "New tokens staged in $PENDING"
echo "Clients should sync within 5 minutes."
echo "Run 'finalize-rathole-rotation.sh' after clients update."
Client-side sync (runs via systemd timer):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
# Fetch new tokens from server API and update local config
SERVER_URL="https://your-domain.com/api/v1/config"
API_KEY=$(cat /etc/rathole/rotation.key)
CLIENT_CONF="/etc/rathole/client.toml"
# Fetch pending tokens
response=$(curl -s -H "X-Api-Key: $API_KEY" "$SERVER_URL")
if [[ -z "$response" ]]; then
exit 0 # No pending rotation
fi
# Parse and update tokens in config
new_ssh=$(echo "$response" | jq -r '.ssh // empty')
if [[ -n "$new_ssh" ]]; then
sed -i "s/^token = .*/token = \"$new_ssh\"/" "$CLIENT_CONF"
systemctl restart rathole-client
fi
Fail2ban Integration
Block brute-force attempts on tunneled SSH:
1
2
3
4
5
6
7
8
# /etc/fail2ban/jail.d/rathole-ssh.conf
[rathole-ssh]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
Geo-blocking
Restrict connections to specific countries:
1
2
3
4
5
6
7
8
9
# Using ipset + iptables
ipset create allowed_countries hash:net
# Add country IP ranges
curl -s https://www.ipdeny.com/ipblocks/data/countries/us.zone | \
while read cidr; do ipset add allowed_countries $cidr; done
# Block non-matching IPs
iptables -A INPUT -p tcp --dport 443 -m set ! --match-set allowed_countries src -j DROP
MCP Integration
Claude Code with Remote Ollama
Configure Claude Code to use the tunneled Ollama:
1
2
3
4
5
6
7
8
9
10
11
{
"mcpServers": {
"ollama": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-ollama"],
"env": {
"OLLAMA_HOST": "http://localhost:11434"
}
}
}
}
Then SSH with port forwarding:
1
ssh -L 11434:localhost:11434 user@your-domain.com
This configuration enables Claude Code to use home GPU resources for Ollama inference.
Remote MCP Servers
Run MCP servers on the home machine and access them through the tunnel:
1
2
3
4
5
# Home machine: Start MCP server
npx @anthropic/mcp-filesystem --root /path/to/files --port 3000
# Laptop: Forward the port
ssh -L 3000:localhost:3000 user@your-domain.com
Monitoring
Status Script
Check active tunnel connections:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash
echo "=== Rathole Tunnel Status ==="
for svc in "ssh:2222" "ollama:11434" "mcp:3000"; do
name=${svc%%:*}
port=${svc##*:}
count=$(ss -tn sport = :$port 2>/dev/null | grep -c ESTAB || echo 0)
if [[ $count -gt 0 ]]; then
echo " $name (:$port): $count active connections"
else
echo " $name (:$port): idle"
fi
done
Systemd Journal
1
2
3
4
5
# Server logs
journalctl -u rathole-server -f
# Client logs
journalctl -u rathole-client -f
Comparison with Alternatives
| Feature | Rathole | ngrok | Cloudflare Tunnel | SSH -R |
|---|---|---|---|---|
| Self-hosted | Yes | No | No | Yes |
| Multiple services | Yes | Limited free | Yes | Manual |
| WebSocket transport | Yes | Yes | Yes | No |
| Per-service auth | Yes | No | Yes | No |
| Resource usage | Minimal | N/A | N/A | Minimal |
| TLS termination | Flexible | Forced | Forced | N/A |
| Open source | Yes | No | No | Yes |
Troubleshooting
Client Connection Failure
1
2
3
4
5
6
# Check client logs
journalctl -u rathole-client -n 50
# Verify WebSocket connectivity
curl -v -H "Upgrade: websocket" -H "Connection: upgrade" \
https://your-domain.com/
Inaccessible Services
1
2
3
4
5
# Check server is listening
ss -tlnp | grep rathole
# Verify service binding
ss -tlnp | grep 2222 # Should show rathole
Token Mismatch
1
2
3
4
# Server and client tokens MUST match exactly
# Check for trailing whitespace or newlines
cat /etc/rathole/server.toml | grep token
cat /etc/rathole/client.toml | grep token
Conclusion
Rathole provides a robust, self-hosted solution for exposing services behind NAT:
- Single connection: All services multiplex over one WebSocket
- Per-service tokens: Granular authentication
- TLS via nginx: Industry-standard security
- Low overhead: Written in Rust, minimal resource usage
- Decoy responses: Non-WebSocket requests receive innocuous JSON
This setup is suitable for:
- Remote access to home lab services
- Running MCP servers on powerful home hardware
- Accessing Ollama from any location
- SSH access without exposing port 22 directly to the internet
The key design principle: nginx handles TLS and presents a normal-looking API endpoint externally, while WebSocket connections pass through to rathole. Scanners observe a JSON API; legitimate clients access a tunnel.