How to Secure Self-Hosted AI Services with Cloudflare Tunnels
Problem
When I started self-hosting AI services at home, I faced a security dilemma. I wanted to access my AI assistant, dashboards, and web UIs from outside my home network, but opening ports on my router felt risky.
I kept thinking about all the bots and scanners constantly probing public IP addresses. Opening ports means:
- My public IP becomes visible to attackers
- Each service needs its own authentication layer
- Certificate management becomes my problem
- DDoS attacks could target my home connection
- SSH exposure creates a major attack vector
I tried setting up a VPN, but connecting and disconnecting constantly was annoying. I also looked at reverse proxies with Let’s Encrypt, but certificate renewal and security configuration felt like too much overhead.
Environment
- Ubuntu Server 22.04 LTS
- UFW firewall (default deny incoming)
- Multiple self-hosted services: AI web UI (port 3000), Grafana (port 3001), InfluxDB (port 8086)
- Domain managed by Cloudflare
- No ports forwarded on router
What happened?
I discovered Cloudflare Tunnels as a solution. The concept is simple: instead of opening ports and letting traffic in, I create an outbound tunnel to Cloudflare’s edge. Traffic flows like this:
Traditional Setup (Risky):Internet --> Router (Port Forward) --> Firewall --> Service
Cloudflare Tunnel (Secure):Internet --> Cloudflare Edge --> Access (Email OTP) --> Tunnel --> Service | +-- No firewall ports opened +-- DDoS protection built-in +-- Automatic HTTPS +-- Zero-trust authenticationHere’s how I set it up.
How to solve it?
Step 1: Install cloudflared
First, I installed the cloudflared CLI tool:
# Download the latest releasewget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
# Install the packagesudo dpkg -i cloudflared-linux-amd64.deb
# Verify installationcloudflared --versionStep 2: Authenticate and create tunnel
Next, I authenticated with Cloudflare and created a tunnel:
# Login to Cloudflare (opens browser)cloudflared tunnel login
# Create a tunnelcloudflared tunnel create ai-homelab
# Output shows tunnel ID# Tunnel credentials written to ~/.cloudflared/<TUNNEL_ID>.jsonI made note of the tunnel ID from the output. This ID is important for the configuration.
Step 3: Configure tunnel routes
I routed the tunnel to my domain:
# Route tunnel to your domaincloudflared tunnel route dns ai-homelab ai.yourdomain.comcloudflared tunnel route dns ai-homelab grafana.yourdomain.comStep 4: Create tunnel configuration
I created a configuration file for the tunnel:
tunnel: <YOUR_TUNNEL_ID>credentials-file: /home/user/.cloudflared/<YOUR_TUNNEL_ID>.json
ingress: # Route AI web UI - hostname: ai.yourdomain.com service: http://localhost:3000
# Route Grafana dashboard - hostname: grafana.yourdomain.com service: http://localhost:3001
# Route Home Assistant (optional) - hostname: home.yourdomain.com service: http://localhost:8123
# Catch-all (required) - service: http_status:404Step 5: Run tunnel as service
I set up cloudflared to run as a systemd service:
# Install as system servicesudo cloudflared service install
# Enable and startsudo systemctl enable cloudflaredsudo systemctl start cloudflared
# Verify statussudo systemctl status cloudflaredStep 6: Configure Cloudflare Access
The tunnel provides connectivity, but I needed authentication too. I configured Cloudflare Access with email OTP:
- Open Cloudflare Zero Trust dashboard
- Go to Access > Applications
- Create a new application for each hostname
- Add a policy with action “Allow” for emails ending with my domain
- Enable “Email OTP” as the login method
Now when I visit https://ai.yourdomain.com, I get an email OTP prompt before reaching my service.
Step 7: Lock down firewall
Finally, I configured UFW to block all incoming traffic except SSH from my LAN:
# Reset UFWsudo ufw --force reset
# Default policiessudo ufw default deny incomingsudo ufw default allow outgoing
# SSH only from LANsudo ufw allow from 192.168.1.0/24 to any port 22 comment 'SSH LAN only'
# Enable firewallsudo ufw enable
# Check statussudo ufw status verboseHere’s the key part: no external ports are opened. The tunnel uses outbound connections only.
Verification
I ran a quick check to confirm everything was working:
# Check firewallsudo ufw status numbered
# Check for externally bound portssudo ss -tlnp | grep -E "0.0.0.0|::" || echo "No externally bound ports - GOOD!"
# Check tunnel statussudo systemctl is-active cloudflared && echo "Tunnel: ACTIVE"
# Test external accesscurl -s -o /dev/null -w "%{http_code}" https://ai.yourdomain.com# Should return 401/403 without auth (Access is working)You can see that I succeeded in creating a secure setup with zero exposed ports.
The reason
I think the key reason this works is that Cloudflare Tunnels reverse the traditional network flow. Instead of my server accepting incoming connections, it maintains an outbound connection to Cloudflare’s edge.
┌─────────────────────┐│ Cloudflare Edge ││ (DDoS Protection) │└──────────┬──────────┘ │┌──────────▼──────────┐│ Cloudflare Access ││ (Email OTP Auth) │└──────────┬──────────┘ │┌──────────▼──────────┐│ cloudflared Tunnel ││ (Outbound Only) │└──────────┬──────────┘ │┌──────────▼──────────┐│ UFW Firewall ││ SSH: LAN only ││ All ports: CLOSED │└─────────────────────┘Benefits I get:
- Zero attack surface: No ports opened on firewall
- DDoS protection: Cloudflare’s edge absorbs attack traffic
- Automatic HTTPS: No certificate management
- Email OTP: User-friendly authentication
- Audit logging: All access attempts are logged
Common mistakes I avoided
1. Binding services to 0.0.0.0
Services should only listen on localhost:
# WRONG - accessible from networkservice: host: 0.0.0.0 port: 3000
# CORRECT - localhost onlyservice: host: 127.0.0.1 port: 30002. Hardcoding secrets in config files
Never put secrets directly in configuration:
# Create secure secrets directorymkdir -p ~/.openclaw/secretschmod 700 ~/.openclaw/secrets
# Store API key securelyecho "your-api-key" > ~/.openclaw/secrets/api_keychmod 600 ~/.openclaw/secrets/api_key3. Exposing SSH to the internet
SSH should never be internet-accessible when using tunnels:
# WRONG - SSH exposed to internetsudo ufw allow 22
# CORRECT - SSH only from LANsudo ufw allow from 192.168.1.0/24 to any port 224. Forgetting Access policies
The tunnel provides connectivity, not security. I always configure at least one Access policy with email OTP enabled.
Summary
In this post, I showed how to secure self-hosted AI services using Cloudflare Tunnels with zero exposed ports. The key point is using outbound-only tunnels combined with Cloudflare Access email OTP for authentication. This eliminates port forwarding risks while adding enterprise-grade security features like DDoS protection and automatic HTTPS.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Reddit: I gave my home a brain. Here's what 50 days of self-hosted AI looks like
- 👨💻 Cloudflare Tunnels Documentation
- 👨💻 Cloudflare Access Documentation
- 👨💻 Cloudflare Zero Trust Overview
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments