Build a Self-Hosted ngrok With a Reverse SSH Tunnel and Nginx

Ganesh Velrajan Ganesh Velrajan • 19 Min Read • Published on Nov 12, 2026
Build a Self-Hosted ngrok With a Reverse SSH Tunnel and Nginx

ngrok gives a web app on your laptop or home device a public HTTPS URL in one command. You can build the same thing yourself with tools that are already on most Linux systems: a reverse SSH tunnel, a small VPS, Nginx and a free Let’s Encrypt certificate. The result is a URL like https://app.example.com on your own domain that reaches a web app running on a Raspberry Pi, IoT device or home server behind NAT, CGNAT or a firewall.

This guide sets up the VPS, hardens it, opens the tunnel and puts Nginx with HTTPS in front of it. Every config file here was tested on Ubuntu 24.04 (OpenSSH 9.6, Nginx 1.24) in a Docker lab with a VPS, two devices and an outside client. You can run the same lab yourself.

Get the code: ampaslabs/socketxp-blog-tutorials on GitHub, in the self-hosted-ngrok-reverse-ssh-tunnel folder (MIT licensed, includes every config file in this guide, the Docker Compose lab and the test script)

If you are new to ssh -R, read Reverse SSH Tunnel: How to Set Up ssh -R With Examples first.

What You’ll Build

Browser ==> https://app.example.com ==> Nginx on VPS (port 443, TLS) ==> 127.0.0.1:8080 on VPS ==> reverse SSH tunnel ==> Device ==> Web app on localhost:3000

Self-hosted ngrok architecture: a browser reaches Nginx on a public VPS over HTTPS, Nginx proxies to 127.0.0.1:8080, and a reverse SSH tunnel opened outbound by the device carries the traffic through NAT to the web app on localhost:3000

The device opens an outbound SSH connection to the VPS and asks it to listen on port 8080. Nginx accepts public HTTPS traffic for app.example.com and proxies it to that port, and SSH carries it back to the device.

The key security decision is that the tunnel port never faces the internet. It listens on the VPS’s loopback address only, and Nginx is the only public entry point. That lets you add HTTPS, authentication and access logs in one place. You do not need GatewayPorts, and you should leave it off.

What You Need

  • A VPS with a public IP address, running Ubuntu 22.04 or 24.04. The smallest plan from any provider is enough.
  • A domain name where you can create DNS records. This guide uses example.com.
  • A device running a web app, such as a Flask, Node.js or Grafana service on localhost:3000.
  • OpenSSH 7.8 or newer on the VPS, for PermitListen.

Step 1: Point Your Domain at the VPS

Create these DNS A records at your DNS provider, using your VPS’s public IP:

NameTypeValuePurpose
tunnel.example.comA203.0.113.10Devices connect here over SSH
app.example.comA203.0.113.10Public URL for the web app

Both names point to the same server. Using a separate name for SSH lets you move the tunnel endpoint later without changing your public URLs.

Step 2: Harden the VPS

Do this before you open any tunnels. Keep one SSH session open the whole time, so a mistake in the sshd config can’t lock you out.

Firewall: allow only SSH, HTTP and HTTPS

$ sudo apt update && sudo apt install -y nginx ufw fail2ban
$ sudo ufw allow OpenSSH
$ sudo ufw allow 'Nginx Full'
$ sudo ufw enable

You do not open the tunnel ports (8080, 8081 and so on). They listen on loopback only and Nginx reaches them locally.

On Ubuntu, the fail2ban package enables the sshd jail by default, so repeated failed logins get banned without extra setup.

Create a locked-down tunnel user

Create one shared account that devices log in to. It has no password and no usable shell:

$ sudo useradd -m -s /usr/sbin/nologin tunnel
$ sudo install -d -m 700 -o tunnel -g tunnel /home/tunnel/.ssh

Harden sshd

Create /etc/ssh/sshd_config.d/10-tunnel.conf:

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
GatewayPorts no
ClientAliveInterval 30
ClientAliveCountMax 3

Match User tunnel
    AllowTcpForwarding remote
    PermitListen 8080 8081 8082
    PermitTTY no
    ForceCommand /usr/sbin/nologin

