---
title: "Self-hosted HTTP tunnels with SSH and nginx"
slug: self-hosted-http-tunnels-with-ssh-and-nginx
url: https://listedarticles.com/articles/self-hosted-http-tunnels-with-ssh-and-nginx
canonical_url: https://vincent.bernat.ch/en/blog/2026-http-over-ssh
content_type: tutorial
language: en
published_at: 2026-10-03T00:00:00.000Z
updated_at: 2026-10-04T23:17:18.105Z
author: "Vincent Bernat"
author_url: https://vincent.bernat.ch/
authored_by: human
publisher: "vincent.bernat.ch"
publisher_url: https://vincent.bernat.ch/
topics: ["Networking", "Linux", "DevOps", "Tutorials", "Infrastructure"]
license: all-rights-reserved
word_count: 998
reading_minutes: 4
citation: "Vincent Bernat, vincent.bernat.ch. \"Self-hosted HTTP tunnels with SSH and nginx.\" 3 Oct 2026. https://vincent.bernat.ch/en/blog/2026-http-over-ssh (all-rights-reserved)"
# The full text follows. The web page shows an extract and sends readers
# to the source above; quote the citation and link the canonical URL.
---

# Self-hosted HTTP tunnels with SSH and nginx

> Vincent Bernat shows how to expose a localhost preview to a friend with only a plain SSH client and an nginx server you control: ssh -R with a dynamic port, an nginx server_name regex mapping per-port subdomains, wildcard TLS via ACME DNS-01, and a helper script plus NixOS module.

A friend wants to proofread your work-in-progress blog post, but its preview
only runs on `localhost:8080`. [Several tools](https://github.com/anderspitman/awesome-tunneling) can help. Some run as a
commercial service, like [ngrok](https://ngrok.com/) or [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/). Some are
self-hostable but require a specific client, like [frp](https://github.com/fatedier/frp) or [localtunnel](https://github.com/localtunnel/localtunnel).
Some only require a plain SSH client but rely on a specific SSH server, like
[sish](https://docs.ssi.sh/). 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[#](https://vincent.bernat.ch#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(?<port>\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](https://letsencrypt.org/):

```
*.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](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge), both for wildcard certificates and for domains served by
several web servers. In my case, NixOS [gets the certificates
automatically](https://wiki.nixos.org/wiki/ACME).

# Access control[#](https://vincent.bernat.ch#access-control)

The port is the only “secret”<sup>[1](https://vincent.bernat.ch#sidenote-port)</sup> 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`](https://nginx.org/en/docs/http/ngx_http_secure_link_module.html), we can secure
this setup a bit. This module computes a hash<sup>[2](https://vincent.bernat.ch#sidenote-md5)</sup> 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://vincent.bernat.ch#sidenote-expiration)

```
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](https://www.rfc-editor.org/rfc/rfc7617). 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.<sup>[4](https://vincent.bernat.ch#sidenote-comma)</sup> 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](https://nginx.org/en/docs/http/websocket.html).
Here is the complete configuration:[5](https://vincent.bernat.ch#sidenote-security)

```
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(?<port>\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[#](https://vincent.bernat.ch#helper-script)

The main difficulty is finding the ephemeral port that OpenSSH allocates, as it
does not appear in any environment variable.<sup>[6](https://vincent.bernat.ch#sidenote-env)</sup> To work around this obstacle,
we look for the ancestor `sshd-session` processes:[7](https://vincent.bernat.ch#sidenote-sshd-session)

```
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](https://vincent.bernat.ch#sidenote-sudo)

```
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](https://github.com/vincentbernat/nixops-take1/blob/master/tags/http-over-ssh.sh), 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`](https://github.com/vincentbernat/nixops-take1/blob/master/tags/http-over-ssh.nix) instead. ❄️
