Horizon

Expose a local Express app

Expose a local Express app through a Horizon tunnel, and make req.hostname and req.protocol use the public URL with trust proxy.

Reach your Express app from outside your laptop, with an HTTPS URL, and make req.hostname return the public host.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • npm install express

Start your app

This app trusts the loopback address, then returns what Express reads from the request. The Horizon CLI runs on your machine and connects to your app from there, so loopback is the proxy to trust.

server.js
const express = require("express");

const app = express();
app.set("trust proxy", "loopback");

app.get("/", (req, res) => {
  res.json({
    hostname: req.hostname,
    protocol: req.protocol,
    host: req.get("host"),
    forwardedHost: req.get("x-forwarded-host"),
    forwardedProto: req.get("x-forwarded-proto"),
  });
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));
node server.js

Start a tunnel

Use -s with a subdomain you reserved. Reserved subdomains are a paid feature, see Pricing.

hrzn tunnel http://localhost:3000 -s my-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Keep this terminal open.

Call the public URL

curl https://my-app.hrzn.run/

Check it works

The Horizon terminal prints GET 200 /. The JSON shows which headers reached Express. host is localhost:3000, because the Horizon CLI sets Host to the address you tunnel to. forwardedHost is my-app.hrzn.run. With trust proxy set, hostname is the value of X-Forwarded-Host.

Without the app.set line, req.hostname is localhost. Express reads X-Forwarded-Host only when trust proxy allows it.

Choose a trust proxy value

Express's default is false: it treats the app as facing the client directly. Pick the narrowest value that fits.

ValueMeaning
"loopback"Trust 127.0.0.1/8 and ::1/128. Use this for the Horizon CLI on your machine.
1Trust the address one hop away.
trueTrust the left-most X-Forwarded-For entry. Use it only if the last proxy overwrites the forwarded headers.

Express warns that with true, the last trusted proxy must remove or overwrite X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto. Otherwise a client can send any value.

Build absolute URLs from the public host

Your app sees Host: localhost:3000. An OAuth redirect URI built from Host points at localhost. Build it from req.protocol and req.hostname after you set trust proxy, or set the public URL in an environment variable.

server.js
app.get("/login", (req, res) => {
  const redirectUri = `${req.protocol}://${req.hostname}/auth/callback`;
  res.json({ redirectUri });
});

req.protocol reads X-Forwarded-Proto when trust proxy allows it. Check the forwardedProto value in the JSON above. If it is null, req.protocol is http, so hard-code https for tunnel URLs or read the public URL from an environment variable.

Troubleshooting

Error: listen EADDRINUSE: address already in use :::3000

Another process holds port 3000. Stop it, or change the port in app.listen and in the tunnel command.

req.hostname returns localhost

You didn't set trust proxy, or the request doesn't come from an address it trusts. Set app.set("trust proxy", "loopback") and check forwardedHost in the JSON.

Nothing reaches your app

Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app.

Next steps

On this page