Skip to content
Go back

Cloudflare Tunnels: The Zero-Port-Forward Guide to Exposing Your Services

By SumGuy 9 min read
Cloudflare Tunnels: The Zero-Port-Forward Guide to Exposing Your Services

Your Router Firewall Is Closed. Your Services Are Still Online. Explain.

Most self-hosters discover Cloudflare Tunnels after the third time they’ve rage-Googled “why is port forwarding not working.” You follow a tutorial, open port 443 on your router, forward it to your server, set up DDNS because your ISP changes your IP every Tuesday at 3am, and then your ISP blocks port 80 anyway. Perfect.

Cloudflare Tunnels flip this model entirely. Instead of the internet connecting to you, your server connects out to Cloudflare, and traffic flows back through that persistent outbound tunnel. Zero inbound ports. Zero DDNS. Zero pleading with your ISP.

If you’ve used basic tunnels and got one service running, that’s great. But you’re probably leaving most of the value on the table. Let’s fix that.


How Tunnels Actually Work (The 60-Second Version)

cloudflared runs on your machine and establishes a persistent connection to Cloudflare’s edge network. When a request hits yourapp.yourdomain.com, Cloudflare routes it through that tunnel connection to your service — all over a connection you initiated outbound.

The magic part: you can route multiple services through a single tunnel using ingress rules. One cloudflared instance, one tunnel, dozens of services. Your router has no idea any of this is happening.


The Config File You Should Actually Be Using

Most tutorials show the cloudflared tunnel run --url one-liner. It works, but it’s the training wheels version. The real power is in config.yml.

tunnel: your-tunnel-id-here
credentials-file: /etc/cloudflared/your-tunnel-id.json
ingress:
- hostname: app1.yourdomain.com
service: http://localhost:8080
- hostname: app2.yourdomain.com
service: http://localhost:3000
- hostname: jellyfin.yourdomain.com
service: http://localhost:8096
originRequest:
noTLSVerify: false
connectTimeout: 30s
- hostname: ssh.yourdomain.com
service: ssh://localhost:22
# Catch-all — required, or cloudflared complains loudly
- service: http_status:404

That ingress block is doing the heavy lifting. Cloudflare matches the incoming hostname and routes it to the correct local service. The last rule is mandatory — it catches anything that doesn’t match and returns a 404 instead of an error.

The ssh:// service type is particularly useful. Cloudflare can proxy SSH connections with cloudflared access ssh, giving you browser-based SSH access with zero exposed ports.


Docker Compose Setup That Won’t Haunt You Later

Running cloudflared as a system service is fine, but Docker Compose means it restarts automatically, logs go somewhere sensible, and you can version-control your setup.

services:
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --config /etc/cloudflared/config.yml run
volumes:
- ./cloudflared:/etc/cloudflared:ro
networks:
- proxy
networks:
proxy:
external: true

Your ./cloudflared/ directory holds two files: config.yml (shown above) and your-tunnel-id.json (the credentials file you downloaded when creating the tunnel).

One important gotcha: if your services are running in Docker, you can’t use localhost in the ingress config — cloudflared is in its own container and localhost is its own localhost. Use the service name on a shared Docker network instead:

ingress:
- hostname: app1.yourdomain.com
service: http://app1:8080
- hostname: jellyfin.yourdomain.com
service: http://jellyfin:8096

Make sure everything is on the same Docker network (proxy in this example) and you’re done.


Cloudflare Access: Putting a Lock on Your Door

Here’s where Cloudflare Tunnels go from “convenient” to “genuinely impressive security layer.” Cloudflare Access lets you put authentication in front of any service before the request ever reaches your server. Your app doesn’t need to know anything about it.

Setting it up lives in the Cloudflare Zero Trust dashboard under Access > Applications.

Create an application:

Create a policy:

When someone hits app1.yourdomain.com, Cloudflare intercepts the request and shows a login page. The user authenticates with Google, GitHub, or a one-time email link. Cloudflare verifies the identity, and only then forwards the request to your tunnel. Your app still thinks it’s talking to a normal HTTP request.

This is genuinely powerful for services that have weak or no authentication built in — Jupyter notebooks, internal dashboards, admin panels.


Short-Lived Certificates and Service Tokens

For machine-to-machine access (your CI pipeline hitting an internal API, for example), Cloudflare Access has service tokens. Instead of human auth, you generate a Client ID and Client Secret, and include them as headers in requests:

Terminal window
curl -H "CF-Access-Client-Id: your-client-id.access" \
-H "CF-Access-Client-Secret: your-secret" \
https://api.yourdomain.com/internal/endpoint

Service tokens don’t expire unless you set them to. Short-lived certificates are the more interesting option for SSH access — cloudflared access ssh-gen generates a temporary certificate valid for a short window, which is considerably more secure than leaving a long-lived key somewhere.


