2026.06 / INFRASTRUCTURE GUIDE 019
Cloudflare Tunnel for self-hosted VPS apps: expose services without opening ports
Every self-hosted app eventually reaches the same question: how do people reach it from the internet? The obvious answer — open a firewall port, point DNS at the server, and let a reverse proxy terminate TLS — works, but it also means your server is reachable from everywhere on those ports. That is fine for a public website on 443. It is less ideal for an admin dashboard, a development instance, a database admin tool, or any service that only needs to be reached through a controlled path.
Cloudflare Tunnel (cloudflared) answers that question differently. Instead of the outside world connecting in to your server, the tunnel client connects out to Cloudflare's edge. Public traffic arrives at Cloudflare first, then flows through the encrypted tunnel to your service. You never open inbound ports for the tunneled apps, and you gain Cloudflare's DDoS protection, TLS termination, and optional Access policies along the way.
Goal: run a Cloudflare Tunnel on a VPS alongside existing services like Coolify, Docker Mailserver, and nginx — without breaking what is already there, and without opening any new inbound ports.
When a tunnel is the right tool
A tunnel is not a replacement for your normal public web stack. If you are already running Traefik or nginx on ports 80/443 with Cloudflare-proxied DNS records, that path works well and you should keep it. A tunnel becomes useful in three specific situations:
- Admin interfaces you want to protect. A Coolify dashboard, Portainer, phpMyAdmin, or Grafana that should only be reachable through Cloudflare Access (email-based login) rather than on a raw public port.
- Services that do not need a public hostname at all. A dev instance, a staging app, or an internal API that you want to reach from your laptop without VPN or SSH port-forwarding.
- Servers behind NAT or firewalls you do not control. A home lab, a Raspberry Pi, or a corporate network where you cannot open inbound ports. The tunnel only needs outbound HTTPS, which is almost always allowed.
If all your services already run behind a reverse proxy on 80/443 and you are happy with that, you do not strictly need a tunnel. But for anything that should be locked behind an identity check rather than just a port number, a tunnel plus Cloudflare Access is the cleaner design.
How it works: the short version
The architecture is straightforward:
cloudflaredruns on your VPS (or in a Docker container).- It maintains an outbound connection to Cloudflare's edge.
- Cloudflare assigns your tunnel a hostname (or several).
- When a visitor hits that hostname, traffic flows: visitor → Cloudflare edge → tunnel → your local service.
- You configure an ingress rule that maps each public hostname to a local port, host, or path on your server.
The visitor never sees your server's IP. The server never needs to accept inbound connections for the tunneled service. TLS is terminated at Cloudflare's edge (or you can configure end-to-end TLS with your own certificate if you need that).
Prerequisites
Before starting, you need:
- A Cloudflare account (free plan works).
- A domain whose DNS is managed by Cloudflare.
- A VPS with Docker installed (or the ability to install
cloudflareddirectly). - At least one service running on the VPS that you want to expose.
If you already have a Cloudflare DNS setup for your VPS, you have most of this already. The tunnel does not replace your existing DNS records or proxied hostnames — it adds a parallel path for services you do not want to expose directly.
Step 1: Create the tunnel
You can create a tunnel from the Cloudflare dashboard (Zero Trust → Networks → Tunnels → Create a tunnel) or from the CLI. The dashboard approach is simpler for a first setup:
- Log into the Cloudflare dashboard, navigate to Zero Trust → Networks → Tunnels.
- Click Create a tunnel.
- Choose Cloudflared as the connector type.
- Name the tunnel something descriptive — for example
vps-mainoradmin-services. - Cloudflare will display an installation command for your server. Copy it.
That command includes a tunnel token that authenticates the connector to your Cloudflare account. Do not commit that token to a public repository. On your VPS, run it directly or store it in an environment variable.
Step 2: Install cloudflared on the VPS
There are two approaches: run cloudflared directly on the host, or run it as a Docker container alongside your other services. On a Docker-based self-hosted setup — which is the norm for Coolify, mail servers, and most modern stacks — the container approach fits better.
Docker approach (recommended for Docker-based stacks)
After creating the tunnel in the dashboard, Cloudflare gives you a command like:
docker run -d --name cloudflared \
--restart unless-stopped \
cloudflare/cloudflared:latest \
tunnel --no-autoupdate run \
--token YOUR_TUNNEL_TOKEN
Replace YOUR_TUNNEL_TOKEN with the token from the dashboard. The --restart unless-stopped flag keeps the tunnel alive across Docker daemon restarts.
If you prefer a docker-compose.yml fragment:
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
Store the token in a .env file next to the compose file, and ensure that file is not committed to version control.
Bare-metal approach
If you do not use Docker for this service, install cloudflared directly:
cloudflared tunnel run YOUR_TUNNEL_NAME
This uses the CLI login flow instead of a token. Either approach produces the same tunnel; the token-based Docker method is just more portable on a Docker-centric VPS.
Step 3: Configure public hostnames and ingress rules
This is the step where you connect a public hostname to a service running on your VPS. In the Cloudflare dashboard, after creating the tunnel, you are prompted to add Public Hostnames. Each hostname has:
- Subdomain: the public hostname (e.g.
admin.example.com). - Path: optional — you can route specific URL paths to different services.
- Service: the local destination — protocol, host, and port (e.g.
http://127.0.0.1:8000).
For a Coolify dashboard running on the VPS:
Subdomain: admin.example.com
Service: http://127.0.0.1:8000
For a local development app on a different port:
Subdomain: staging.example.com
Service: http://127.0.0.1:3000
The key insight: the Service field always points at a local address on the VPS. Since cloudflared runs on the same machine, it can reach 127.0.0.1 or any Docker network address. The visitor connects to admin.example.com, Cloudflare routes through the tunnel, and cloudflared forwards the request to the local port.
Step 4: Add a Cloudflare DNS record
Cloudflare can automatically create the DNS record for you when you add a public hostname in the tunnel configuration. If it does not, add a CNAME record manually:
admin.example.com CNAME YOUR_TUNNEL_ID.cfargotunnel.com proxied
The tunnel ID is shown in the Cloudflare dashboard. The record should be proxied (orange cloud) — this is the only case where a tunnel-specific CNAME should be proxied. If you use DNS-only mode for a tunnel hostname, traffic will try to resolve to the tunnel target directly and will not work.
This is different from your normal web and mail DNS records, where the proxying decision depends on the protocol. Tunnel CNAMEs should always be proxied.
Step 5: Add Cloudflare Access (optional but recommended)
For admin interfaces, a tunnel gets you inbound-port avoidance. Cloudflare Access gets you identity-based access control on top. Without Access, anyone who guesses the hostname can reach the service. With Access, they also need to authenticate.
In the Cloudflare dashboard, under Zero Trust → Access → Applications:
- Create a new Self-hosted application.
- Set the Application domain to your tunnel hostname (e.g.
admin.example.com). - Add a policy: Allow emails matching your address (or a group, or a GitHub org).
- The default action for everyone else is Deny.
Now visiting admin.example.com shows a Cloudflare Access login page. After authenticating with your email (or another configured identity provider), you land on the actual service. The service itself never sees a raw unauthenticated connection.
This is the pattern I described in the VPS hardening checklist: bind admin services to loopback, tunnel them through Cloudflare, and add an Access policy so only you can reach them.
Handling websockets and real-time connections
Some self-hosted services use websockets for real-time features — Coolify's dashboard uses them for live logs and terminal sessions. Cloudflare Tunnel supports websockets, but the path routing can be tricky if you need to send some paths to one service and others to a different port.
For Coolify specifically, you may need multiple public hostnames pointing at the same tunnel:
# Coolify dashboard (HTTP)
admin.example.com → http://127.0.0.1:8000
# Coolify realtime (websocket)
realtime.admin.example.com → http://127.0.0.1:6001
# Coolify terminal websocket
terminal.admin.example.com → http://127.0.0.1:6002
The dashboard itself loads at admin.example.com, while the websocket connections for live updates and terminal sessions go to their own hostnames. This avoids the common "Cannot connect to real-time service" error that occurs when websocket traffic is routed to the wrong port or blocked by the ingress rules.
For simpler services that just use websockets on the same port (like a chat app or a dev server with hot reload), no special configuration is needed. The tunnel handles upgrade requests transparently.
TLS: what happens at each layer
Cloudflare Tunnel handles TLS by default:
- Visitor to Cloudflare: HTTPS, terminated at the edge with a Cloudflare-managed or custom SSL certificate.
- Cloudflare to cloudflared: encrypted with the tunnel's own TLS connection (QUIC or HTTPS).
- cloudflared to your service: HTTP by default (since it is local). You can configure this as HTTPS if your local service uses TLS.
For most self-hosted apps on 127.0.0.1, the local connection does not need TLS. The tunnel itself is already encrypted, and the visitor-to-edge leg is HTTPS. If you want end-to-end encryption all the way to your service, configure the service URL as https://127.0.0.1:PORT and ensure your local service has a certificate that cloudflared can validate.
Tunnel vs. proxied DNS: when to use which
My Cloudflare DNS checklist covers the normal pattern: point a proxied A or CNAME record at your origin, and Cloudflare sits in front of nginx or Traefik on your VPS. That works great for public-facing websites and is how most Coolify apps are deployed.
Use a tunnel when:
- You do not want to open inbound ports for the service.
- You want identity-based access (Cloudflare Access) rather than just network-level reachability.
- Your server is behind NAT or a restrictive firewall.
- You are exposing admin tools that should not be publicly discoverable.
Use proxied DNS when:
- The service is a public website or API that anyone should be able to reach.
- You already have a reverse proxy (nginx, Traefik, Caddy) handling routing.
- You need full control over TLS certificates, caching rules, and headers at the proxy level.
These are not mutually exclusive. On a typical VPS, the public website runs through proxied DNS and Traefik on port 443, while admin dashboards, dev instances, and monitoring tools run through a tunnel on a separate subdomain. Both paths share the same Cloudflare account and zone.
Common pitfalls
- Tunnel hostname is DNS-only: The tunnel CNAME must be proxied (orange cloud). If it is grey-cloud, traffic bypasses Cloudflare entirely and the tunnel never sees the request.
- Service host is 0.0.0.0 instead of 127.0.0.1: The service address in the ingress rule should be
127.0.0.1orlocalhost, not0.0.0.0. The latter may work but is a habit that leads to accidental public exposure on other configs. - Missing websocket routes: If a dashboard loads but live updates or terminals do not work, check whether the service expects websocket connections on a different port or path. Add separate tunnel hostnames for those.
- Token in version control: The tunnel token is a secret. Store it in an environment variable or a
.envfile that is gitignored, not in adocker-compose.ymlthat gets pushed to a public repo. - Tunnel container not restarting: If
cloudflaredcrashes and does not restart, all tunneled services go dark silently. Use--restart unless-stoppedin Docker or a systemd unit for bare-metal installs. - Access policy blocking assets: If Cloudflare Access intercepts CSS, JS, or API requests and returns the login page instead of assets, add a bypass policy for static paths like
/build/assets/*. Do not bypass the whole hostname — just the static asset paths. - Multiple services on the same port: If two apps both listen on port 3000, the tunnel cannot route to both at once. Either change one app's port or run them in separate Docker networks with distinct ports.
Verification checklist
After setting up the tunnel, verify each layer:
# Check the tunnel is running
docker ps | grep cloudflared
docker logs cloudflared --tail 20
# Check DNS resolves to a Cloudflare address
dig +short admin.example.com
# Check the HTTPS response through the tunnel
curl -I https://admin.example.com/
# If using Cloudflare Access, check you get the login page
curl -s https://admin.example.com/ | head -5
# Check websocket upgrade if the service needs it
curl -I -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://realtime.admin.example.com/
If the tunnel is working, the DNS lookup returns a Cloudflare edge address, the curl -I shows a 200 or a Cloudflare Access redirect, and the tunnel logs show active connections. If the tunnel is not working, check the container logs first — they usually say exactly what is wrong.
Running alongside an existing reverse proxy
If your VPS already runs nginx or Traefik on ports 80/443 (as it would with Coolify), the tunnel does not conflict. cloudflared connects outbound to Cloudflare on port 443, and it forwards requests to local services on their existing ports. Nothing needs to change in your nginx or Traefik configuration for the tunnel to work.
The only consideration is that if you tunnel a hostname that is also configured as a proxied DNS record pointing at your nginx/Traefik, you will get two paths to the same service. That is not necessarily a problem, but it can be confusing when debugging. Pick one path per hostname: either proxied DNS through the reverse proxy, or tunnel.
The practical summary
- Create a tunnel in Cloudflare Zero Trust dashboard.
- Run
cloudflaredon your VPS with the tunnel token (Docker recommended). - Add public hostnames in the tunnel configuration, each pointing at a local service.
- Ensure the DNS records are proxied (orange cloud).
- Add Cloudflare Access policies for admin hostnames.
- Handle websocket paths separately if needed.
- Verify with
curl -Iand tunnel logs.
A tunnel does not replace your firewall or your reverse proxy. It is a complementary tool that lets you expose services without opening inbound ports, and adds identity-based access control through Cloudflare Access. On a well-organized VPS, the public website runs through the normal proxy path, and admin tools, staging instances, and internal services run through the tunnel. Both paths share the same domain, the same Cloudflare account, and the same goal: make services reachable to the right people without exposing them to everyone.