Hand-drawn network map comparing a VPS reverse SSH route with a direct Cloudflare Tunnel route to a home server.

Replace a VPS Reverse Tunnel with Cloudflare Tunnel

A practical guide to routing public hostnames directly through Cloudflare Tunnel to Nginx and multiple services on a home server.

Mike Chumba Mike Chumba
6 min read
1071 words

A VPS and reverse SSH tunnel can publish services from a home network without opening inbound ports. The tradeoff is an extra public server and a long-lived transport between that server and home.

Cloudflare Tunnel can provide the same public route directly. A small cloudflared process runs on the private server and establishes outbound connections to Cloudflare. Public requests enter through Cloudflare, travel over those connections, and reach a local HTTP service.

Nginx can remain the router for everything on the machine. This keeps the tunnel configuration small even when the server hosts several applications.

Architecture

The VPS design sends each request through two public layers before it reaches the home server:

browser
  -> Cloudflare edge
  -> public VPS
  -> reverse SSH tunnel
  -> Nginx on the home server
  -> local application

The Cloudflare Tunnel design removes the VPS from the HTTP path:

browser
  -> Cloudflare edge
  -> Cloudflare Tunnel
  -> cloudflared on the home server
  -> Nginx on 127.0.0.1:80
  -> local application

cloudflared initiates the connection from inside the network. The router does not need to forward ports 80 or 443 to the server. Cloudflare terminates public TLS, while Nginx routes requests to local applications by hostname.

For example:

chat.example.net  -> Nginx -> Mattermost on 127.0.0.1:8065
notes.example.net -> Nginx -> notes app on 127.0.0.1:4000
api.example.net   -> Nginx -> API on 127.0.0.1:3001

All three public hostnames can use the same tunnel and the same Nginx listener.

Prerequisites

You need:

  • A domain using Cloudflare DNS.
  • A Linux server that can reach the Internet.
  • Nginx or another local reverse proxy.
  • At least one application listening on the server.
  • Administrative access to the Cloudflare dashboard and the Linux server.

The examples below use example.net. Replace it with your own domain.

Configure Nginx First

Set up and test the local application before adding the public route. A Mattermost virtual host can look like this:

server {
    listen 80;
    listen [::]:80;
    server_name chat.example.net;

    location ~ /api/v[0-9]+/(users/)?websocket$ {
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://127.0.0.1:8065;
        proxy_read_timeout 3600s;
    }

    location / {
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://127.0.0.1:8065;
    }
}

The explicit WebSocket location keeps Mattermost live updates connected. Ordinary HTTP applications need only the second location block.

Validate and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Test the route locally with the public host header:

curl -i \
  -H 'Host: chat.example.net' \
  http://127.0.0.1/api/v4/system/ping

Fix local routing before continuing. A tunnel can be healthy even when Nginx or the application behind it is not.

Create the Tunnel

In the Cloudflare dashboard:

  1. Open Networking > Tunnels.
  2. Select Create a tunnel.
  3. Give the tunnel a descriptive name, such as home-services.
  4. Choose the operating system used by the origin server.
  5. Run the installation command displayed by Cloudflare on that server.
  6. Wait for the connector to report a healthy status.

The dashboard command installs cloudflared and registers it as a system service. Check it locally with:

systemctl status cloudflared
journalctl -u cloudflared --since '-10 minutes'

If the connector cannot establish a connection, confirm that the server and its firewall allow outbound traffic to Cloudflare on port 7844.

Publish an Application

Open the tunnel in the Cloudflare dashboard, then:

  1. Select the Routes tab.
  2. Choose Add route and then Published application.
  3. Enter the public hostname, such as chat.example.net.
  4. Set the service URL to http://127.0.0.1:80.
  5. Save the route.

Cloudflare creates the proxied DNS record for a published application when it manages the domain’s DNS. Requests for chat.example.net now enter the tunnel and arrive at Nginx.

Test the public path:

curl -i https://chat.example.net/api/v4/system/ping

The tunnel service URL deliberately points to Nginx rather than directly to Mattermost. Nginx remains responsible for application routing, proxy headers, upload limits, timeouts, and WebSocket handling.

Add More Services

Create another Nginx virtual host for each application:

server {
    listen 80;
    listen [::]:80;
    server_name notes.example.net;

    location / {
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://127.0.0.1:4000;
    }
}

Then add a published application route for notes.example.net with the same service URL:

http://127.0.0.1:80

Repeat this for other applications. The tunnel carries the request to Nginx; the original hostname tells Nginx which virtual host should receive it.

This arrangement also avoids running a separate cloudflared process for every application. Cloudflare supports multiple published application routes on one tunnel, and one cloudflared service can carry them all.

Wildcard Hostnames

A wildcard route is useful when most first-level subdomains belong to the same server:

*.example.net -> Cloudflare Tunnel -> local Nginx

The tunnel route and Cloudflare DNS both need to cover the wildcard hostname. Nginx still needs a matching virtual host for every real application; otherwise its default server will answer the request.

Exact DNS records take precedence over a wildcard record. That allows selected hostnames to point elsewhere:

*.example.net   -> home tunnel
docs.example.net -> static hosting provider
api.example.net  -> another server

For a second server, create a tunnel on that server and publish the hostnames that should reach it. A wildcard simplifies routing to one origin, but it does not choose automatically between independent servers.

Verify WebSockets

Applications such as Mattermost need more than a successful page request. Verify the WebSocket upgrade explicitly:

KEY=$(openssl rand -base64 16)

curl --http1.1 -i -N --max-time 4 \
  -H 'Connection: Upgrade' \
  -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H "Sec-WebSocket-Key: $KEY" \
  https://chat.example.net/api/v4/websocket

A working route begins with:

HTTP/1.1 101 Switching Protocols

If ordinary HTTP works but the WebSocket test fails, inspect the Nginx upgrade headers and application path before changing the tunnel.

Troubleshooting

Check each layer separately.

Test the application directly:

curl -i http://127.0.0.1:8065/api/v4/system/ping

Test Nginx locally:

curl -i \
  -H 'Host: chat.example.net' \
  http://127.0.0.1/api/v4/system/ping

Inspect Nginx and connector logs:

journalctl -u nginx --since '-10 minutes'
journalctl -u cloudflared --since '-10 minutes'

Then test the public hostname:

curl -i https://chat.example.net/api/v4/system/ping

This order identifies whether the failure sits in the application, Nginx, the connector, or the public route. A healthy tunnel status only confirms the connection between cloudflared and Cloudflare; it does not prove that the local application is responding.

Result

The server keeps its applications and reverse proxy on private listeners. cloudflared provides the outbound transport, Cloudflare provides the public edge, and Nginx keeps routing local and legible.

Adding another service usually means adding one Nginx virtual host and one published application route. The home router remains closed to unsolicited inbound traffic.

References