INFRASTRUCTURE · SSH · LITTLE INTERNET ADVENTURES
Localhost goes
on a field trip.
A real domain. A shiny HTTPS connection. An app that’s still sitting on your laptop. Let’s build the tunnel.

You’ve built something cool. You send a friend localhost:3000. They open it. Nothing happens. Congratulations: you’ve invited them to a party inside their own computer.
Let’s give your app an actual front door. We’ll put a VPS on the internet, point a domain at it, and send incoming requests through an SSH tunnel to your laptop. Nginx plays receptionist. Certbot brings the HTTPS certificate. Your laptop does the work, probably next to a suspicious number of coffee cups.
01 / Meet the route
This is a reverse SSH tunnel: your laptop starts the SSH connection, but visitors enter through the VPS. No incoming connection to your home router is needed.
DNS maps the domain to an IP address, not a port. Nginx listens on ports 80 and 443, then forwards requests to the VPS’s private port 9000. That port is the tunnel entrance.
A fresh Ubuntu 24.04 VPS with Nginx available, a sudo-capable SSH user (called deploy below), SSH key access, a domain you control, and Node.js plus pnpm on your laptop. Examples assume a macOS/Linux terminal. Replace demo.example.com and 203.0.113.10 everywhere; both are placeholders.
This walkthrough makes the demo public. Use a harmless app with no secrets or private data. HTTPS encrypts traffic; it does not decide who is allowed to visit.
02 / Create an app with absolutely no ambition
Our dummy project has one job: say hello. Create a folder named tunnel-demo on your laptop and add these two files. No framework committee meeting required.
{
"name": "tunnel-demo",
"private": true,
"scripts": { "dev": "node server.mjs" }
}import { createServer } from 'node:http';
const page = `<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Localhost on tour</title>
<style>
body { margin: 0; min-height: 100vh; display: grid;
place-items: center; background: #101015; color: #f4f4f5;
font: 20px system-ui; text-align: center; }
main { padding: 24px; } h1 { color: #a5b4fc; }
</style>
<main><h1>Localhost has left the building.</h1>
<p>Served from my laptop. Delivered through a tunnel.</p></main>
</html>`;
createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
response.end(page);
}).listen(3000, '127.0.0.1', () => {
console.log('Demo ready at http://127.0.0.1:3000');
});pnpm i
pnpm run devOpen http://127.0.0.1:3000. You should see “Localhost has left the building.” It hasn’t yet, but we respect the confidence. Keep this terminal running. Binding to 127.0.0.1 makes the app available locally; the tunnel will handle the public journey.
03 / Give the VPS a name tag
In your DNS provider, create an A record. Use a DNS-only record for this walkthrough so requests go directly to your VPS.
| Type | Name | Value |
|---|---|---|
| A | demo | Your VPS public IPv4 |
That gives you demo.example.com if your zone is example.com. Only add an AAAA record if IPv6 is configured and reachable on this VPS. A stale AAAA record can send browsers and certificate checks on a very unhelpful detour.
dig +short A demo.example.com
dig +short AAAA demo.example.com
ssh deploy@203.0.113.10Confirm the A result matches your VPS. Verify the SSH host fingerprint against your provider’s console on first connection. Install Nginx on the server:
sudo apt update
sudo apt install nginx
sudo systemctl enable --now nginxIn your provider firewall and any active server firewall, allow inbound TCP 80 and 443. Keep your actual SSH port reachable from your laptop (usually 22). Ports 3000 and 9000 stay private. If UFW is already enabled and SSH is already allowed, sudo ufw allow 'Nginx Full' opens the web ports.
04 / Dig the tunnel. Hard hat optional.
Open a second terminal on your laptop. Run this while your dummy app is still running:
ssh -NT \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-R 127.0.0.1:9000:127.0.0.1:3000 \
deploy@203.0.113.10The first address in -R is the VPS listener; the final address is the destination reached from your laptop. The server listener stays on loopback, so Nginx can reach it without exposing port 9000 publicly. -N skips the remote shell and -T skips a terminal allocation. OpenSSH documents these forwarding options.
A quiet terminal is success, not stage fright. The keepalive options detect a dead connection; they do not reconnect it. ExitOnForwardFailure catches listener setup failure, not an app that stops responding later.
curl http://127.0.0.1:9000You should get the dummy page’s HTML. If forwarding is denied, the VPS SSH policy must permit remote TCP forwarding for your user; check any AllowTcpForwarding, DisableForwarding, PermitListen, or key restrictions with your server administrator. Keep GatewayPorts at its default no for this setup.
05 / Tell the receptionist where to send people
On the VPS, create /etc/nginx/sites-available/tunnel-demo using sudo nano, and paste this configuration. Use a new file and your chosen subdomain so it doesn’t replace another site.
server {
listen 80;
server_name demo.example.com;
location / {
proxy_pass http://127.0.0.1:9000;
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;
}
}sudo ln -s /etc/nginx/sites-available/tunnel-demo /etc/nginx/sites-enabled/tunnel-demo
sudo nginx -t && sudo systemctl reload nginxVisit http://demo.example.com. Your laptop’s page should appear. The proxy_pass directive is the handoff to port 9000; the forwarded headers carry the original request context. See the Nginx proxy documentation for details.
This tiny app uses plain HTTP. Apps that use WebSockets need additional upgrade headers, and some frameworks need trusted-proxy or allowed-host settings. Keep the first experiment simple.