What each part does:

  • No passwords, no root. Only SSH keys work. Before you reload, make sure your own admin account has a working key.
  • AllowTcpForwarding no globally, remote for the tunnel user. Devices can create reverse (-R) tunnels but can’t use local forwarding (-L) to reach services on the VPS itself. This also blocks -L for your admin account. If you need it, set AllowTcpForwarding yes in a Match User youradmin block.
  • PermitListen 8080 8081 8082. This lists the only ports the tunnel user may ask the VPS to listen on. Add a port here for each new app.
  • GatewayPorts no. Forwarded ports bind to 127.0.0.1 only.
  • ClientAliveInterval 30 and ClientAliveCountMax 3. The VPS closes a dead tunnel after about 90 seconds and frees its port, so the device can reconnect.

The file name starts with 10- on purpose. sshd uses the first value it reads for each setting, and files in sshd_config.d are read in alphabetical order. Some cloud images ship 50-cloud-init.conf with PasswordAuthentication yes, and your file needs to load before it.

Use the port-only form of PermitListen. When a device runs ssh -R 8080:..., it asks the server to listen on localhost:8080. A rule like PermitListen 127.0.0.1:8080 doesn’t match the string localhost, and sshd refuses the tunnel with remote port forwarding failed. Port-only rules are still safe, because GatewayPorts no forces the bind to loopback.

Test the config, then reload:

$ sudo sshd -t && sudo systemctl reload ssh

Check the settings that sshd actually applies to the tunnel user:

$ sudo sshd -T -C user=tunnel,host=x,addr=1.2.3.4 | grep -E 'passwordauth|gatewayports|allowtcpforwarding|permitlisten|permittty|forcecommand'
passwordauthentication no
permittty no
gatewayports no
allowtcpforwarding remote
forcecommand /usr/sbin/nologin
permitlisten *:8080 *:8081 *:8082

From a second terminal, confirm you can still log in with your admin key before you close the first session.

Step 3: Give Each Device Its Own Key and Port

On the device, create a dedicated local user and an SSH key for the tunnel:

$ sudo useradd -m tunnel-client
$ sudo -u tunnel-client ssh-keygen -t ed25519 -N "" -C device-01 -f /home/tunnel-client/.ssh/id_ed25519
$ sudo cat /home/tunnel-client/.ssh/id_ed25519.pub

On the VPS, add the public key to /home/tunnel/.ssh/authorized_keys, restricted to that device’s port:

restrict,port-forwarding,permitlisten="8080" ssh-ed25519 AAAA... device-01
restrict,port-forwarding,permitlisten="8081" ssh-ed25519 AAAA... device-02
$ sudo chown tunnel:tunnel /home/tunnel/.ssh/authorized_keys
$ sudo chmod 600 /home/tunnel/.ssh/authorized_keys

restrict turns off everything (PTY, agent and X11 forwarding, and so on), port-forwarding turns forwarding back on, and permitlisten limits each key to one port. If someone steals device-01’s key, they can’t take over device-02’s port. In our tests, device-01’s key was refused when it asked for port 8081, even though sshd allows 8081 for the tunnel user.

Step 4: Open the Reverse Tunnel From the Device

First, pin the VPS host key on the device, so it never connects to an impostor server:

$ sudo -u tunnel-client sh -c 'ssh-keyscan -t ed25519 tunnel.example.com >> ~/.ssh/known_hosts'
$ sudo -u tunnel-client ssh-keygen -lf /home/tunnel-client/.ssh/known_hosts

Compare the fingerprint with the output of ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the VPS. They must match.

Then open the tunnel:

$ sudo -u tunnel-client ssh -NT \
    -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes -o StrictHostKeyChecking=yes \
    -R 8080:localhost:3000 tunnel@tunnel.example.com

On the VPS, check that port 8080 is listening on loopback only:

$ sudo ss -tlnp | grep 8080
LISTEN 0  128  127.0.0.1:8080  0.0.0.0:*

If you see 0.0.0.0:8080, the port is exposed to the internet. Check that GatewayPorts is no.

Step 5: Put Nginx and HTTPS in Front of the Tunnel

Create /etc/nginx/sites-available/app.example.com:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;
    }
}
  • proxy_pass http://127.0.0.1:8080 uses the IPv4 loopback address on purpose. localhost can resolve to ::1 first, where the tunnel isn’t listening.
  • The map block and the Upgrade/Connection headers let WebSockets through, which many dashboards and dev servers need.
  • proxy_read_timeout 3600s stops Nginx from closing idle WebSocket connections after the default 60 seconds.
  • The X-Forwarded-* headers tell the app the real client IP and that the request came in over HTTPS.

