Every time you forward a port on your router, you crack open a door to the internet. Anyone scanning your IP can knock on it, and some will.
There is a better way.
Cloudflare Tunnel creates a secure, outbound-only connection between your local server and Cloudflare's edge network. No open ports. No static IP. No firewall rules. Your app becomes reachable at your custom domain, fully encrypted, completely free.
I recently migrated to a new domain and used this exact setup to get my Dockerized React + Django + Nginx stack live in under an hour. This guide walks you through every step.
By the end, your app will be live at your domain with zero exposed ports and zero cost.
Prerequisites
- A Cloudflare account (free)
- A domain added to Cloudflare (nameservers pointed to Cloudflare)
- Your application running inside Docker
-
cloudflaredservice running in Docker - Basic terminal knowledge
Step 1: Add Your Domain to Cloudflare
- Log in to Cloudflare and click Add domain
- Enter your domain > select the Free plan > click Continue
- Cloudflare scans for existing DNS records. Review, remove irrelevant ones, and continue
Update nameservers at your registrar:
Cloudflare gives you two nameservers, for example:
xyz.ns.cloudflare.com
abc.ns.cloudflare.com
Go to your domain registrar (wherever you bought your domain), find DNS/Nameserver settings, and replace the current nameservers with these two. Wait for Cloudflare to confirm the domain is Active.
Step 2: DNS Records
Since you are using Cloudflare Tunnel (cloudflared), you do NOT need A records pointing to your server IP. The tunnel handles all routing. CNAME records are created automatically in Step 7.
Step 3: Install cloudflared
# Add Cloudflare's package signing key
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
# Add Cloudflare's apt repo
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
# Update and install
sudo apt-get update && sudo apt-get install cloudflared
Step 4: Authenticate cloudflared
cloudflared tunnel login
This prints a URL. Open it in your browser, select your domain, and a cert.pem file is saved to ~/.cloudflared/cert.pem.
Note: This cert is your account-level credential. Keep it safe. It is only needed on the host for management commands, not inside your Docker container.
Step 5: Create the Tunnel
cloudflared tunnel create tunnel-name
Output:
Created tunnel tunnel-name with id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Tunnel credentials written to ~/.cloudflared/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json
Save the tunnel UUID, you need it in the next step.
Copy the credentials to your project:
cp ~/.cloudflared/*.json .cloudflared/
Step 6: Create the Configuration File
Inside your .cloudflared directory, create a config.yml file. This file tells the tunnel which local service to route traffic to for each hostname.
tunnel: <YOUR-TUNNEL-UUID>
credentials-file: /etc/cloudflared/<YOUR-TUNNEL-UUID>.json
ingress:
# Route your primary domain to Nginx on port 443
- hostname: example.com
service: https://nginx:443
# Route www subdomain to the same service
- hostname: www.example.com
service: https://nginx:443
# Catch-all rule. required, must be last
- service: http_status:404
For a full list of configuration options, type this command in your terminal:
cloudflared tunnel help
Note: Not using Nginx? Replace
https://nginx:443withhttp://localhost:8000(or your app's port). The catch-all rule is required,cloudflaredthrows an error without it.
Validate your rules:
cloudflared tunnel --config .cloudflared/config.yml ingress validate
# Expected: OK
Step 7: Route Traffic to Your Domain
# Point root domain to the tunnel (auto-creates CNAME in Cloudflare DNS)
cloudflared tunnel route dns tunnel-name example.com
# Point www subdomain
cloudflared tunnel route dns tunnel-name www.example.com
Confirm in the Cloudflare dashboard under DNS > Records:
Type Name Content Proxy
CNAME @ <tunnel-uuid>.cfargotunnel.com Proxied
CNAME www <tunnel-uuid>.cfargotunnel.com Proxied
Test routing:
cloudflared tunnel --config .cloudflared/config.yml ingress rule https://example.com
# Expected: Matched rule #0
Step 8: Add cloudflared to docker-compose.yml
cloudflared:
image: cloudflare/cloudflared
restart: unless-stopped
command: tunnel --config /etc/cloudflared/config.yml run
user: "nonroot"
dns:
- 1.1.1.1
- 1.0.0.1
volumes:
- ./.cloudflared:/etc/cloudflared
Step 9: Set File Permissions
The nonroot user inside the container needs read access to the credentials:
chmod 600 .cloudflared/*.json
chmod 644 .cloudflared/config.yml
Tip: If the container still can't read the JSON file after
600, try644. This is a common issue depending on how Docker volume mounts are configured on your system.
Step 10: Start and Verify
# Start the container
docker compose up cloudflared -d
# Watch the logs
docker compose logs cloudflared -f
A healthy connection looks like:
INF Registered tunnel connection connIndex=0 ...
INF Registered tunnel connection connIndex=1 ...
Check tunnel health in the Cloudflare dashboard under Access > Networks > Connectors. Your tunnel should show a green Healthy status.
Common Issues and How to Fix Them
1. Tunnel shows "Degraded" or "Offline"
The cloudflared container cannot read the credentials file.
ls -la .cloudflared/
chmod 644 .cloudflared/*.json
docker compose restart cloudflared
docker compose logs cloudflared -f
2. ingress validate passes but traffic returns 404
Your catch-all rule is missing or misplaced. It must be last with no hostname:
ingress:
- hostname: example.com
service: https://nginx:443
- service: http_status:404 # must be last, no hostname
3. cert.pem errors after a long period
The cert expires after inactivity. Re-run this to refresh it without affecting your tunnel or DNS:
cloudflared tunnel login
Conclusion
You now have a production-ready, zero-cost tunnel connecting your custom domain to a locally running Docker stack, no open ports, no exposed IPs, no monthly bill.
This setup scales further than you might think. You can add more hostname entries to expose multiple services through the same tunnel, set up Cloudflare Access policies to password-protect specific routes, or layer on Cloudflare's WAF and DDoS protection, all from the same free account.
If something broke along the way, drop your error in the comments. And if everything worked, what are you running behind your tunnel? I'd love to see what you're building.

Top comments (0)