06 / Put HTTPS on the guest list
Now that DNS and HTTP work, install Certbot on the VPS. These commands follow the snap installation route for a fresh server. If you already have Certbot installed through another package manager, follow the official migration instructions before mixing installations.
sudo apt install snapd
sudo snap install --classic certbot
sudo /snap/bin/certbot --nginx -d demo.example.com --redirect
sudo /snap/bin/certbot renew --dry-runProvide your renewal-contact email and accept the terms when prompted. Certbot verifies domain control, installs the certificate in Nginx, and configures the HTTP-to-HTTPS redirect. Port 80 must remain reachable for this HTTP validation flow, including future renewals.
Open https://demo.example.com. The snap includes automated renewal; the dry run checks that renewal works. HTTPS ends at Nginx, while SSH encrypts the VPS-to-laptop leg. The loopback hops use HTTP.
Close the tunnel, stop the app, or let the laptop sleep, and the VPS can no longer serve the demo. This is handy for short previews and experiments. An always-on service needs an always-on host and supervised processes.
07 / If the internet says “nope”
| Symptom | First thing to check |
|---|---|
| 502 Bad Gateway | Try port 3000 on the laptop, then port 9000 on the VPS. The app or SSH connection may have stopped. |
| Remote forwarding failed | Port 9000 may already be occupied, or the SSH account may forbid forwarding. If you change it, update Nginx too. |
| Certificate validation failed | Check A and AAAA records, public port 80, and the Nginx server name. |
| Works until the laptop sleeps | Keep it awake for the demo. Keepalives detect disconnections; restart SSH to reconnect. |
Debug from the inside out: local app → VPS tunnel port → Nginx → public domain. It’s much easier than staring at the browser and negotiating with it.
Done showing off? Press Ctrl+C in the tunnel terminal and the app terminal. That closes the session’s remote listener and stops the local app. DNS, Nginx, and the certificate remain configured; remove the demo’s Nginx site and DNS record separately if you’re retiring it.
FUEL THE NEXT EXPERIMENT
Your app got a tunnel.
My brain could use a coffee.
If this saved you an afternoon of “why is it 502?”, you can help fuel more practical guides like this one.
Buy me a coffee Optional support via Buy Me a CoffeeWritten by Venkatesh Donthula. Illustrations generated with AI. This guide uses example infrastructure; it does not describe a live tunnel on this website.