Define the map block only once. If you add more sites later, keep it in one file.

Enable the site and add HTTPS with Let’s Encrypt:

$ sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
$ sudo nginx -t && sudo systemctl reload nginx
$ sudo apt install -y certbot python3-certbot-nginx
$ sudo certbot --nginx -d app.example.com --redirect

Certbot gets the certificate, adds a listen 443 ssl block with the certificate paths, turns port 80 into a redirect to HTTPS, and sets up automatic renewal. Check renewal with sudo certbot renew --dry-run.

Open https://app.example.com in a browser. You should see the web app running on the device.

Step 6: Keep the Tunnel Running With systemd

A plain ssh command stops when the network drops or the device reboots. Create /etc/systemd/system/reverse-tunnel.service on the device:

[Unit]
Description=Reverse SSH tunnel to tunnel.example.com (app.example.com)
Wants=network-online.target
After=network-online.target

[Service]
User=tunnel-client
ExecStart=/usr/bin/ssh -NT \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -o ExitOnForwardFailure=yes \
  -o StrictHostKeyChecking=yes \
  -i /home/tunnel-client/.ssh/id_ed25519 \
  -R 8080:localhost:3000 \
  tunnel@tunnel.example.com
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now reverse-tunnel
$ systemctl status reverse-tunnel

ExitOnForwardFailure=yes makes SSH exit if the VPS refuses the port, instead of staying connected with no tunnel. systemd then retries every 10 seconds until the port is free again. ServerAliveInterval detects a dead connection within about 90 seconds. In our tests, the public URL returned 502 the moment the tunnel was killed and worked again after the 10-second restart, and the tunnel came back up by itself after a reboot.

If you prefer autossh, the equivalent is autossh -M 0 with the same -o options. Set AUTOSSH_GATETIME=0 in its environment, or autossh gives up if the very first connection fails, for example because the VPS is still booting.

Add More Apps on Subdomains

Unlike ngrok, this setup doesn’t create a subdomain when a device connects. Each app needs its own tunnel port on the VPS and a mapping from a hostname to that port:

Public URLVPS portDeviceDevice app
app.example.com8080device-01localhost:3000
grafana.example.com8081device-02localhost:3001
shop.example.com8082device-01localhost:8000

For every new app, whichever option you choose below:

  1. Add the port to PermitListen in 10-tunnel.conf and reload sshd.
  2. Add the device key with permitlisten="<port>".

Option 1: One server block per app

Copy the Nginx server block from Step 5, change server_name and the proxy_pass port, create a DNS record and run certbot for the new name. This is simple for two or three apps, but each new app means a new DNS record, a new file and a new certificate.

Option 2: One wildcard server block with a port map

With a wildcard DNS record, a wildcard certificate and one Nginx server block, adding an app comes down to one line in a map.

1. Create a wildcard DNS record. Add an A record for *.example.com that points to the VPS. Every subdomain now resolves to it.

2. Get a wildcard certificate. Let’s Encrypt only issues wildcard certificates through the DNS-01 challenge, so use the certbot plugin for your DNS provider. For example, with Cloudflare:

$ sudo apt install -y python3-certbot-dns-cloudflare
$ sudo certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.cloudflare.ini -d example.com -d '*.example.com'

3. Replace the per-app Nginx files with a single /etc/nginx/sites-available/tunnels.example.com. It already contains the map $http_upgrade block, so remove any per-app files you created in Step 5:

# One port per subdomain. Add a line here for each new app.
map $host $tunnel_port {
    app.example.com      8080;
    grafana.example.com  8081;
    shop.example.com     8082;
    default              0;
}

# Optional login per subdomain. "off" means no password prompt.
map $host $tunnel_auth {
    grafana.example.com  "Restricted";
    default              off;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name example.com *.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name *.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    if ($tunnel_port = 0) {
        return 404;
    }

    auth_basic           $tunnel_auth;
    auth_basic_user_file /etc/nginx/.htpasswd;

    location / {
        proxy_pass http://127.0.0.1:$tunnel_port;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;
    }
}
$ sudo ln -s /etc/nginx/sites-available/tunnels.example.com /etc/nginx/sites-enabled/
$ sudo nginx -t && sudo systemctl reload nginx

