Local HTTP Tunnel with Node.js

Expose your localhost to the internet using a free‑tier tunnel service

Welcome to another official UVF IT build guide. In the next 45 minutes you'll spin up a tiny Node.js app and connect it to a public tunnel so anyone on the web can hit your local endpoint, just like Portr does.

We'll use the open‑source localtunnel package as our cloud relay – no paid accounts, no API keys. By the end you’ll have a working clone you can extend with custom routes or authentication.

Before you start

Minimal Tunnel Architecture
LOCAL ENVIRONMENT CLOUD RELAY HTTP over tunnel Public request Executes Node.js Express App localtunnel.me Relay Remote Browser / curl Node Runtime
1

Phase 1: Scaffold the project

Create a fresh folder for your clone, initialise it as an npm project, and pull in the two packages we need: Express for the web server and localtunnel for the relay. This gives us a clean starting point and guarantees all dependencies are recorded in package.json.

Running the commands below will also generate a lockfile so future installs are reproducible.

mkdir portr-clone && cd portr-clone
npm init -y
npm install express localtunnel
2

Phase 2: Core feature – simple HTTP endpoint

Now we write the tiny Express app that will be exposed through the tunnel. It listens on a local port, defines a single GET route at /status that returns a JSON payload, and finally starts the server. Keeping the code minimal makes it easy to see exactly what will be reachable from the internet once the tunnel is active.

index.js

const express = require('express')
const app = express()
const PORT = process.env.PORT || 3000

app.get('/status', (req, res) => {
  res.json({ service: 'portr-clone', status: 'online' })
})

app.listen(PORT, () => {
  console.log(`🚀 App listening on http://localhost:${PORT}`)
})
3

Phase 3: Core feature – start the tunnel automatically

Now that the Express server is up, we want a public address without manual steps. By invoking localtunnel from Node we can spin up a tunnel as soon as the app begins listening, capture the URL, and print it for the developer. This keeps the workflow one‑command and mirrors how the original Portr CLI works.

tunnel.js

const localtunnel = require('localtunnel');

// Called after the HTTP server starts listening.
async function startTunnel(port) {
  try {
    const tunnel = await localtunnel({ port });
    console.log('Public URL:', tunnel.url);
    // Keep the tunnel alive for the lifetime of the process.
    tunnel.on('close', () => console.log('Tunnel closed'));
  } catch (err) {
    console.error('Failed to start tunnel:', err);
  }
}

module.exports = { startTunnel };
4

Phase 4: Run locally and verify

We need a single command that launches both the server and the tunnel script. npm-run-all (or the built‑in concurrently package) lets us start multiple Node processes in parallel, forwarding their stdout so we can see the public URL immediately. Once both are running, a quick curl or browser hit proves the tunnel works.

# Install the helper if you haven't already
npm install --save-dev concurrently

# Add a script to package.json (you can paste this into the "scripts" section)
# "start": "concurrently \"node index.js\" \"node tunnel.js\""

# Then run the combined command
npm run start
5

Phase 5: Extend – add a second route (optional)

Adding more endpoints is just a matter of defining another Express handler; the tunnel code stays untouched because it only cares about the port. Below we add a /time route that returns the current server timestamp in JSON. This demonstrates how easy it is to grow the API without re‑engineering the tunneling logic.

index.js

const express = require('express');
const { startTunnel } = require('./tunnel');

const app = express();
const PORT = 3000;

// Existing route
app.get('/hello', (req, res) => {
  res.json({ message: 'Hello from your local tunnel!' });
});

// New optional route – returns server time
app.get('/time', (req, res) => {
  res.json({ time: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`App listening on http://localhost:${PORT}`);
  // Kick off the tunnel as soon as the server is ready
  startTunnel(PORT);
});

Verifying the Stack

CheckCommand / Why it matters
Server starts without errorsTerminal shows App listening on http://localhost:3000 after running the concurrent command.
Public URL printedTerminal displays Public URL: https://<random>.loca.lt from tunnel.js.
Endpoint reachable from the internetRun curl https://<random>.loca.lt/hello and receive the JSON response.
Second route works (if added)Visit https://<random>.loca.lt/time in a browser and see the timestamp JSON.

What’s next?

You now have a functional HTTP tunnel clone. Experiment by adding authentication middleware, HTTPS redirection, or swapping localtunnel for another free relay like ngrok (free tier) to explore different features.