How to secure a VPS with Tailscale and Cloudflare Tunnel
By Flavio Copes
Secure a VPS with private SSH over Tailscale and public web traffic through Cloudflare Tunnel. A complete guide to setup, firewalls, testing, and recovery.
You can run a public website on a VPS without leaving SSH, HTTP, or HTTPS open on the server’s public IP address.
Your visitors reach the website through Cloudflare Tunnel. You reach the server through Tailscale. The server starts the connections both systems need, so its provider firewall can block new connections arriving from the internet.
We will build that setup on an Ubuntu VPS, run a small Node.js application, close public inbound access, and check that everything survives a reboot. We will also prepare a way back in before changing the firewall.
I haven’t run this exact migration on a live VPS yet. This walkthrough follows the vendors’ documentation, checked on 23 October 2026. Start with a spare server and complete the checks before applying it to a production machine.
What we are changing
On a typical VPS, you connect to its public IP on port 22 for SSH. Your visitors connect on ports 80 and 443. Perhaps the machine also has a database, a development server, or an admin dashboard listening on another port.
An SSH key protects authentication. The SSH service still receives connections from strangers, and its software still needs security updates.
We will change which services strangers can reach:
| Service | After this tutorial |
|---|---|
| Website | Public, through Cloudflare on your domain |
| SSH | Available to an authorized administrator over Tailscale |
| Node.js app | Listening on the VPS loopback address only |
| Database | Kept local, or reached through an SSH port forward |
| New connections to the public VPS IP | Blocked by the provider firewall |
The VPS keeps its public IP and outbound internet connection. We aren’t removing its network interface or disconnecting it from the internet.
This matters because cloudflared, the program that runs Cloudflare Tunnel, needs to reach Cloudflare. Tailscale needs to reach its coordination and relay services too. Package downloads, DNS lookups, backups, and API calls also need outbound access.
How the website and SSH paths work
Tailscale creates an encrypted network between devices you enroll. That network is called a tailnet. Your laptop and VPS receive private Tailscale addresses, and an access policy decides which connections are allowed.
Cloudflare Tunnel connects a service on the VPS to a public hostname. Visitors use an ordinary browser. They don’t install Tailscale or join your network.
The diagram pages show the website request, the administrator’s connection, and a blocked public connection:
Those arrows show where requests travel. They don’t show who opens the underlying internet connection.
For the web path, the VPS opens the tunnel connection to Cloudflare first. Cloudflare sends visitor requests back through that established connection. A stateful firewall recognizes the return traffic and allows it.
Tailscale tries to establish a direct encrypted connection between devices. If the networks prevent that, it can carry the encrypted traffic through a relay. A firewall blocking unsolicited inbound traffic can therefore work with Tailscale, although a relayed connection may be slower. See Tailscale’s firewall requirements.
What this setup protects, and what it leaves open
Blocking direct public access removes the route where someone scans the server’s IP and connects to SSH, an accidentally published database, or the web server behind Cloudflare.
The public website remains an entry point. Its login form, upload handler, API endpoints, and dependencies still need attention. A vulnerable application can still be compromised through the tunnel if a harmful request reaches it.
Cloudflare’s WAF can filter requests according to its rules. It cannot understand every permission check in your application, and it doesn’t replace patching.
An attacker who compromises your laptop, your Tailscale account, your Cloudflare account, or the hosting account may have another route in. Protect those accounts with strong authentication, and keep backups outside the VPS.
I would use this setup for an ordinary web application I administer myself. I would plan a different network layout for a mail server, a public game server, or software that requires arbitrary inbound TCP or UDP connections. This tutorial publishes HTTP through Cloudflare and keeps administration private.
What you need
Use a spare Ubuntu LTS VPS for the first run. The shell commands in the server sections assume Bash, sudo, and systemd.
You need:
- A working SSH connection with a key and a user who can run
sudo. - Access to the hosting provider’s firewall and browser console.
- A Tailscale account you can administer, plus Tailscale on your laptop.
- A Cloudflare account and a domain using Cloudflare DNS.
- A hostname available for the example application.
The provider firewall steps below use Hetzner Cloud. The application and networking setup also apply elsewhere, but check your provider’s firewall semantics before copying the final rules.
These are the names used throughout the tutorial:
| Name | Meaning |
|---|---|
vps-web | Tailscale hostname for the server |
deploy | Existing Linux user with SSH access and sudo |
tag:vps-web | Tailscale tag identifying this server’s role |
status.flaviocopes.com | Example public hostname; use a subdomain you control |
203.0.113.10 | Documentation-only public IPv4 address; substitute the VPS address |
2001:db8::10 | Documentation-only public IPv6 address; substitute it if your VPS has IPv6 |
The example Tailscale policy uses [email protected]. Replace it with the exact login identity shown in your Tailscale admin console. These example names don’t imply that a server or hostname already exists.
If SSH keys and Linux users are new to you, start with my SSH guide. My free Tailscale course covers tailnets, device enrollment, grants, and troubleshooting in more detail.
Prepare recovery before touching access
Open the provider console and log into the server through it. Run:
whoami
Seeing a login screen is insufficient. You need to know that you can authenticate and get a shell without SSH or Tailscale.
On Hetzner, a server created with an SSH key may have no root password available for console login. Its console instructions explain the root-password reset option. A console password does not require enabling password authentication in SSH.
Keep the provider console available while you work. Keep your existing public SSH session open too.
Back up the application and any database before a production migration. Take a provider snapshot if appropriate, but remember that restoring a snapshot may roll back database writes made since it was taken.
Record the current DNS records and firewall rules somewhere outside the VPS. You may need them to restore access.
Inspect the server you have
In the existing SSH session, list listening TCP and UDP sockets:
sudo ss -lntup
Look at the local address column:
| Address | What it means |
|---|---|
127.0.0.1:3000 | IPv4 loopback; reachable from this machine |
[::1]:3000 | IPv6 loopback |
0.0.0.0:3000 | Listening on all IPv4 interfaces |
[::]:3000 | Listening on IPv6 interfaces; IPv4 behavior depends on the socket configuration |
A listener is not proof that a port is publicly reachable. A firewall may already block it. But every listener should have a purpose you recognize.
Inspect the host firewall too:
sudo ufw status verbose
Don’t reset an existing firewall to make the tutorial easier to follow. Record its rules. If your server uses nftables, firewalld, or another manager, inspect that configuration instead of adding a second manager.
Check what depends on public access today. An outside deployment service may SSH to the public IP. A monitoring service may probe it directly. Another server may use its database port. Those connections will stop working when we close inbound access, so move them to an approved private route or redesign them first.
Add the laptop to Tailscale
Install Tailscale using the download instructions for your operating system. Sign in and connect.
Open the Tailscale admin console and check that the laptop appears under your user account. Keep this laptop user-owned for the policy we will write. Tagged devices use a different identity model.
Enable strong authentication with the identity provider you use for Tailscale. If device approval is enabled, approve the laptop before continuing.
Define who can reach the VPS
Joining a tailnet and being authorized to use a service are separate steps.
We will allow your user to reach TCP port 22 on machines tagged tag:vps-web. We won’t grant the server permission to start connections to other tailnet devices.
For a new practice tailnet, open Access controls in the admin console and use this complete policy, substituting your actual login email in every occurrence:
{
"tagOwners": {
"tag:vps-web": ["[email protected]"]
},
"grants": [
{
"src": ["[email protected]"],
"dst": ["tag:vps-web"],
"ip": ["tcp:22"]
}
],
"tests": [
{
"src": "[email protected]",
"proto": "tcp",
"accept": ["tag:vps-web:22"],
"deny": ["tag:vps-web:3000", "tag:vps-web:5432"]
}
]
}
tagOwners controls who can assign the server tag. The grant permits the network connection. The tests check the intended allowed and denied destinations when you save the policy.
On an existing tailnet, back up the policy and preserve unrelated settings. Adapt its rules deliberately rather than replacing the whole file with this practice policy.
Grants and legacy ACL rules are additive. Adding a narrow SSH grant doesn’t cancel a wildcard rule that already permits everything. Remove or narrow overlapping permissions affecting this server. The deny entries above are tests, not firewall rules that override an allow.
Tailscale documents the fields in its grants syntax reference and policy-file reference.
Install Tailscale on the VPS
In the existing public SSH session, update the package index and install the tools we need:
sudo apt update
sudo apt install -y curl ca-certificates
Download Tailscale’s official installer to a file:
curl -fsSL https://tailscale.com/install.sh -o /tmp/install-tailscale.sh
less /tmp/install-tailscale.sh
After reviewing it, run it:
sudo sh /tmp/install-tailscale.sh
rm /tmp/install-tailscale.sh
The installer sets up the package repository for your distribution. Tailscale also provides manual package installation instructions if you prefer to configure the repository yourself.
Enroll the server with its hostname and role:
sudo tailscale up --hostname=vps-web --advertise-tags=tag:vps-web
Open the authentication link on your laptop, confirm the account, and complete enrollment. Approve the server if your tailnet requires it.
In the admin console, check the server’s tag. Then check the service and addresses on the VPS:
sudo systemctl enable --now tailscaled
tailscale status
tailscale ip -4
tailscale ip -6
Copy the IPv4 Tailscale address for the next step. It will normally start with 100.. That address belongs to the overlay network and is different from the provider’s public IP.
Test a fresh SSH connection over Tailscale
On your laptop, open another terminal. Set a variable to the address you just copied:
VPS_TAILNET_IP=100.101.102.103
Replace that example address with your server’s real Tailscale address.
Connect with SSH connection sharing disabled, so this test creates a new connection:
ssh -o ControlMaster=no -o ControlPath=none deploy@"$VPS_TAILNET_IP"
You are using the same OpenSSH server and SSH key as before, over a different network path. Verify the host-key fingerprint against the server you already trust. Connecting through a new address can produce a first-connection prompt even though it is the same machine.
Run a command in this new session:
hostname
sudo -v
If MagicDNS is enabled, also try the Tailscale hostname from the laptop:
ssh -o ControlMaster=no -o ControlPath=none deploy@vps-web
Use the numeric address if local DNS configuration gets in the way. Diagnose that separately after confirming the underlying SSH connection works.
We are keeping ordinary OpenSSH authentication. Tailscale SSH is an optional feature that changes how SSH connections are authenticated and authorized. Leave the --ssh option out of tailscale up for this walkthrough.
If the connection fails, stop here. Check device approval, the exact login identity in the grant, the server tag, and whether the SSH key works. Keep public SSH available until the private connection works reliably.
Run a small app on loopback
We need a local application to publish through the tunnel. This example has a home page, a health endpoint, and a 404 response. It uses Node’s built-in HTTP server and needs no npm dependencies.
Install Node.js from the Ubuntu repositories for this small example:
sudo apt install -y nodejs
If you already manage Node with another installation method, use that supported installation and find its executable with command -v node. The service below uses the Ubuntu package’s /usr/bin/node.
Create a dedicated service user and an application directory:
sudo useradd --system --user-group --home-dir /opt/vps-status --shell /usr/sbin/nologin vps-status
sudo install -d -o root -g root -m 755 /opt/vps-status
These creation commands assume a fresh practice server where that user and directory don’t already exist.
Save the complete application at /opt/vps-status/server.mjs:
sudo tee /opt/vps-status/server.mjs > /dev/null <<'EOF'
import { createServer } from 'node:http'
const server = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store')
response.setHeader('X-Content-Type-Options', 'nosniff')
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD' })
response.end()
return
}
if (request.url === '/healthz') {
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
response.end('ok\n')
return
}
if (request.url !== '/') {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
response.end('Page not found\n')
return
}
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VPS status</title>
</head>
<body>
<h1>The VPS app is running</h1>
<p>This page is served through Cloudflare Tunnel.</p>
</body>
</html>`)
})
server.listen(3000, '127.0.0.1')
EOF
The binding in the last line matters. The app accepts connections on loopback only. It doesn’t need to listen on the public IP or even the Tailscale interface.
The example page describes the route we are about to configure. Seeing that text alone won’t prove a request used the tunnel; our later connection checks establish that.
Keep the app running with systemd
Save this complete service at /etc/systemd/system/vps-status.service:
sudo tee /etc/systemd/system/vps-status.service > /dev/null <<'EOF'
[Unit]
Description=VPS status example
After=network.target
[Service]
Type=simple
User=vps-status
Group=vps-status
WorkingDirectory=/opt/vps-status
ExecStart=/usr/bin/node /opt/vps-status/server.mjs
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
EOF
This runs the app as an unprivileged user. The process can read its application file, but the service configuration doesn’t give it permission to modify the system or user home directories.
Start it and enable it at boot:
sudo systemctl daemon-reload
sudo systemctl enable --now vps-status
sudo systemctl status vps-status --no-pager
Check the response and listening address:
curl --fail --show-error http://127.0.0.1:3000/healthz
sudo ss -lntp 'sport = :3000'
The request should return ok. The socket should show 127.0.0.1:3000.
Check the missing-page response too:
curl -i http://127.0.0.1:3000/missing
Expect HTTP 404. A working home page shouldn’t hide broken error handling.
Install Cloudflare Tunnel
We will use a remotely managed named tunnel. Cloudflare stores the hostname routing configuration, and the VPS runs a connector using a tunnel credential.
This is a persistent tunnel on your own domain. A temporary Quick Tunnel is useful for a demo, but it isn’t the deployment we want here. My Cloudflare Tunnel guide also covers locally managed configuration files and other routing options.
On the VPS, add Cloudflare’s package repository:
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list > /dev/null
sudo apt update
sudo apt install -y cloudflared
These are the official Debian-family package instructions. Using the repository makes the connector available to your usual package-update process.
Create the named tunnel
In the Cloudflare dashboard, open Networking → Tunnels and create a tunnel named vps-web. Choose the cloudflared connector if the dashboard asks for a connector type.
The setup screen supplies an installation command containing a long token. That token lets a connector join this tunnel. Keep it secret.
Copy just the token. In Bash on the VPS, read it without echoing it or putting its literal value in shell history:
read -r -s -p 'Tunnel token: ' tunnel_token
printf '\n'
sudo cloudflared service install "$tunnel_token"
unset tunnel_token
The installer receives the token as an argument, so this does not hide it from a privileged observer of the machine. It avoids saving the literal credential in your interactive command history. Run it from a private session without shell tracing or terminal recording.
The service installer saves the credential for future starts. Treat the connector’s credential files and service configuration as secrets too. Don’t paste them into a support ticket.
Enable and inspect the service:
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared --no-pager
The dashboard should show the connector as healthy. Cloudflare’s setup guide describes the dashboard flow, and its token documentation explains credential handling and rotation.
Route the hostname to the local app
Add a published application route to the tunnel. The dashboard may call this a public hostname.
Use these values, substituting your domain:
| Setting | Value |
|---|---|
| Subdomain | status |
| Domain | Your domain on Cloudflare |
| Path | Leave empty |
| Service type | HTTP |
| Service URL | 127.0.0.1:3000 |
For our example, the public URL is https://status.flaviocopes.com and the origin service is http://127.0.0.1:3000.
If an A, AAAA, or CNAME record already uses the hostname, record its value before replacing it. Use a new subdomain for your first test so you aren’t moving an existing site yet.
Cloudflare creates or asks you to create the DNS route to the tunnel. Inspect the DNS record and confirm it targets the tunnel rather than the VPS public IP.
Why the local URL uses HTTP
The browser connects to Cloudflare over HTTPS, and the connector maintains an encrypted connection to Cloudflare. On the VPS, the request travels between two local processes over loopback.
Cloudflare terminates the visitor’s HTTPS connection. It can inspect the web traffic. Consider that trust relationship when deciding whether to use this architecture.
If the connector and application live on different machines, loopback no longer describes the last hop. Configure and validate HTTPS for that connection, or use an appropriately secured private transport. Don’t disable certificate verification to silence an error.
Test the public hostname before closing anything
On your laptop, set the hostname you configured:
APP_HOST=status.flaviocopes.com
Open https://status.flaviocopes.com in a browser, substituting your hostname. You should see the example page with a valid HTTPS connection.
From the laptop terminal, request the health endpoint:
curl --fail --show-error "https://$APP_HOST/healthz"
Check the error response too:
curl -i "https://$APP_HOST/missing"
Expect ok for the health endpoint and HTTP 404 for the missing page. Our example sends Cache-Control: no-store, so an ordinary cached home page should not mask an unavailable app. Remove any custom cache rule that overrides this while testing.
If the hostname fails, fix it now. A healthy connector doesn’t prove its origin URL is correct.
Move an existing website carefully
If this is a fresh example server, continue to the firewall section.
For an existing application, first add a temporary hostname to the tunnel and test the application through it. Some applications enforce an allowed-host list or generate absolute URLs, so a temporary hostname may require an application setting too.
When that works, change the real hostname’s DNS route to the tunnel. Account for the old DNS record’s cache lifetime before blocking the old route. Test the real hostname, including login, logout, redirects, uploads, webhooks, and any WebSocket connection your app uses.
Check these common migration details:
| Existing behavior | What to check |
|---|---|
| HTTP redirects to HTTPS | The app recognizes the original HTTPS request and doesn’t loop |
| IP-based logs or rate limits | The app obtains visitor addresses from trusted proxy headers |
| Secure session cookies | Login persists through the public HTTPS hostname |
| Virtual hosts in Nginx or Apache | The origin receives the Host header your site expects |
| Certificate renewals using inbound HTTP | The renewal method no longer depends on a blocked public port |
| Third-party deployment over SSH | The deployment runner has an authorized private path |
Cloudflare supplies visitor information through request headers, including CF-Connecting-IP and X-Forwarded-Proto. Configure your framework to trust the actual proxy path. Arbitrary clients must not be able to choose a header that your application treats as proof of identity or permission.
Don’t disable an existing reverse proxy just because our Node example doesn’t need one. It may still handle several applications, local routing, or application-specific behavior. Point the tunnel at its loopback listener and check each site before changing it.
Block public inbound access with the provider firewall
We now have a working private SSH connection and a working public tunnel route. Only now should we close the old routes.
On Hetzner Cloud, a firewall with no inbound rules drops new inbound connections. With no outbound rules, it allows outbound connections. Return traffic for established connections is allowed because the firewall is stateful.
Create a firewall called vps-private-entry:
- Remove any default inbound allow rules.
- Leave the outbound rules empty.
- Apply the firewall to the correct practice server.
- Inspect every other firewall attached to that server, including ones applied through label selectors.
Hetzner combines allow rules across attached firewalls. An empty firewall won’t cancel an SSH or web allow rule in another attached firewall. Remove those overlapping public permissions as part of the cutover.
The desired provider configuration has no public inbound allow rules and unrestricted outbound access. Hetzner’s firewall FAQ explains its connection tracking and combined-rule behavior; its firewall limitations are worth checking when adapting this to your server.
Do not copy “empty means allow outbound” to another provider without checking. Elsewhere, empty outbound rules may block everything.
Keep outbound access working
This tutorial closes inbound access and leaves outbound access allowed. We are not building an outbound allowlist.
Cloudflare Tunnel needs outbound port 7844, using UDP for QUIC or TCP for HTTP/2. DNS and other supporting traffic also matter. Cloudflare lists the requirements in its tunnel firewall documentation.
Tailscale needs outbound access to its coordination and relay infrastructure and uses UDP when it can establish direct connections. If you later restrict outbound access, treat that as a separate change and follow the current vendor requirements.
You do not need to allow inbound traffic from Cloudflare’s public IP ranges for this tunnel. That allowlist belongs to the different setup where Cloudflare connects directly to a publicly listening origin.
Account for private provider networks
A provider firewall isn’t necessarily a firewall for every interface. Hetzner Cloud Firewalls do not filter traffic inside Hetzner private Cloud Networks.
If the VPS belongs to such a network, another machine on that network may still reach a service bound to all interfaces. Keep private-network permissions in your inventory and use appropriate host filtering. Binding the example app to 127.0.0.1 avoids publishing that app on either interface.
Verify the public routes are closed
Run these checks from your laptop or another external machine, using the provider’s public IP addresses directly. A command run on the VPS against its own IP doesn’t test the provider’s inbound firewall in the same way.
First, try a new public SSH connection:
ssh -o ConnectTimeout=5 -o ControlMaster=no -o ControlPath=none [email protected]
It should fail to establish a connection. An authentication prompt or SSH banner means port 22 is still reachable.
Your old public SSH terminal may remain connected after the firewall change. Hetzner preserves established connections. That session is useful for recovery, but it doesn’t prove that new public access remains open or closed.
Probe the old public web ports:
curl --noproxy '*' --connect-timeout 5 --max-time 8 http://203.0.113.10/
curl --noproxy '*' --connect-timeout 5 --max-time 8 https://203.0.113.10/
Both should fail to establish a connection. A certificate error means the HTTPS listener was reachable, even though the request failed later.
Test the application port too:
curl --noproxy '*' --connect-timeout 5 --max-time 8 http://203.0.113.10:3000/
That should fail. In our example, the loopback binding already prevents direct access to port 3000.
For an existing HTTPS site, test a forced direct origin request that preserves the real hostname and TLS server name:
curl --noproxy '*' --connect-timeout 5 --max-time 8 --resolve "$APP_HOST:443:203.0.113.10" "https://$APP_HOST/"
It should fail to connect. This catches an origin bypass that a normal request through Cloudflare would miss.
Test IPv6 separately
If your VPS has a public IPv6 address, test it from a machine with working IPv6 connectivity:
ssh -6 -o ConnectTimeout=5 -o ControlMaster=no -o ControlPath=none deploy@2001:db8::10
curl -6 --noproxy '*' --connect-timeout 5 --max-time 8 'http://[2001:db8::10]/'
curl -6 --noproxy '*' --connect-timeout 5 --max-time 8 'https://[2001:db8::10]/'
Substitute the actual address. All should fail to connect. An immediate “network unreachable” from your laptop only proves your test machine lacks a route; use an IPv6-capable test machine before claiming the VPS’s IPv6 path is blocked.
Also probe any other public IPv4 addresses, floating IPs, or provider-specific interfaces assigned to the VPS. An old AAAA record or a second public address can leave another route behind.
Verify the routes that should still work
Open another fresh SSH session over the Tailscale address:
ssh -o ControlMaster=no -o ControlPath=none deploy@"$VPS_TAILNET_IP"
Request the public hostname again:
curl --fail --show-error "https://$APP_HOST/healthz"
Open the page in a browser on your phone with Wi-Fi and Tailscale turned off. Ordinary visitors should still reach the public website.
On the VPS, confirm outbound DNS and HTTPS still work:
getent ahosts tailscale.com
curl --head --fail --show-error https://www.cloudflare.com/
Keep a small record of the expected results:
| Check | Expected result |
|---|---|
| New SSH connection to public IPv4 | Cannot connect |
| New SSH connection to public IPv6, if present | Cannot connect |
| Direct public HTTP and HTTPS | Cannot connect |
| Forced hostname request to origin IP | Cannot connect |
| SSH to Tailscale address | Works for the authorized user |
| Public hostname from a device outside the tailnet | Works |
| Local health endpoint | Returns ok |
| Outbound DNS and HTTPS | Work |
A few blocked probes don’t prove that every port is filtered. Combine the connection checks with the provider rule audit and the server’s listener inventory. Probe any additional ports you found during that inventory.
Check that the tailnet policy denies other services
An unsuccessful connection to a port with no listener doesn’t prove an access policy blocked it. Nothing was there to answer.
We can make the check meaningful by temporarily running a harmless listener on the server’s Tailscale address. On the VPS, run:
TS_BIND_IP=$(tailscale ip -4) node --input-type=module <<'EOF'
import { createServer } from 'node:http'
createServer((request, response) => {
response.end('temporary policy probe\n')
}).listen(3001, process.env.TS_BIND_IP)
EOF
Leave that terminal running. In a second VPS session, confirm the listener responds locally, substituting the address:
curl --connect-timeout 3 http://100.101.102.103:3001/
Now try from the laptop:
curl --noproxy '*' --connect-timeout 3 --max-time 5 "http://$VPS_TAILNET_IP:3001/"
With our SSH-only grant, this should time out. If it returns the probe text, review the tailnet’s grants and legacy ACLs for overlapping permissions.
Stop the temporary server with Ctrl-C immediately after the check. Use only this harmless probe; don’t expose a real database or admin application to test a rule.
Decide whether to add a host firewall
The provider firewall is our public inbound boundary. A host firewall can add protection if the provider attachment changes, and can filter traffic the provider doesn’t cover.
If you use UFW, inspect the existing rules before changing them. Broad OpenSSH, 80/tcp, 443/tcp, or application-profile allows can remain in effect even after you change the default policy.
On a fresh practice server using UFW, a starting configuration is:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22 proto tcp
sudo ufw enable
sudo ufw status verbose
Do this from the provider console with the tested private SSH route available. Inspect /etc/default/ufw and ensure IPv6 filtering is enabled with IPV6=yes if the server uses IPv6. Review existing allow rules individually; don’t use ufw reset on a working server.
UFW is not the authority for which tailnet ports are allowed in this setup. In its default Linux netfilter mode, Tailscale installs rules that accept permitted Tailscale traffic before ordinary UFW input rules. A UFW rule mentioning port 22 doesn’t mean UFW blocks every other tailnet port. Our narrow Tailscale grant handles that authorization. See Tailscale’s netfilter modes.
Retest fresh private SSH and the public website after any host-firewall change. Avoid changing Tailscale’s netfilter mode as part of this first migration.
If the app runs in Docker
Docker changes host firewall rules for published ports. A port published with -p 3000:3000 can become reachable through paths that ordinary UFW rules don’t protect as you expect. Docker documents the interaction in its packet-filtering guide.
If cloudflared runs on the host, publish the container port on host loopback explicitly:
services:
app:
ports:
- '127.0.0.1:3000:3000'
This is the relevant section to add to your application’s existing Compose file, not a complete standalone Compose application. Inside the container, the app generally needs to listen on its container interface, commonly 0.0.0.0, so Docker can forward the connection.
If the connector also runs in Docker, 127.0.0.1 points to the connector’s own container. Put the connector and app on a shared Docker network and route to the app’s service name and internal port. You may not need to publish the app port to the host at all.
Keep the provider firewall in place and repeat external probes after changing container networking.
Understand what the free WAF does
Cloudflare’s Free plan includes the Free Managed Ruleset, deployed by default. It is a subset of the broader Cloudflare Managed Ruleset. Check the site’s security settings and events to confirm which protections are active. The WAF setup guide explains the distinction.
Do not assume that a new vulnerability immediately has a matching rule, that the rule is included in your plan, or that every possible exploit will match it.
For your real application, test forms and API clients after changing security rules. Investigate false positives in the security events rather than disabling protection across the whole site.
Keep fixing vulnerabilities in the application. A request that exploits a missing ownership check may look perfectly ordinary to a web firewall.
Put private dashboards behind authentication
The public status page is intentionally public. A database console, deployment dashboard, or personal tool should have an access decision before strangers reach it.
You can keep it entirely inside the tailnet. For a loopback-only dashboard, SSH forwarding is convenient. From the laptop:
ssh -o ExitOnForwardFailure=yes -N -L 127.0.0.1:8080:127.0.0.1:8080 deploy@vps-web
Then open http://127.0.0.1:8080 locally. This uses the existing private SSH permission and your SSH authentication. The service doesn’t need a public hostname or another open tailnet port.
Alternatively, publish a separate hostname through the tunnel and put Cloudflare Access in front of it. Create a self-hosted Access application for that exact hostname, allow only the intended identities, and test it in a signed-out browser. Follow Cloudflare’s self-hosted application instructions.
Create the Access protection before publishing a sensitive service. A tunnel alone doesn’t add a login screen.
Avoid placing an interactive Access login in front of a public webhook endpoint without planning service authentication. A browser may handle a login redirect; a payment provider or deployment hook usually won’t.
Reboot and prove that the setup persists
Working terminal sessions can hide missing startup configuration. Before rebooting, check the services:
sudo systemctl is-enabled tailscaled cloudflared vps-status
sudo systemctl is-active tailscaled cloudflared vps-status
The services should be enabled and active. Keep the provider console available and reboot during an appropriate maintenance window:
sudo reboot
After the machine returns, establish a new private SSH connection. Then repeat the public hostname request and direct public-IP probes from the laptop.
On the VPS, inspect startup failures:
sudo systemctl --failed
sudo journalctl -u tailscaled -u cloudflared -u vps-status -b --no-pager -n 100
Logs can contain addresses, identifiers, and sensitive application data. Review them before sharing.
Don’t close the migration until the private route and website recover after a reboot while the public origin routes remain blocked.
Troubleshooting by the part that failed
Follow the request path one connection at a time. Changing several firewall and DNS settings at once makes it harder to see which change helped.
SSH works publicly but not over Tailscale
Check that both devices are connected to the same tailnet and that approvals are complete. Confirm the server tag and source identity match the policy. Try the numeric Tailscale IP before debugging MagicDNS.
If OpenSSH rejects the key, networking may already be working. Use ssh -v to distinguish connection failure from authentication failure. Don’t add public firewall rules to fix a rejected key.
SSH works, but it is slower after closing inbound access
Run this on the laptop:
tailscale ping vps-web
It can show whether traffic is direct or relayed. This is a connectivity diagnostic, not proof that an SSH grant permits port 22.
A relay can be the expected result of strict filtering. If performance becomes a problem, review Tailscale’s direct and relayed connection guidance before deciding whether to allow an inbound UDP port. Opening one changes our “no inbound allow rules” configuration.
Cloudflare reports that the tunnel is unavailable
Check the cloudflared service and its recent logs:
sudo systemctl status cloudflared --no-pager
sudo journalctl -u cloudflared -b --no-pager -n 50
Check the dashboard’s connector state, the token, outbound connectivity, and DNS resolution. A firewall that accidentally blocks outbound port 7844 can break the connector even though ordinary HTTPS requests from the VPS work.
The tunnel is healthy, but the website returns an origin error
On the VPS, request the exact origin URL configured in the route:
curl -i http://127.0.0.1:3000/healthz
If that fails, fix the app service or port. If it succeeds, inspect the route’s service type, address, Host-header requirements, and whether the connector runs on the host or inside a container.
Cloudflare’s tunnel troubleshooting guide covers its connection and origin errors.
The site redirects forever or login won’t stick
Check your application’s trusted-proxy configuration and original-scheme handling. Confirm the public hostname matches its configured URL and cookie domain. Inspect browser redirects and cookies before changing TLS settings.
Closing the firewall seemed to do nothing
Create a fresh connection with SSH multiplexing disabled. Check all attached provider firewalls, label selectors, and public addresses. Confirm you’re probing the VPS address rather than Cloudflare’s address for the domain.
Both private SSH and the website are down
Use the provider console you tested earlier. Check that the server booted, the services started, and outbound networking still works. If you accidentally blocked outbound traffic, restore it in the provider firewall before changing the applications.
Recover without reopening everything
If Tailscale alone fails, use the provider console to inspect tailscaled and its logs. Reauthenticate or repair its policy as needed. The console recovery path doesn’t depend on the tailnet.
If you must temporarily restore public SSH, allow TCP 22 only from your current public source address: an IPv4 /32, or an appropriately narrow IPv6 source. Both the provider firewall and any host firewall must permit that connection. Keep the existing SSH-key requirement, then remove the temporary permissions and repeat the blocked-public-SSH test after recovery.
If the host firewall denies public SSH and neither the console nor Tailscale is available, a provider-side allow rule alone won’t get you in. Use the provider’s rescue or recovery environment to repair the host rules. Check how that recovery system works before relying on it.
If Cloudflare Tunnel fails but SSH still works, use the private connection to inspect the connector and local app. Fix the tunnel before exposing the origin again.
For an urgent rollback of an existing production site, restore its recorded DNS and its previous reviewed firewall rules together. Pointing DNS back to the origin while the origin remains blocked won’t restore service. Account for DNS caching during the rollback, and remove temporary access after the incident.
Never make “temporarily allow every port from everywhere” the recovery procedure.
Keep the setup working
The machine still needs maintenance. Install operating-system and application updates, update the connector through its package repository, and check which services need a restart. Schedule reboots when updates require them.
Inspect Tailscale device expiry in the admin console. Tagged servers normally have key expiry disabled by default when enrolled as tagged devices, but verify the setting on your actual server. A non-expiring device credential needs a deliberate revocation process when the server is retired or compromised. Tailscale explains the behavior in its tag documentation.
Keep the tunnel token out of Git, container images, screenshots, and shared logs. If it leaks, follow Cloudflare’s token-rotation procedure and reconnect the legitimate connector. Review connected replicas too.
Monitor the public hostname from outside the server. Keep a separate way to notice a dead connector or unavailable VPS. Back up the application and database off the machine, and test a restore.
This architecture also creates dependencies. A problem with Cloudflare can affect the public website, while a Tailscale problem can affect routine administration. A second connector on the same VPS doesn’t solve a VPS outage. Decide whether those tradeoffs fit the application you run.
When you next change networking, repeat the same practical checks: a new private SSH session, a real public website request, and fresh failed connections to the public origin addresses. Keep the recovery instructions somewhere you can read when the VPS is unavailable.
Want me to talk about your product? You can sponsor this site.
Related posts about cloudflare: