Exposing a Home Server Safely with Cloudflare Tunnel
How to publish local services to the internet over an outbound-only connection, with no port forwarding
Contents
Publish a home server to the internet through an outbound-only tunnel instead of port forwarding, and gate access with Zero Trust policies.
Why Cloudflare Tunnel
Exposing a home server or a local development environment normally means configuring port forwarding on the router. That approach carries several security downsides.
| Aspect | Port forwarding | Cloudflare Tunnel |
|---|---|---|
| Connection direction | External → internal (inbound) | Internal → Cloudflare (outbound only) |
| Firewall opening | Required (exposed port) | Not required |
| DDoS (Distributed Denial of Service) protection | None | Handled automatically at the Cloudflare edge |
| SSL certificates | Issued and renewed by you | Issued automatically by Cloudflare |
| Static IP | Required (or DDNS) | Not required |
| Extra authentication | Build it yourself | Zero Trust Access policies provided |
Cloudflare Tunnel opens only an outbound connection from the local machine to the Cloudflare edge. No path exists for the outside world to reach the local network directly, so the attack surface shrinks considerably compared with port forwarding.
How it works
The cloudflared daemon opens an outbound connection from the local machine to a Cloudflare edge server, and external traffic reaches local services only through that tunnel. No port has to be opened on the router or the firewall.
Installing cloudflared
macOS (Homebrew)
brew install cloudflared
# check the version
cloudflared --versionHomebrew is the simplest installation route and works on both Apple Silicon and Intel Macs.
Manual installation (Apple Silicon)
Without Homebrew, download the binary directly.
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz | tar xz
sudo mv cloudflared /usr/local/bin/Linux (Debian/Ubuntu)
# add the official package repository
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 $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflaredAuthentication and tunnel creation
Authenticating with a Cloudflare account
First connect cloudflared to the Cloudflare account.
cloudflared tunnel loginThis opens a browser and shows a Cloudflare dashboard screen for selecting a domain. Approving it creates the certificate at ~/.cloudflared/cert.pem.
Creating the tunnel
cloudflared tunnel create personaltraderOutput:
Tunnel credentials written to /Users/jk/.cloudflared/<TUNNEL-UUID>.json
Created tunnel personaltrader with id <TUNNEL-UUID>The tunnel credentials file (<TUNNEL-UUID>.json) is stored in ~/.cloudflared/. It authenticates the tunnel at run time, so keep it protected.
# list the tunnels
cloudflared tunnel listSetting up DNS routing
Connecting the tunnel to a domain requires a DNS record, and cloudflared creates the CNAME record automatically.
# route the apex domain to the tunnel
cloudflared tunnel route dns personaltrader jongkwan.dev
# add an API subdomain
cloudflared tunnel route dns personaltrader api.jongkwan.devCloudflare DNS gets a CNAME pointing at <TUNNEL-UUID>.cfargotunnel.com. Adding the same CNAME by hand in the dashboard produces an identical result.
Multi-service routing with config.yml
Configuration file location
~/.cloudflared/config.ymlWriting ingress rules
Routing several services through one tunnel is the central feature of Cloudflare Tunnel. ingress rules are matched top to bottom, and the first matching rule wins.
tunnel: <TUNNEL-UUID>
credentials-file: /Users/jk/.cloudflared/<TUNNEL-UUID>.json
# ingress rules (matched top to bottom)
ingress:
# API subdomain -> FastAPI (backend)
- hostname: api.jongkwan.dev
service: http://localhost:8000
originRequest:
connectTimeout: 10s
noTLSVerify: false
# WebSocket path -> FastAPI WebSocket
- hostname: jongkwan.dev
path: /ws/*
service: http://localhost:8000
originRequest:
connectTimeout: 30s
# apex domain -> Next.js (frontend)
- hostname: jongkwan.dev
service: http://localhost:3000
# fallback (required - reject anything that did not match)
- service: http_status:404The final catch-all rule (
service: http_status:404) is required. Without it,cloudflaredfails configuration validation.
Ingress rules at a glance
| Rule | Hostname | Target service | Port |
|---|---|---|---|
| API | api.jongkwan.dev | FastAPI | 8000 |
| WebSocket | jongkwan.dev/ws/* | FastAPI WS | 8000 |
| Frontend | jongkwan.dev | Next.js | 3000 |
| Fallback | catch-all | 404 response | - |
Validating the configuration
After writing the configuration file, always run the validation step.
# validate the syntax of the configuration file
cloudflared tunnel ingress validateA healthy configuration prints OK.
# test which rule a given URL matches
cloudflared tunnel ingress rule https://jongkwan.dev
# → Using rule 3 (hostname: jongkwan.dev → http://localhost:3000)
cloudflared tunnel ingress rule https://api.jongkwan.dev/v1/health
# → Using rule 1 (hostname: api.jongkwan.dev → http://localhost:8000)
cloudflared tunnel ingress rule https://jongkwan.dev/ws/stream
# → Using rule 2 (hostname: jongkwan.dev, path: /ws/* → http://localhost:8000)Running the tunnel manually (for testing)
cloudflared tunnel run personaltraderA successful connection prints logs similar to the following.
INF Starting tunnel
INF Registered tunnel connection connIndex=0
INF Registered tunnel connection connIndex=1Automatic HTTPS and TLS
With Cloudflare Tunnel there is no SSL/TLS certificate to manage.
Traffic flow
User ──(HTTPS)──▶ Cloudflare Edge ──(cloudflared tunnel)──▶ localhost:3000/8000Encryption on each leg:
| Leg | Encryption | Managed by |
|---|---|---|
| User → Cloudflare | HTTPS (certificate issued automatically) | Cloudflare |
| Cloudflare → cloudflared | Tunnel-internal encryption | cloudflared |
| cloudflared → local service | HTTP (localhost) | Not needed |
Local services can keep running on plain http://localhost. External users always connect over HTTPS, and Cloudflare handles certificate issuance and renewal.
SSL settings in the Cloudflare dashboard
For the strongest configuration, use the following settings.
SSL/TLS → Overview → Full (strict)
SSL/TLS → Edge Certificates → Always Use HTTPS: ON
SSL/TLS → Edge Certificates → Automatic HTTPS Rewrites: ON
Full (strict)is the recommended mode with a tunnel, but evenFlexiblestill guarantees HTTPS between the user and Cloudflare. The important part is that the tunnel itself is an encrypted channel.
Registering a launchd service on macOS
Running cloudflared tunnel run from a terminal ties the tunnel to that terminal session; close it and the tunnel dies. macOS launchd provides automatic start at login and automatic restart after an abnormal exit.
Automatic service installation
# user level (starts at login) - recommended
cloudflared service install
# system level (starts at boot)
sudo cloudflared service installWriting the plist manually
For finer control, write the plist yourself.
File location: ~/Library/LaunchAgents/com.cloudflare.cloudflared.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cloudflare.cloudflared</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/cloudflared</string>
<string>tunnel</string>
<string>--config</string>
<string>/Users/jk/.cloudflared/config.yml</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>NetworkState</key>
<true/>
</dict>
<key>StandardOutPath</key>
<string>/Users/jk/.cloudflared/cloudflared.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/jk/.cloudflared/cloudflared.err.log</string>
<key>ThrottleInterval</key>
<integer>10</integer>
</dict>
</plist>ProgramArguments is the command launchd runs; it is cloudflared tunnel --config <path> run spelled out as an argument array. The key entries mean the following.
| Key | Value | Meaning |
|---|---|---|
RunAtLoad | true | Start automatically at login |
KeepAlive.NetworkState | true | Restart automatically when the network comes back |
ThrottleInterval | 10 | Wait 10 seconds before restarting after an abnormal exit |
Service management commands
# load (register) the service
launchctl load ~/Library/LaunchAgents/com.cloudflare.cloudflared.plist
# start the service
launchctl start com.cloudflare.cloudflared
# stop the service
launchctl stop com.cloudflare.cloudflared
# unload (deregister) the service
launchctl unload ~/Library/LaunchAgents/com.cloudflare.cloudflared.plist
# check the status
launchctl list | grep cloudflareReading the logs
# when installed via cloudflared service install
tail -f /Library/Logs/com.cloudflare.cloudflared.out.log
tail -f /Library/Logs/com.cloudflare.cloudflared.err.log
# when installed with a custom plist
tail -f ~/.cloudflared/cloudflared.out.log
tail -f ~/.cloudflared/cloudflared.err.logZero Trust Access policies
Cloudflare Tunnel alone already avoids exposing ports, but adding a Zero Trust Access policy restricts the service to authorized users. Email one-time passwords (OTP), IP restrictions, and similar controls combine into an authentication layer.
Creating an Access application
Register the application in the Cloudflare Zero Trust dashboard.
Zero Trust → Access → Applications → Add an applicationSettings:
| Field | Value |
|---|---|
| Application type | Self-hosted |
| Application name | PersonalTrader |
| Application domain | jongkwan.dev |
| Session Duration | 24 hours |
Configuring the Access policy
Email-based authentication (one-time PIN)
This is the simplest option. Cloudflare sends an OTP to the configured email address, and entering it grants access.
policy_name: "Owner Only"
decision: Allow
include:
- email:
- "owner@example.com"
identity_providers:
- One-time PIN # Cloudflare emails the OTPdecision sets what happens on a match: allow, block, or bypass. include lists what the policy covers, here a specific email address. identity_providers picks the authentication method. It works without integrating a separate identity provider (IdP), which suits personal projects.
IP range restriction (optional)
Access can also be limited to specific IP ranges.
include:
- ip_ranges:
- "203.0.113.0/24" # home/office IPCombining the email OTP with an IP restriction gives an effect similar to two-factor authentication.
Protecting API endpoints
For the API subdomain, either configure a separate Access application or use a service token. Service tokens suit automated API calls such as CI/CD pipelines and cron jobs.
Application domain: api.jongkwan.dev
Policy: Service Auth
- Service Token: <auto-generated token>Clients must send the CF-Access-Client-Id and CF-Access-Client-Secret headers with each request.
Configuring bypass paths
Paths that need no authentication, such as health checks, are opened with a bypass rule.
path: /api/health
decision: BypassAccess policy flow
Day-to-day operations
Checking tunnel status
# check from the CLI
cloudflared tunnel info personaltraderThe Cloudflare dashboard shows the same information.
Zero Trust → Networks → Tunnels → personaltraderMetrics available in the dashboard:
- Active connections: number of currently active connections
- Requests per second: request throughput
- Error rate: share of failed requests
Restarting after a configuration change
Changes to config.yml take effect only after the service restarts.
launchctl stop com.cloudflare.cloudflared
launchctl start com.cloudflare.cloudflaredDeleting a tunnel
cloudflared tunnel delete personaltraderDeleting a tunnel does not delete its DNS records, so remove the CNAME records manually in the Cloudflare dashboard.
Troubleshooting
Common problems
| Symptom | Cause | Fix |
|---|---|---|
ERR Connection refused | The local service is not running | Start FastAPI/Next.js first, then run the tunnel |
ERR Too many connections | The tunnel is running more than once | Find duplicate processes with ps aux | grep cloudflared and kill them |
| DNS does not resolve | The CNAME record was never created | Run cloudflared tunnel route dns again |
| WebSocket disconnects | connectTimeout is too low | Raise the timeout in config.yml to 30s or more |
Expired cert.pem | The certificate needs renewal | Run cloudflared tunnel login again |
| 502 Bad Gateway | The local service responds too slowly | Increase connectTimeout and tlsTimeout under originRequest |
Debugging commands
# detailed tunnel status
cloudflared tunnel info personaltrader
# follow the tunnel logs live (debug level)
cloudflared tunnel --loglevel debug run personaltrader
# test an ingress rule
cloudflared tunnel ingress rule https://jongkwan.dev/ws/streamStartup order for local services
The tunnel only works if the local services are already running. Recommended order:
# 1. start the FastAPI backend
uvicorn app.main:app --host 0.0.0.0 --port 8000
# 2. start the Next.js frontend
pnpm dev # localhost:3000
# 3. start Cloudflare Tunnel (or let launchd start it)
cloudflared tunnel run personaltraderSummary
Key points
- Security: no port forwarding, so an outbound-only connection keeps the attack surface minimal
- HTTPS: Cloudflare issues and renews the SSL certificate
- Multiple services: one tunnel routes frontend, backend, and WebSocket traffic at once
- Self-healing: launchd
KeepAlivereconnects automatically when the network recovers - Access control: Zero Trust Access policies add email OTP, IP restrictions, and other checks
Cloudflare Tunnel fits well when a home server has to be reachable from the internet without opening ports. An outbound-only connection shrinks the attack surface, and certificate issuance and renewal move to Cloudflare. A single tunnel routes frontend, backend, and WebSocket traffic simultaneously, and Zero Trust Access policies gate who gets through. The trade-off is that all traffic passes through Cloudflare, so it is a good fit only when that external dependency and the added latency are acceptable.