Poor Man's ngrok: Build Your Own Tunnel with SSH
You’re testing webhooks locally. Stripe needs to hit your endpoint. GitHub needs to trigger your CI. The problem? They can’t reach localhost.
ngrok is the standard solution, and it’s great. But the free tier gives you random URLs that change every restart. Updating webhook configs constantly gets old. The paid tier has persistent subdomains, but feels like overkill for occasional testing. Plus bandwidth limits, rate limits, and for some teams, security concerns about routing traffic through third parties.
I had a $5 DigitalOcean droplet sitting idle and realized: this is just SSH reverse tunneling. Here’s how to set it up.
What You Need:
- A VPS with public IP ($5/month DigitalOcean, Linode, whatever)
- NGINX installed
- A domain pointing to your server
- 5 minutes
The SSH Tunnel
Simple, one command:
ssh -R 7070:localhost:3000 -N your-server
This creates a reverse tunnel. Your server’s port 7070 now forwards to your local port 3000. The -N flag means “just tunnel, don’t run commands.” That’s it.
I generally set up an alias or npm command like npm run tunnel in each project with its own port configured. Makes it universal across projects without remembering which port maps to which.
DNS Configuration
Point your domain to your server. You have two options:
Wildcard CNAME - Set up *.dev.yourdomain.com once, all subdomains automatically work. Easy but points all traffic to your box.
Manual A records - Add each subdomain individually (dev.yourdomain.com, dev2.yourdomain.com, etc). More work but I prefer this. Don’t want wildcard DNS routing everything to my server.
NGINX Configuration
Create a basic config in /etc/nginx/sites-available/dev.yourdomain.com:
server {
listen 80;
server_name dev.yourdomain.com;
location / {
proxy_pass http://localhost:7070;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
Enable it:
sudo ln -s /etc/nginx/sites-available/dev.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
SSL Setup
Now add SSL with certbot:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d dev.yourdomain.com
Certbot modifies your NGINX config, adds SSL certificates, sets up HTTPS redirects, and configures automatic renewal. Certificates renew every 60 days automatically. Your final config looks like this:
server {
server_name dev.yourdomain.com;
location / {
proxy_pass http://localhost:7070;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/dev.yourdomain.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/dev.yourdomain.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = dev.yourdomain.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name dev.yourdomain.com;
return 404; # managed by Certbot
}
All those # managed by Certbot lines were added automatically. Now https://dev.yourdomain.com hits your local port 3000 with valid SSL.
Why This Works
Perfect for:
- Testing OAuth flows: Stable callback URLs that don’t change between restarts
- Webhook testing: Stripe payments, GitHub webhooks, Twilio callbacks
- Shopify apps: Set the URL once in Partners dashboard, never update it
- Mobile app testing: Point your app at a stable endpoint
- Client demos:
https://demo.yourdomain.comlooks better than random ngrok URLs
Benefits:
- No rate limits. Trigger 500 test webhooks if you need to.
- Stable URL.
https://dev.yourdomain.comdoesn’t change. - No disconnects. Tunnels run for days without timeouts.
- Multiple environments. Different ports = different subdomains:
ssh -R 7070:localhost:3000 -N your-server # main
ssh -R 7071:localhost:3001 -N your-server # staging
ssh -R 7072:localhost:3002 -N your-server # experimental
Trade-offs:
- You need a server ($5/month vs ngrok free tier - though if you already have a box, this is free)
- Initial setup takes 5 minutes (vs 2 seconds for ngrok)
- Manual reconnection if SSH drops (use
autosshfor auto-reconnect)
Make It Easy
Add an alias:
# ~/.bashrc or ~/.zshrc
alias tunnel='ssh -R 7070:localhost:3000 -N yourserver'
Now it’s just tunnel and you’re live.
Better yet, use ~/.ssh/config:
Host tunnel
HostName your-server.com
User youruser
RemoteForward 7070 localhost:3000
ServerAliveInterval 60
ServerAliveCountMax 3
Run ssh -N tunnel. The ServerAlive settings prevent timeouts.
That’s It
SSH reverse tunneling + NGINX + Let’s Encrypt = stable development tunnel. No rate limits, no random URLs, no subscriptions.
ngrok is still the best-in-class, scalable solution if you consistently need tunnels and don’t mind paying for it. But for extended webhook testing or when you need stable URLs without ongoing costs, this setup wins. Set it up once, forget about it.
Related Posts
- 2 min readMake Vercel open source and self-hosted, you get Coolify
- 2 min readMonitoring your microservice stack with simple ping health checks using Healthchecks.io for free
- 6 min readSimple Gitlab CI/CD Deployment via SSH+RSYNC
- 8 min readCrafting Error Messages That Actually Help Users
- 3 min readUsing gitlab.com as your background workers using CI schedules
- 11 min readTaming Claude Code
Share