Exposing Services with Cloudflare Tunnel
Previous: Ansible Base Software Setup
Most home networks use CGNAT (Carrier-Grade NAT), which means your Raspberry Pi shares an IP address with other devices and cannot be directly accessed from the internet. This is a common problem! Cloudflare Tunnel solves this elegantly by creating a secure outbound connection from your Pi to Cloudflare's network, allowing you to expose services without port forwarding or a public IP. Let's dive in!
Github Repository
The complete Cloudflare tunnel Ansible role and configuration from this guide are available in https://github.com/IaC-Toolbox/iac-toolbox-raspberrypi. Feel free to clone it and follow along!
Why Cloudflare Tunnel?
Here's why this solution is so powerful:
No Port Forwarding: Works behind CGNAT and restrictive firewalls (no more fighting with your router!)
Free HTTPS: Automatic SSL/TLS certificates for your domains (yes, completely free!)
Secure: Traffic is encrypted end-to-end through Cloudflare's network
Simple DNS: Cloudflare manages DNS records automatically (one less thing to configure manually!)
Prerequisites
- A domain name (you can use Cloudflare's free DNS service)
- Cloudflare account with your domain added
- Services running on your Raspberry Pi (we'll use ports 4000 and 4001 as examples)
Manual Setup Overview
We'll automate this with Ansible, but understanding the manual steps helps with troubleshooting:
- Install cloudflared binary
- Create a tunnel
- Create DNS routes mapping domains to the tunnel
- Configure which local services the tunnel exposes
- Run cloudflared as a system service
Automated Setup with Ansible
Create roles/cloudflare/tasks/main.yml:
- name: Download cloudflared binary
ansible.builtin.get_url:
url: https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64
dest: /usr/local/bin/cloudflared
mode: '0755'
- name: Verify cloudflared is executable
ansible.builtin.command: cloudflared --version
register: cloudflared_version
changed_when: false
- name: Create .cloudflared directory
ansible.builtin.file:
path: '/home/{{ cloudflare_user }}/.cloudflared'
state: directory
owner: '{{ cloudflare_user }}'
group: '{{ cloudflare_user }}'
mode: '0700'Authenticating Cloudflare
Before running the Ansible playbook, authenticate cloudflared with your Cloudflare account:
# SSH into your Pi
ssh pi@raspberrypi.local
# Authenticate (this will open a browser)
cloudflared tunnel loginThis creates a cert file at ~/.cloudflared/cert.pem that authorizes your Pi to create tunnels.
Complete Ansible Role
Continue roles/cloudflare/tasks/main.yml:
- name: Get list of existing tunnels
ansible.builtin.command: cloudflared tunnel list --output json
register: tunnel_list
failed_when: false
changed_when: false
become_user: '{{ cloudflare_user }}'
- name: Create tunnel if not exists
ansible.builtin.command: >
cloudflared tunnel create {{ tunnel_name }}
args:
chdir: '/home/{{ cloudflare_user }}/.cloudflared'
become_user: '{{ cloudflare_user }}'
when: '''"name": "'' + tunnel_name + ''"'' not in tunnel_list.stdout
- name: Get tunnel ID
ansible.builtin.command: cloudflared tunnel list --output json
register: tunnel_list_json
changed_when: false
become_user: '{{ cloudflare_user }}'
- name: Extract tunnel ID
ansible.builtin.set_fact:
tunnel_id: "{{ (tunnel_list_json.stdout | from_json) | selectattr('name','equalto', tunnel_name) | map(attribute='id') | first }}"
- name: Wait for tunnel to be fully registered
ansible.builtin.pause:
seconds: 5
- name: Create DNS routes
ansible.builtin.command: >
cloudflared tunnel route dns {{ tunnel_name }} {{ item.hostname }}
args:
chdir: '/home/{{ cloudflare_user }}/.cloudflared'
become_user: '{{ cloudflare_user }}'
loop: '{{ domains }}'
when: tunnel_id is defined
- name: Build ingress configuration
ansible.builtin.set_fact:
ingress_config: |
{% for domain in domains %}
- hostname: {{ domain.hostname }}
service: http://localhost:{{ domain.service_port }}
{% endfor %}
- service: http_status:404
- name: Create config.yml
ansible.builtin.copy:
dest: '/home/{{ cloudflare_user }}/.cloudflared/config.yml'
content: |
tunnel: {{ tunnel_name }}
credentials-file: /home/{{ cloudflare_user }}/.cloudflared/{{ tunnel_id }}.json
ingress:
{{ ingress_config | indent(2, true) }}
owner: '{{ cloudflare_user }}'
group: '{{ cloudflare_user }}'
mode: '0600'
- name: Create systemd service
ansible.builtin.copy:
dest: /etc/systemd/system/cloudflared.service
content: |
[Unit]
Description=Cloudflare Tunnel
After=network.target
[Service]
Type=simple
User={{ cloudflare_user }}
ExecStart=/usr/local/bin/cloudflared --config /home/{{ cloudflare_user }}/.cloudflared/config.yml tunnel run {{ tunnel_name }}
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
notify: Restart cloudflared
- name: Enable and start service
ansible.builtin.systemd:
name: cloudflared
daemon_reload: yes
enabled: yes
state: startedAdd handler in roles/cloudflare/handlers/main.yml:
- name: Restart cloudflared
ansible.builtin.systemd:
name: cloudflared
state: restartedUpdate Playbook
Update playbooks/playbook.yml to include the cloudflare role:
- name: Setup Raspberry Pi
hosts: all
become: true
vars:
cloudflare_user: 'pi'
tunnel_name: 'my-rpi-tunnel'
domains:
- hostname: 'api.yourdomain.com'
service_port: 4000
- hostname: 'app.yourdomain.com'
service_port: 4001
roles:
- setup
- docker
- cloudflareRun the Playbook
Now let's apply it all:
ansible-playbook -i inventory/all.yml playbooks/playbook.ymlAfter completion, your services will be accessible at the configured domains with automatic HTTPS! Pretty cool, right?
Verify the Tunnel
Check tunnel status on your Pi:
sudo systemctl status cloudflaredTest your domains:
curl https://api.yourdomain.comTroubleshooting Cloudflare Tunnel
When things don't work as expected, here's how to debug your Cloudflare tunnel setup. Let's walk through the most common issues!
Check Tunnel Configuration
First, verify your tunnel configuration file:
cat ~/.cloudflared/config.ymlYou should see something like:
tunnel: my-rpi-tunnel
credentials-file: /home/pi/.cloudflared/abc123-uuid-here.json
ingress:
- hostname: api.yourdomain.com
service: http://localhost:4000
- service: http_status:404Verify Tunnel ID Matches Credentials
The UUID in your credentials file must match your tunnel ID. Check your tunnel list:
cloudflared tunnel listOutput:
ID NAME CREATED
abc123-uuid-here my-rpi-tunnel 2025-10-17T13:18:19ZThe ID in tunnel list should match the UUID in your credentials-file path. If they don't match, your tunnel won't connect!
Check Tunnel Service Status
Verify the cloudflared service is running:
sudo systemctl status cloudflared -lHealthy output looks like:
● cloudflared.service - Cloudflare Tunnel Service
Loaded: loaded (/etc/systemd/system/cloudflared.service; enabled)
Active: active (running) since Fri 2025-10-17 14:56:02
Oct 17 14:56:03 raspberrypi cloudflared[60676]: INF Starting metrics server on 127.0.0.1:20241/metrics
Oct 17 14:56:03 raspberrypi cloudflared[60676]: INF Registered tunnel connection connIndex=0
Oct 17 14:56:04 raspberrypi cloudflared[60676]: INF Registered tunnel connection connIndex=1
Oct 17 14:56:05 raspberrypi cloudflared[60676]: INF Registered tunnel connection connIndex=2
Oct 17 14:56:06 raspberrypi cloudflared[60676]: INF Registered tunnel connection connIndex=3Look for "Registered tunnel connection" messages - you should see at least 4 connections for redundancy.
Verify Tunnel Health in Cloudflare Dashboard
- Log into Cloudflare dashboard
- Navigate to Zero Trust → Access → Tunnels
- Find your tunnel - it should show as "Healthy" with green status
- Check that "Connectors" shows 4 active connections
If the tunnel shows as "Down" or "Inactive", your cloudflared service isn't connecting properly. Check the service logs.
Check DNS Configuration
Verify DNS is pointing to your tunnel:
cloudflared tunnel route dns my-rpi-tunnel api.yourdomain.comIf DNS already exists, you'll see:
The route for api.yourdomain.com already existsIf not, the command will create it.
You can also verify in Cloudflare dashboard:
- Go to your domain's DNS settings
- Look for a CNAME record for
api.yourdomain.com - It should point to
<tunnel-id>.cfargotunnel.com
Test Local Service First
Before blaming the tunnel, make sure your local service actually works:
# Test from the Pi itself
curl http://localhost:4000
# Should return your application responseIf this fails, your application isn't running or isn't listening on the correct port. Fix that first!
Common Issues and Solutions
Tunnel shows as "Down":
- Check if cloudflared service is running:
sudo systemctl status cloudflared - Restart the service:
sudo systemctl restart cloudflared - Check logs:
sudo journalctl -u cloudflared -n 50
502 Bad Gateway:
- Your tunnel is working, but the local service isn't responding
- Verify service is running:
docker psorsystemctl status your-service - Check the port number in config.yml matches your service
DNS doesn't resolve:
- DNS changes can take time to propagate (up to 5 minutes)
- Try flushing your local DNS cache
- Test with:
dig api.yourdomain.com- should show a CNAME record
Connection timeout:
- Firewall on Pi might be blocking the port
- Check with:
sudo ufw status - Your service might be binding to 127.0.0.1 instead of 0.0.0.0
Manually Re-create DNS Route
If DNS routing is broken, delete and recreate it:
- In Cloudflare dashboard, delete the DNS record for your domain
- Run from your Pi:
cloudflared tunnel route dns my-rpi-tunnel api.yourdomain.comThis creates a fresh DNS route to your tunnel.
View Real-time Logs
Watch cloudflared logs in real-time:
sudo journalctl -u cloudflared -fMake a request to your domain and watch for errors. You'll see each request hit the tunnel and forward to your local service.
Next Steps
And that's a wrap! With Cloudflare Tunnel configured, your Raspberry Pi services are now accessible from anywhere in the world. In the next section, we'll set up a complete CI/CD pipeline that automatically builds and deploys Docker containers. Continue reading!
Previous: Ansible Base Software Setup | Next: Configure GitHub Runner