Vincent Bernat builds self-hosted HTTP tunnels from SSH and nginx

Vincent Bernat starts from a small problem: a friend wants to proofread a work-in-progress blog post, but its preview only runs on localhost:8080. Existing tools split into three groups. Some are commercial services, like ngrok or Cloudflare Quick Tunnels. Some are self-hostable but need a specific client, like frp or localtunnel. Some need only a plain SSH client but rely on a specific SSH server, like sish. His alternative uses only OpenSSH and nginx.
The basic setup has two parts. First, an SSH remote forward: ssh -N -R 0:localhost:8080 web02.luffy.cx. Passing 0 as the remote port makes the server allocate a free one; in the post's example it prints "Allocated port 41535 for remote forward to localhost:8080". Second, nginx proxies https://p41535.ssh.luffy.cx to http://127.0.0.1:41535. A server block matches the host name with a regex (the letter p followed by five digits, then .ssh.luffy.cx), captures the digits as $port and uses it in proxy_pass. This needs a wildcard DNS record, *.ssh.luffy.cx as a CNAME to web02.luffy.cx, plus a wildcard certificate from Let's Encrypt. Bernat adds a CAA record for ssh.luffy.cx and delegates _acme-challenge to a zone on Route 53 (acme.luffy.cx), which he uses for ACME DNS-01 challenges. On his NixOS machine the certificates are obtained automatically.
Access control is the weak point of that basic version. The port is the only secret keeping the content confidential, whereas other forwarding solutions add a random string to the domain name so an intruder cannot enumerate the possible values. Bernat uses ngx_http_secure_link_module to secure the setup "a bit". The module hashes a set of values including a secret and compares the result with the hash in the request. The hash is base64-encoded, so it cannot go in the case-insensitive domain name. Instead it goes in the URL as a username, followed by an expiration timestamp, for example https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/en/blog, where the parts are hash, expiry, port and path. The client sends the username using HTTP basic authentication, which works with most HTTP clients, including curl.
In nginx, the username arrives in $remote_user. A map directive with the regex ^([-_A-Za-z0-9]{22})--([0-9]+)$ splits it into a 22-character hash and the expiry, and joins them with a comma as the module expects. The string to hash is "$secure_link_expires $port" followed by a secret, passed to secure_link_md5. The module sets $secure_link to empty if the hashes do not match, "0" if they match but the link has expired, and "1" otherwise. The full configuration returns 401 with a WWW-Authenticate header when the hash is wrong or missing, and 410 when the link has expired. It also clears the Authorization header before forwarding, sets Host and X-Forwarded-For, adds the directives needed to proxy WebSockets, turns off proxy buffering and sets proxy_read_timeout to 30m.
Generating a hash by hand takes a shell pipeline: compute the expiry as the current time plus 86400 seconds, print "expires port secret" with printf, then pipe through openssl md5 -binary, openssl base64, tr +/ -_ and tr -d =. Bernat concedes this is inconvenient, so he writes a helper script. Its main difficulty is finding the ephemeral port OpenSSH allocated, because it appears in no environment variable. The script walks up the process tree looking for ancestor sshd-session processes, then runs sudo -n ss to list listening TCP ports owned by those processes. It exits with a message if it is not in an SSH session or finds no forwarded port. For each port it computes the token with a 86400-second lifetime, prints the URL, then runs sleep infinity to keep the session open.
On the server he installs the script as http-over-ssh and adds an SSH config entry: Host http-over-ssh, Hostname web02.luffy.cx, RemoteCommand http-over-ssh, ControlPath none. After that, ssh -R 0:localhost:8080 http-over-ssh prints the allocated port and a shareable URL. Bernat's summary is that he relies only on OpenSSH and nginx, already running on the server, and gets a self-hosted tunnel and a URL to share with one short command. He links a complete helper script with a few minor improvements, and a http-over-ssh.nix for NixOS users.
Key facts
- The tunnel uses only OpenSSH remote forwarding (ssh -R 0:localhost:8080, where 0 asks the server for a free port) and nginx, with no extra tunnel software.
- nginx maps the wildcard host p
.ssh.luffy.cx to 127.0.0.1: ; this needs a *.ssh.luffy.cx DNS record and a Let's Encrypt wildcard certificate obtained with ACME DNS-01. - ngx_http_secure_link_module adds an expiring access link: a 22-character hash and an expiry timestamp go in the URL as a basic-auth username. nginx returns 401 for a bad or missing hash and 410 for an expired link.
- A helper script finds the allocated port by tracing ancestor sshd-session processes and running sudo -n ss, then prints a URL valid for 86400 seconds.
- Bernat compares it with ngrok, Cloudflare Quick Tunnels, frp, localtunnel and sish, and links the full helper script and a NixOS module.
Why it matters
Sharing a local web service with someone else usually means signing up for a commercial tunnel or installing a specific client or server. Bernat shows that a server already running OpenSSH and nginx can do the job with a few lines of configuration and one short script. The access link also carries an expiry, which the basic port-only version lacks.
Who it affects
Developers and writers who run a preview server on localhost and want a colleague or friend to see it, and who already have a server with OpenSSH and nginx. NixOS users get a ready-made http-over-ssh.nix. The post does not address AI or machine learning workloads.
How to use it
Forward a port with ssh -R 0:localhost:8080 to your server, and have nginx proxy a wildcard host name such as p
How solid is it
The post is a first-person walkthrough from the person who runs the setup on his own server, with the nginx configuration and scripts shown in full. It reports no performance, latency or throughput measurements, and no cost or feature comparison with other tools beyond sorting them into three categories. The secret in the examples and the port 41535 are illustrative.
Risks and caveats
Bernat himself says the module secures the setup only "a bit". The module in this setup computes an MD5 hash, and the post gives no security audit or analysis of the scheme's weaknesses. Without the secure link, the port is the only secret. The nginx regex accepts exactly five-digit ports. The helper script depends on sudo -n ss and on finding sshd-session processes, so it works only inside an SSH session with a forwarded port. The example secret must be replaced with your own.
“With this solution, I only rely on OpenSSH and nginx, two pieces of software already running on this server.”
— Vincent Bernat