jongkwan.dev
Development · Essay №055

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

Jongkwan Lee2026년 3월 2일11 min read
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.

AspectPort forwardingCloudflare Tunnel
Connection directionExternal → internal (inbound)Internal → Cloudflare (outbound only)
Firewall openingRequired (exposed port)Not required
DDoS (Distributed Denial of Service) protectionNoneHandled automatically at the Cloudflare edge
SSL certificatesIssued and renewed by youIssued automatically by Cloudflare
Static IPRequired (or DDNS)Not required
Extra authenticationBuild it yourselfZero 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)

bash
brew install cloudflared
 
# check the version
cloudflared --version

Homebrew is the simplest installation route and works on both Apple Silicon and Intel Macs.

Manual installation (Apple Silicon)

Without Homebrew, download the binary directly.

bash
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)

bash
# 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 cloudflared

Authentication and tunnel creation

Authenticating with a Cloudflare account

First connect cloudflared to the Cloudflare account.

bash
cloudflared tunnel login

This 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

bash
cloudflared tunnel create personaltrader

Output:

text
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.

bash
# list the tunnels
cloudflared tunnel list

Setting up DNS routing

Connecting the tunnel to a domain requires a DNS record, and cloudflared creates the CNAME record automatically.

bash
# 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.dev

Cloudflare 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

text
~/.cloudflared/config.yml

Writing 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.

yaml
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:404

The final catch-all rule (service: http_status:404) is required. Without it, cloudflared fails configuration validation.

Ingress rules at a glance

RuleHostnameTarget servicePort
APIapi.jongkwan.devFastAPI8000
WebSocketjongkwan.dev/ws/*FastAPI WS8000
Frontendjongkwan.devNext.js3000
Fallbackcatch-all404 response-

Validating the configuration

After writing the configuration file, always run the validation step.

bash
# validate the syntax of the configuration file
cloudflared tunnel ingress validate

A healthy configuration prints OK.

bash
# 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)

bash
cloudflared tunnel run personaltrader

A successful connection prints logs similar to the following.

text
INF Starting tunnel
INF Registered tunnel connection connIndex=0
INF Registered tunnel connection connIndex=1

Automatic HTTPS and TLS

With Cloudflare Tunnel there is no SSL/TLS certificate to manage.

Traffic flow

text
User ──(HTTPS)──▶ Cloudflare Edge ──(cloudflared tunnel)──▶ localhost:3000/8000

Encryption on each leg:

LegEncryptionManaged by
User → CloudflareHTTPS (certificate issued automatically)Cloudflare
Cloudflare → cloudflaredTunnel-internal encryptioncloudflared
cloudflared → local serviceHTTP (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.

text
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 even Flexible still 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

bash
# user level (starts at login) - recommended
cloudflared service install
 
# system level (starts at boot)
sudo cloudflared service install

Writing the plist manually

For finer control, write the plist yourself.

File location: ~/Library/LaunchAgents/com.cloudflare.cloudflared.plist

xml
<?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.

KeyValueMeaning
RunAtLoadtrueStart automatically at login
KeepAlive.NetworkStatetrueRestart automatically when the network comes back
ThrottleInterval10Wait 10 seconds before restarting after an abnormal exit

Service management commands

bash
# 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 cloudflare

Reading the logs

bash
# 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.log

Zero 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.

text
Zero Trust → Access → Applications → Add an application

Settings:

FieldValue
Application typeSelf-hosted
Application namePersonalTrader
Application domainjongkwan.dev
Session Duration24 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.

yaml
policy_name: "Owner Only"
decision: Allow
include:
  - email:
    - "owner@example.com"
 
identity_providers:
  - One-time PIN  # Cloudflare emails the OTP

decision 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.

yaml
include:
  - ip_ranges:
    - "203.0.113.0/24"  # home/office IP

Combining 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.

text
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.

yaml
path: /api/health
decision: Bypass

Access policy flow


Day-to-day operations

Checking tunnel status

bash
# check from the CLI
cloudflared tunnel info personaltrader

The Cloudflare dashboard shows the same information.

text
Zero Trust → Networks → Tunnels → personaltrader

Metrics 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.

bash
launchctl stop com.cloudflare.cloudflared
launchctl start com.cloudflare.cloudflared

Deleting a tunnel

bash
cloudflared tunnel delete personaltrader

Deleting a tunnel does not delete its DNS records, so remove the CNAME records manually in the Cloudflare dashboard.


Troubleshooting

Common problems

SymptomCauseFix
ERR Connection refusedThe local service is not runningStart FastAPI/Next.js first, then run the tunnel
ERR Too many connectionsThe tunnel is running more than onceFind duplicate processes with ps aux | grep cloudflared and kill them
DNS does not resolveThe CNAME record was never createdRun cloudflared tunnel route dns again
WebSocket disconnectsconnectTimeout is too lowRaise the timeout in config.yml to 30s or more
Expired cert.pemThe certificate needs renewalRun cloudflared tunnel login again
502 Bad GatewayThe local service responds too slowlyIncrease connectTimeout and tlsTimeout under originRequest

Debugging commands

bash
# 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/stream

Startup order for local services

The tunnel only works if the local services are already running. Recommended order:

bash
# 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 personaltrader

Summary

Key points

  1. Security: no port forwarding, so an outbound-only connection keeps the attack surface minimal
  2. HTTPS: Cloudflare issues and renews the SSL certificate
  3. Multiple services: one tunnel routes frontend, backend, and WebSocket traffic at once
  4. Self-healing: launchd KeepAlive reconnects automatically when the network recovers
  5. 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.