How it behaves (all checked in our test lab):

  • app.example.com goes to port 8080 and grafana.example.com to port 8081, with paths and query strings passed through unchanged. WebSockets work on every subdomain.
  • Only grafana.example.com asks for a password. The $tunnel_auth map turns basic auth on or off per subdomain.
  • A subdomain that isn’t in the map, a request to the VPS’s bare IP address, or a forged Host header gets 404. Nginx never forwards it to a loopback port.
  • If one device goes offline, only its subdomain returns 502. The others keep working.

Adding shop.example.com then takes one line in the map, plus the two sshd steps above, and sudo nginx -t && sudo systemctl reload nginx. You don’t need a new DNS record, certificate or server block.

Don’t calculate the port from the hostname. A regex such as server_name ~^d(?<port>\d+)\.example\.com$ with proxy_pass http://127.0.0.1:$port saves you the map line, but it lets anyone reach any service listening on the VPS’s loopback address, such as a database or an admin panel, just by guessing a hostname. An explicit map only exposes the ports you list.

One device, several apps

To publish two apps from the same device, put both ports in one authorized_keys line for that device’s key:

restrict,port-forwarding,permitlisten="8080",permitlisten="8082" ssh-ed25519 AAAA... device-01

Then pass -R twice in the device’s ssh command (or in the systemd unit):

$ ssh -NT -o ExitOnForwardFailure=yes \
    -R 8080:localhost:3000 -R 8082:localhost:8000 \
    tunnel@tunnel.example.com

Don’t add a second line with the same key and a different permitlisten. sshd uses only the first line that matches a key, so the second line is ignored and the new port is refused with remote port forwarding failed. We hit exactly this in testing.

Protect the Public URL

Without authentication, anyone who finds your URL can use the web app on your device, and scanners look up new hostnames in public certificate transparency logs within minutes. Unless the app has its own login, add one in Nginx.

Basic authentication:

$ sudo apt install -y apache2-utils
$ sudo htpasswd -c /etc/nginx/.htpasswd alice

Add these two lines inside the server block, then reload Nginx:

auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;

Requests without valid credentials get 401, including WebSocket upgrade requests.

IP allowlist, when users come from fixed addresses:

allow 198.51.100.0/24;
deny  all;

For single sign-on, put oauth2-proxy in front of the app with Nginx’s auth_request.

Test Your Setup

Run these checks from a machine outside the VPS:

# Public URL reaches the device
$ curl -s https://app.example.com/

# HTTP redirects to HTTPS
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://app.example.com/
301 https://app.example.com/

# The tunnel port is NOT reachable from the internet
$ nc -zv -w 3 tunnel.example.com 8080
nc: connect to tunnel.example.com port 8080 (tcp) failed: Connection refused

# The tunnel account can't get a shell (run on the device, which has the tunnel key)
$ sudo -u tunnel-client ssh tunnel@tunnel.example.com
This account is currently not available.

# Password logins are off
$ ssh -o PubkeyAuthentication=no you@tunnel.example.com
you@tunnel.example.com: Permission denied (publickey).

If the nc check connects, your tunnel port is exposed. Fix that before anything else.

Try It Yourself With Docker Compose

Every config file in this guide is in the self-hosted-ngrok-reverse-ssh-tunnel folder of the ampaslabs/socketxp-blog-tutorials repo on GitHub (MIT licensed), together with a Docker Compose lab that runs them. You can try the whole setup on your laptop before you touch a real server:

$ git clone --depth 1 https://github.com/ampaslabs/socketxp-blog-tutorials.git
$ cd socketxp-blog-tutorials/self-hosted-ngrok-reverse-ssh-tunnel
$ docker compose up -d --build
$ ./test.sh
...
27 passed, 0 failed
$ docker compose down -v

The lab starts four Ubuntu 24.04 containers on a private Docker network:

ContainerPlays the role of
vpsThe public VPS, running sshd and Nginx as tunnel.example.com and *.example.com
device1device-01, publishing app.example.com (localhost:3000) and shop.example.com (localhost:8000) through one key
device2device-02, publishing a WebSocket dashboard as grafana.example.com behind basic auth
clientAn outside machine on the internet

The vps container mounts the repo’s 10-tunnel.conf and tunnels.example.com files directly, so the tests run against exactly the files you would copy to your server. To check that a test can fail, set GatewayPorts yes and rerun: the two port-exposure checks (T03 and T04) fail.

What we tested

