Back to Blog

Exposing Services with Cloudflare Tunnel

Viktor Vasylkovskyi

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:

  1. Install cloudflared binary
  2. Create a tunnel
  3. Create DNS routes mapping domains to the tunnel
  4. Configure which local services the tunnel exposes
  5. 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 login

This 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: started

Add handler in roles/cloudflare/handlers/main.yml:

- name: Restart cloudflared
  ansible.builtin.systemd:
    name: cloudflared
    state: restarted

Update 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
    - cloudflare

Run the Playbook

Now let's apply it all:

ansible-playbook -i inventory/all.yml playbooks/playbook.yml

After 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 cloudflared

Test your domains:

curl https://api.yourdomain.com

Troubleshooting 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.yml

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

Verify Tunnel ID Matches Credentials

The UUID in your credentials file must match your tunnel ID. Check your tunnel list:

cloudflared tunnel list

Output:

ID                                   NAME          CREATED
abc123-uuid-here                     my-rpi-tunnel 2025-10-17T13:18:19Z

The 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 -l

Healthy 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=3

Look for "Registered tunnel connection" messages - you should see at least 4 connections for redundancy.

Verify Tunnel Health in Cloudflare Dashboard

  1. Log into Cloudflare dashboard
  2. Navigate to Zero TrustAccessTunnels
  3. Find your tunnel - it should show as "Healthy" with green status
  4. 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.com

If DNS already exists, you'll see:

The route for api.yourdomain.com already exists

If not, the command will create it.

You can also verify in Cloudflare dashboard:

  1. Go to your domain's DNS settings
  2. Look for a CNAME record for api.yourdomain.com
  3. 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 response

If 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 ps or systemctl 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:

  1. In Cloudflare dashboard, delete the DNS record for your domain
  2. Run from your Pi:
cloudflared tunnel route dns my-rpi-tunnel api.yourdomain.com

This creates a fresh DNS route to your tunnel.

View Real-time Logs

Watch cloudflared logs in real-time:

sudo journalctl -u cloudflared -f

Make 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