Origin Certificates: TLS All the Way

By default, traffic between Cloudflare and your origin server may be unencrypted (or using a self-signed cert Cloudflare doesn’t verify). For anything sensitive, you want full end-to-end TLS.

Cloudflare Origin Certificates are free CA-signed certs issued by Cloudflare, valid for up to 15 years, designed specifically for the Cloudflare-to-origin segment. They’re trusted by Cloudflare but not by browsers directly (so you can only use them for the tunnel leg, not if you’re bypassing Cloudflare).

In your reverse proxy (Caddy, Nginx, Traefik) behind the tunnel:

  1. Download the Origin Certificate from the Cloudflare dashboard (SSL/TLS > Origin Server)
  2. Configure your reverse proxy to use it
  3. Set Cloudflare SSL mode to “Full (strict)”

Now traffic is encrypted from browser → Cloudflare edge → your server. The tunnel connection itself is always encrypted.


Cloudflare Tunnels vs Tailscale vs Traditional VPN

This comparison comes up constantly, and the answer is genuinely “it depends.”

FeatureCloudflare TunnelsTailscaleTraditional VPN
Port forwarding neededNoNoSometimes
Public internet exposureYes (by design)No (mesh only)Depends on config
Auth layerCloudflare AccessTailscale ACLsVPN credentials
LatencyCloudflare edge (low)Peer-to-peer (very low)Depends
External usersEasyRequires inviteComplex
Self-hostablecloudflared onlyHeadscaleYes
CostFree tier generousFree tier limitedHardware/hosting
What they seeYour trafficEncrypted meshEncrypted mesh

Cloudflare Tunnels excel when you want to expose services to the public internet or to users who aren’t on your network. Tailscale is better for private access — connecting your devices together, remote access to your homelab without exposing anything publicly. They’re not mutually exclusive.


The Elephant in the Room: What Cloudflare Can See

Let’s be honest about the tradeoff here. When you use Cloudflare Tunnels, Cloudflare is your TLS termination point. In “Full (strict)” mode with origin certificates, traffic is re-encrypted for the tunnel segment, but Cloudflare still decrypts and inspects traffic at their edge before re-encrypting it for you.

This is the same tradeoff you make with any CDN or DDoS protection service. Cloudflare can see the content of your HTTP requests. They have a strong incentive not to abuse this (it would destroy their business), but it is technically possible.

What this means practically:

Cloudflare’s privacy policy says they don’t sell data, and they have a decent track record. But “a third party can read my traffic if they want to” is a real thing that’s true, and you should know it going in.


Advanced Ingress Patterns Worth Knowing

Path-based routing to different backends: Cloudflare Tunnels do hostname-based routing, not path-based. If you need /api/* to go somewhere different than /, handle that in a reverse proxy behind the tunnel. Put Caddy or Nginx between cloudflared and your services.

Health checks:

ingress:
- hostname: app.yourdomain.com
service: http://app:8080
originRequest:
connectTimeout: 10s
tcpKeepAlive: 30s
keepAliveTimeout: 90s
keepAliveConnections: 100

Load balancing: Cloudflare’s load balancing is a paid feature, but for homelab use the free tier plus multiple tunnel replicas (cloudflared tunnel run can run multiple instances pointing to the same tunnel ID) gives you some redundancy.

Private network access (WARP): With Cloudflare Zero Trust and WARP client, you can expose entire private subnets — not just individual services. Install the WARP client on your phone/laptop, and suddenly your entire home network’s RFC 1918 space is accessible. This is basically Cloudflare’s answer to Tailscale.


Monitoring and Debugging

When something breaks (and it will), cloudflared has decent logging:

Terminal window
cloudflared tunnel --config /etc/cloudflared/config.yml --loglevel debug run

In Docker:

Terminal window
docker logs cloudflared --follow

Common issues and their actual causes:


Putting It Together

The full homelab Cloudflare setup that actually makes sense:

  1. Create a single tunnel for your domain
  2. Configure all services via ingress rules in config.yml
  3. Run cloudflared in Docker Compose on your server
  4. Set up Cloudflare Access policies for anything that needs auth (internal tools, admin panels, services without their own auth)
  5. Use Origin Certificates + “Full (strict)” SSL mode
  6. Keep Tailscale for genuinely private access (your personal devices accessing the homelab without public exposure)

The result: services publicly accessible where you want them to be, authenticated where they need to be, and zero ports open on your router. Your ISP is completely in the dark about what you’re running. Your router firewall shows nothing inbound.

Digital ninja achieved.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
UFW Advanced: Rate Limiting, Logging, and Rules That Actually Make Sense
Next Post
Bash Arrays: The Feature That Makes Scripts Readable

Related Posts