test.sh runs these 27 checks. All of them passed:

#CheckResult
T01sshd config is valid (sshd -t)✅ Pass
T02Nginx config is valid (nginx -t)✅ Pass
T03Tunnel ports 8080–8082 are bound to 127.0.0.1 only✅ Pass
T04Tunnel port 8080 can’t be reached from the internet✅ Connection refused
T05Password logins are refused✅ Permission denied (publickey)
T06Root login is refused, even with a valid key✅ ROOT LOGIN REFUSED in the sshd log
T07The admin account can still log in with its key✅ Pass
T08The tunnel user gets no shell✅ This account is currently not available.
T09The tunnel user can’t use ssh -L to reach services on the VPS✅ administratively prohibited
T10A port not listed in PermitListen is refused✅ remote port forwarding failed
T11The device refuses a VPS whose host key it doesn’t know✅ Host key verification failed
T12https://app.example.com reaches the app on device-01✅ 200
T13HTTP redirects to HTTPS✅ 301
T14Paths and query strings pass through to the device✅ Pass
T15One key with two ports publishes shop.example.com from the same device✅ 200
T16app.example.com has no password prompt✅ 200
T17grafana.example.com without a password✅ 401
T18grafana.example.com with the password✅ 200
T19WebSocket through Nginx and the tunnel✅ Echo received
T20WebSocket with a wrong password✅ 401
T21A subdomain that isn’t in the map✅ 404
T22A request to the VPS’s bare IP address✅ 404
T23A forged Host: localhost header✅ 404
T24device-01 offline: its subdomain✅ 502
T25device-01 offline: device-02’s subdomain keeps working✅ 200
T26device-02’s key can’t take device-01’s port, even while it’s free✅ Refused
T27device-01 back online: the tunnel reconnects by itself✅ 200

We also tested the systemd unit from Step 6 separately, in a container running systemd. The tunnel restarted after its process was killed and came back up after a reboot.

The lab does not cover real Let’s Encrypt certificates, which need a public domain (it uses a self-signed wildcard certificate in the same Nginx config), or ufw and fail2ban.

Troubleshooting

502 Bad Gateway

Nginx can’t reach 127.0.0.1:8080. Either the tunnel is down (systemctl status reverse-tunnel on the device, ss -tlnp | grep 8080 on the VPS), or the app on the device isn’t listening on the port in the -R option. Test the app on the device with curl http://localhost:3000.

“Error: remote port forwarding failed for listen port 8080”

The VPS refused the port. There are four common causes:

  • The port isn’t in PermitListen, or the key’s permitlisten doesn’t match it.
  • The same key appears on two authorized_keys lines with different ports. sshd only reads the first matching line, so list every port on one line (see One device, several apps).
  • PermitListen uses 127.0.0.1:8080 instead of the port-only 8080 (see Step 2).
  • An old session still holds the port after a network drop. It is freed when ClientAliveInterval × ClientAliveCountMax runs out (about 90 seconds with the settings above). Find the old session with sudo ss -tlnp | grep 8080 and end it if you can’t wait.

The sshd log on the VPS shows the reason: sudo journalctl -u ssh | grep 'remote forward'.

“bind [::1]:8080: Cannot assign requested address” in the sshd log

sshd tried to bind the IPv6 loopback address too, on a server where IPv6 is off. It’s harmless as long as 127.0.0.1:8080 is listening.

WebSockets disconnect or fail to connect

Check that the server block has proxy_http_version 1.1, the Upgrade and Connection headers, and the map $http_upgrade block, and that proxy_read_timeout is longer than your app’s idle period.

“channel 1: open failed: administratively prohibited”

You used ssh -L and AllowTcpForwarding no blocked it. This is intended for the tunnel user. For your admin account, allow it in a Match User block.

Limitations of a DIY ngrok

This setup works well for a few apps you look after yourself. As it grows, the manual work grows with it:

  • No automatic subdomains. Even with the wildcard setup, every new app needs a unique port, a PermitListen entry, a key entry and a map line, edited by hand on the VPS. You keep the map between them yourself.
  • Key management. Device keys never expire unless you rotate them. Revoking a lost device means editing authorized_keys on the VPS.
  • Access control. Basic auth passwords are shared, and there is no per-user audit trail of who opened which app.
  • A server to run. The VPS is a public machine that you patch, monitor and back up. If it goes down, every URL goes down with it.
  • Web traffic only. Private SSH, VNC or database access to the devices themselves needs a separate solution.

