Tech & InnovationOct 5, 2026 (28 days ago)

Self-hosted HTTP tunnels with SSH and Nginx

Self-hosted HTTP tunnels with SSH and nginx Vincent Bernat A friend wants to proofread your work-in-progress blog post, but its preview only runs on localhost:8080. Several tools can help. Some run as a commercial service, like ngrok or Cloudflare Qu...

AI
Vincent
NewsFlow Autonomous Newsroom Pipeline
r/
Self-hosted HTTP tunnels with SSH and Nginx
NewsFlow Editorial Imagery

Vincent Bernat

A friend wants to proofread your work-in-progress blog post, but its preview

only runs on localhost:8080. Several tools can help. Some run as a

commercial service, like ngrok or Cloudflare Quick Tunnels. Some are

self-hostable but require a specific client, like frp or localtunnel.

Some only require a plain SSH client but rely on a specific SSH server, like

sish. Let's implement a self-hosted solution with only OpenSSH and

nginx!

$ ssh -R 0:localhost:8080 http-over-ssh

Allocated port 41535 for remote forward to localhost:8080

https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/

Basic setup#

First, we forward connections from a port on a remote server to your local service:

$ ssh -N -R 0:localhost:8080 web02.luffy.cx

Allocated port 41535 for remote forward to localhost:8080

When you specify 0 as the remote port, the server allocates a free port.

Then, we configure nginx to proxy requests from https://p41535.ssh.luffy.cx to

http://127.0.0.1:41535:

server {

listen 0.0.0.0:443 ssl ;

listen [::0]:443 ssl ;

server_name ~^p(?\d\d\d\d\d).ssh.luffy.cx$;

location / {

proxy_pass http://127.0.0.1:$port;

}

}

We also need to add DNS records for *.ssh.luffy.cx and get a wildcard

certificate through Let's Encrypt:

*.ssh.luffy.cx. CNAME web02.luffy.cx.

ssh.luffy.cx. CAA 0 issuewild "letsencrypt.org"

_acme-challenge.ssh.luffy.cx CNAME ssh.luffy.cx.acme.luffy.cx.

acme.luffy.cx is a zone hosted on Route 53. I use it for ACME DNS-01

challenges, both for wildcard certificates and for domains served by

several web servers. In my case, NixOS gets the certificates

automatically.

Access control#

The port is the only "secret"1 keeping the content confidential. Other forwarding solutions add a random string to the domain name to prevent an intruder from enumerating the possible values.

Thanks to ngx_http_secure_link_module, we can secure

this setup a bit. This module computes a hash2 over a set of values,

including a secret, and compares it with the hash from the request. The hash is

base64-encoded, so we cannot put it in the domain name, which is

case-insensitive. Instead, we put it in the URL as a username, along with its

expiration timestamp:3

https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/en/blog

╰─────────┬──────────╯ ╰───┬────╯ ╰─┬─╯ ╰──┬───╯

hash expires port path

The client sends the username to the server with HTTP basic

authentication. This works with most HTTP clients, including curl.

Nginx exposes the username in the $remote_user variable. The module expects

the hash and the expiration timestamp separated by a comma. We use a map

directive to extract the two parts from $remote_user and join them with a

comma.4 We also give the module the string to hash. It contains the

expiration timestamp, the port, and a secret:

map $remote_user $httpssh_link {

"~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";

}

server {

[…]

location / {

secure_link $httpssh_link;

secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";

}

}

The module returns the status of the check in the $secure_link variable:

  • empty if the hashes do not match,

  • "0" if they match but the link has expired, or

  • "1" otherwise.

If the hash is incorrect or missing, we return a 401 error with a

WWW-Authenticate header to ask for credentials. If the link has expired, we

return a 410 error. We remove the Authorization header before forwarding the

request and add a few directives to proxy WebSocket connections.

Here is the complete configuration:5

map $remote_user $httpssh_link {

"~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";

}

server {

listen 0.0.0.0:443 ssl ;

listen [::0]:443 ssl ;

server_name ~^p(?\d\d\d\d\d).ssh.luffy.cx$;

location / {

secure_link $httpssh_link;

secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";

if ($secure_link = "") {

add_header WWW-Authenticate 'Basic realm="tunnel"' always;

return 401;

}

if ($secure_link = "0") {

return 410;

}

proxy_pass http://127.0.0.1:$port;

proxy_set_header Host $host;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_set_header Authorization "";

proxy_http_version 1.1;

proxy_set_header Upgrade $http_upgrade;

proxy_set_header Connection "upgrade";

proxy_buffering off;

proxy_read_timeout 30m;

}

}

I think you are now asking yourself the obvious question: "How should I generate the hash?" Easy peasy!

$ expires=$(( $(date +%s) + 86400 ))

$ port=41535

$ secret='ZuPerS3cr3!'

$ printf '%s %s %s' "$expires" "$port" "$secret" \

| openssl md5 -binary \

| openssl base64 \

| tr +/ -_ | tr -d =

6J3jK1WmB15c6WmjW_X-Wg

Well, I suppose you are now saying: "Vincent, this is not very convenient! I'll stick with ngrok if you don't mind." Okay, I hear you. Let's write a helper script.

Helper script#

The main difficulty is finding the ephemeral port that OpenSSH allocates, as it

does not appear in any environment variable.6 To work around this obstacle,

we look for the ancestor sshd-session processes:7

pids=$(

pid=$$

while [ "$pid" -gt 1 ]; do

line=$(ps -o comm=,pid=,ppid= -p "$pid")

echo "$line"

pid=${line##* }

done | awk '$1 == "sshd-session" { printf "pid=%s,\n", $2 }'

)

if [ -z "$pids" ]; then

echo "not an ssh session" >&2

exit 1

fi

Then, we get the listening ports associated with these sshd-session

processes:8

ports=$(sudo -n ss --listening --numeric --tcp --processes --no-header \

| grep -F "$pids" \

| awk '{ print $4 }' | awk -F: '{ print $NF }' \

| sort -un)

if [ -z "$ports" ]; then

echo "no forwarded port, use ssh -R 0:localhost:PORT" >&2

exit 1

fi

Finally, we display the URLs and keep the session open:

lifetime=86400

secret='ZuPerS3cr3!'

expires=$(( $(date +%s) + lifetime ))

for port in $ports; do

token=$(printf '%s %s %s' "$expires" "$port" "$secret" \

| openssl md5 -binary \

| openssl base64 \

| tr +/ -_ | tr -d =)

echo "https://$token--$expires@p$port.ssh.luffy.cx/"

done

sleep infinity

I install this script as http-over-ssh on the server and add this entry to my

~/.ssh/config:

Host http-over-ssh

Hostname web02.luffy.cx

RemoteCommand http-over-ssh

ControlPath none

With this solution, I only rely on OpenSSH and nginx, two pieces of software

already running on this server. One short command gives me a self-hosted tunnel

and a URL to share. To try it, grab the complete helper script, which

includes a few minor improvements. If you run NixOS, as any person of taste

would, have a look at my http-over-ssh.nix instead. ❄️

Originally synthesized and fact-checked from primary reporting source.View Primary Source Report
Share this story
r/

More in Tech & Innovation

View all →