For more on these risks, see How to Secure a Reverse SSH Tunnel.

Alternatives to a DIY Reverse SSH Tunnel

DIY SSH + NginxsishfrpSocketXP
Server to runYour VPSYour VPSYour VPSNone (cloud), or self-hosted
Client on deviceStandard sshStandard sshfrpcSocketXP agent
Public URLs on your domainYes, added by hand (one map line per app)Yes, subdomains assigned automaticallyYesYes (custom domains on paid plans)
Public ports on the VPS80 and 443 onlyDepends on setupDepends on setupNone you manage
Private SSH/VNC to devicesSeparate setupSeparate setupTCP tunnels, managed by youBuilt in, through the portal
Device fleet dashboardNoNoBasic server dashboardYes
CostVPS onlyVPS onlyVPS onlyFree trial, then from $0.50 per device per month; free self-hosted Community edition
  • sish is a self-hosted server that works with a plain ssh -R command and hands out subdomains automatically, without a server block per app.
  • frp is an open-source reverse proxy with its own client and server, supporting TCP, UDP, HTTP and HTTPS.

See Best ngrok Alternatives for a broader comparison.

SocketXP: the same result, without running the relay

SocketXP uses the same outbound-tunnel idea, but you don’t run the relay server. The SocketXP agent on the device opens an outbound TLS tunnel to the SocketXP cloud gateway. One command gives a web app a public HTTPS URL:

$ socketxp connect http://localhost:3000

TCP services such as SSH, VNC or databases stay private: they are reachable only through the SocketXP portal by authenticated users, with no public port opened anywhere. Since SocketXP is an IoT device management and remote access platform, the same agent also gives you a device dashboard, a browser terminal, OTA updates and APIs for fleet automation.

The same tunnel can expose a local LLM server, such as Ollama or vLLM on an edge device. The SocketXP AI Gateway lets edge devices reach your private LLMs without a public IP.

If you chose a DIY setup because you want to host everything yourself, SocketXP can be self-hosted too:

  • Community edition: free, self-hosted, for non-commercial use. Up to 100 devices, device management, browser-based SSH, VNC and SFTP access, and 2 team members, with community forum support.
  • Enterprise edition: self-hosted on-premise or in your private cloud for commercial, data-residency and air-gapped deployments. See Self-Hosted Cloudflare Tunnel Alternative for Linux IoT.

Conclusion

A reverse SSH tunnel, Nginx and Let’s Encrypt make a working self-hosted ngrok on your own domain. The settings that matter are GatewayPorts no, so the tunnel port stays on loopback behind Nginx; a no-shell tunnel user with port-only PermitListen rules; one key and one port per device; and authentication on every public URL. For a handful of apps, that’s all you need. Once you are tracking ports, keys and access across many devices, a platform like SocketXP handles the relay, the URLs and the access control for you.

Frequently Asked Questions

  1. Can I build my own ngrok with SSH?

    Yes. Rent a VPS with a public IP, point a domain such as app.example.com at it, and run 'ssh -R 8080:localhost:3000 tunnel@your-vps' on the device. Nginx on the VPS receives HTTPS requests for app.example.com and proxies them to 127.0.0.1:8080, which the SSH tunnel carries back to the web app on the device. Let's Encrypt provides the certificate.

  2. Do I need GatewayPorts enabled to expose a web app through a reverse SSH tunnel?

    No. Keep 'GatewayPorts no'. The tunnel port then listens only on the VPS loopback address (127.0.0.1), and Nginx, running on the same VPS, proxies public traffic to it. Only ports 80 and 443 face the internet, and you can add HTTPS, authentication and logging in Nginx. Enabling GatewayPorts would expose the raw tunnel port to internet scanners.

  3. Why does sshd refuse my reverse tunnel with 'remote port forwarding failed' when PermitListen is set?

    When you run 'ssh -R 8080:localhost:3000', the client asks the server to listen on 'localhost:8080'. A rule such as 'PermitListen 127.0.0.1:8080' does not match the string 'localhost', so sshd denies the request. Use the port-only form, 'PermitListen 8080' and 'permitlisten="8080"'. With GatewayPorts set to no, the port is still bound to loopback only.

  4. How do I get HTTPS for a reverse SSH tunnel on my own domain?

    Terminate TLS in Nginx on the VPS. Install certbot and python3-certbot-nginx, create an Nginx server block for your hostname, and run 'sudo certbot --nginx -d app.example.com'. Certbot gets a Let's Encrypt certificate, adds the HTTPS listener and the HTTP to HTTPS redirect, and renews the certificate automatically.

  5. Can I expose several apps or devices on different subdomains?

    Yes. Give each app its own tunnel port (8080, 8081, 8082 and so on). With a wildcard DNS record (*.example.com), a wildcard Let's Encrypt certificate from the DNS-01 challenge, and one Nginx server block that uses a 'map $host $tunnel_port' to pick the port, each new app needs only one map line plus its sshd PermitListen and authorized_keys entries.

  6. Does a self-hosted reverse SSH tunnel create a subdomain automatically for each device?

    No. Unlike ngrok, nothing is created when a device connects. You add a port to PermitListen, a permitlisten entry for the device key, and a hostname-to-port mapping in Nginx by hand for every new app. Tools like sish assign subdomains automatically, and SocketXP gives each device a public URL with one command and no server edits.

  7. How do I let one device publish two web apps through the same tunnel key?

    List both ports on one authorized_keys line, for example 'restrict,port-forwarding,permitlisten="8080",permitlisten="8082" ssh-ed25519 AAAA...', and pass '-R' twice in the ssh command. Don't add a second line for the same key: sshd only uses the first line that matches a key, so the second port is refused.

  8. Do WebSockets work through a reverse SSH tunnel and Nginx?

    Yes. The SSH tunnel carries any TCP traffic, so WebSockets pass through it unchanged. Nginx needs 'proxy_http_version 1.1' and the Upgrade and Connection headers set from a 'map $http_upgrade $connection_upgrade' block. Raise 'proxy_read_timeout' so idle WebSocket connections are not closed after 60 seconds.

  9. What happens to the public URL when the device goes offline?

    Nginx returns 502 Bad Gateway, because nothing is listening on the tunnel port. When the device reconnects, the URL works again. You can add a custom error page in Nginx with 'error_page 502' to show a friendlier message.

  10. How do I stop the tunnel account from getting a shell on the VPS?

    Create the account with '/usr/sbin/nologin' as its shell, and add a 'Match User tunnel' block in sshd_config with 'PermitTTY no', 'ForceCommand /usr/sbin/nologin' and 'AllowTcpForwarding remote'. Also prefix each key in authorized_keys with 'restrict,port-forwarding'. A login attempt then prints 'This account is currently not available.'

  11. Is a self-hosted ngrok with SSH secure?

    It can be, if you keep the tunnel port on loopback, allow only key-based logins, restrict each key to one port, and put authentication in front of public URLs. Without authentication, anyone who finds the URL can reach the web app on your device. You are also responsible for patching the VPS, renewing keys and monitoring logs.

  12. What is the difference between this setup and ngrok?

    ngrok runs the public relay, assigns URLs and handles TLS for you. In a self-hosted setup you run the relay yourself: a VPS, sshd, Nginx and Let's Encrypt. You get full control and your own domain at the cost of a VPS, but you also manage ports, keys, certificates and access for every device yourself.

  13. Can I test a self-hosted ngrok setup locally before using a real VPS?

    Yes. The self-hosted-ngrok-reverse-ssh-tunnel folder of the ampaslabs/socketxp-blog-tutorials repo on GitHub has a Docker Compose lab with a VPS container (sshd and Nginx), two device containers and an outside client. Run 'docker compose up -d --build' and then './test.sh', which runs 27 checks covering sshd hardening, HTTPS routing, WebSockets, basic auth, unknown subdomains, and tunnel recovery after a device goes offline.

  14. What are the alternatives to a DIY reverse SSH tunnel with Nginx?

    sish is a self-hosted server that hands out subdomains to standard SSH clients automatically. frp is an open-source reverse proxy with its own client and server. SocketXP is a managed alternative that gives devices public HTTPS URLs or private TCP tunnels without a public port, plus a device dashboard, and it can also be self-hosted.

SocketXP IoT Remote Access and Device Management Platform

Remotely access, manage, and update your IoT & AIoT edge fleet with SocketXP's secure and scalable platform.

Start Your Free Trial Now!

Join thousands of satisfied users who trust SocketXP for a secure, reliable, and scalable IoT Edge device management solution. Start your free trial now.