---
title: "Raspberry Pi and Cloudflare Tunnel: a practical guide to Access and automatic startup"
source: "https://www.matteovincenti.com/en/blog/raspberry-pi-cloudflare-tunnel-access-guide"
language: en
author: "Matteo Vincenti"
published: 2026-10-28
category: "Code"
tags: ["Raspberry Pi", "Cloudflare", "Security"]
---

# Raspberry Pi and Cloudflare Tunnel: a practical guide to Access and automatic startup

Connect a Raspberry Pi to Cloudflare Tunnel: set up a domain, DNS and Cloudflare Access, test access controls and start the lab automatically.

![A stylized Raspberry Pi board connects to a cloud through a green tunnel with a padlock.](https://cdn.sanity.io/images/xrqkwry7/production/ab67d193fa6fb73d3bff8d61a0d7d9e450db6568-1731x909.png?w=1400&fit=max&auto=format)

Category: Code · Tags: Raspberry Pi, Cloudflare, Security

A Raspberry Pi can host a small web application at an HTTPS address without opening an inbound port on the router. Before setting up the connection, though, you need to make a decision: should the page be public, or should only authorized people be able to access it?

This guide covers the second case. We start with a harmless HTML page, connect the Raspberry Pi through Cloudflare Tunnel, and use Cloudflare Access to require a login. Then we add a machine client and automatic startup as separate extensions. Each step includes a check: the aim is to understand the path a request takes and recognize which component is failing.

The example can become a starting point for a small internal portal, a project preview, or a lab service. The demonstration page lets us verify the connection before attaching an application with real data.

## How DNS, Tunnel, and Access fit together

The domain is the registered name, such as `example.com`. The registrar manages its registration and nameserver delegation; the authoritative DNS provider publishes its records. These can be different companies. Using Cloudflare for DNS does not require transferring the domain registration to Cloudflare.

The subdomain `lab.example.com` identifies the service. Its DNS record directs visitors to Cloudflare; the tunnel route maps that name to the local origin, which is the HTTP server on the Raspberry Pi.

`cloudflared` is the program that opens and maintains outbound connections from the Raspberry Pi to Cloudflare. Requests arrive through that connection. This approach does not require a static public IP address or a dynamic DNS service.

Access decides who can reach the application. The browser encounters the identity check before the request is forwarded to the origin. In this lab, we also enable Access token validation in the connector: Cloudflare signs a proof of access called a JWT, and `cloudflared` validates it before contacting the local server.

The browser uses HTTPS to reach Cloudflare, and the tunnel connection is encrypted too. The last hop will use HTTP on `127.0.0.1`, within the same Raspberry Pi. If you move the origin to another machine, you need to reassess that connection and its protection.

## Prerequisites and lab names

The starting point is a Raspberry Pi already running 64-bit Raspberry Pi OS or Debian, with Bash, `systemd`, `sudo` access, and an Internet connection. Installing the operating system and setting up initial SSH access come before this guide.

You also need a Cloudflare account, a domain you can administer, access to its registrar if you need to change nameservers, and an email account to authorize in Access. The procedure uses a DNS zone managed by Cloudflare in full setup mode.

The lab fits within the DNS Free and Zero Trust Free plans; the latter currently has a limit of 50 users. The domain, hardware, and Internet connection are separate costs. Activating Zero Trust requires choosing a plan and entering payment details even when selecting Free; Cloudflare states that the free plan does not incur a charge. Check the [plan terms](https://www.cloudflare.com/plans/) and [activation requirements](https://developers.cloudflare.com/cloudflare-one/setup/) before proceeding.

Run all Linux commands, including the `dig` and `curl` checks, on the lab Raspberry Pi. You can use three terminals or SSH sessions: one for the web server, one for the connector, and one for checks. A browser on another device is enough for tests from an external connection.

`example.com` is a domain reserved for documentation. Replace `lab.example.com` with an unused subdomain of your domain, keeping the same name in DNS, Access, routes, and commands.

Check the environment:

```bash
cat /etc/os-release
dpkg --print-architecture
getconf LONG_BIT
systemctl --version
```

For the 64-bit Raspberry Pi setup assumed here, the package architecture should be `arm64` and the bit width `64`. Do not install an `amd64` package by following instructions intended for a conventional PC.

Install the tools used in the following steps on the lab machine if they are missing:

```bash
sudo apt-get update
sudo apt-get install python3 curl ca-certificates dnsutils iproute2
```

The network must allow DNS and outbound tunnel connections on port `7844`: UDP for QUIC and TCP for HTTP/2. Automatic protocol selection can fall back to HTTP/2 if UDP is unavailable. Access validation also needs outbound HTTPS to your organization's `NOME_TEAM.cloudflareaccess.com` domain. A restrictive firewall must follow the [official Cloudflare destination list](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/tunnel-with-firewall/). Do not open `18080` on the router: that is the example's local port.

## Choosing a domain and where to register it

For the lab, we will use an address such as `lab.example.com`. You register the domain `example.com`; `lab` is a subdomain you create in its DNS zone. You do not need to buy a domain for every application.

Cloudflare provides several distinct services: it can register a domain, manage its DNS, receive web requests, and control who can access an application. In this guide, we use DNS, Tunnel, and Access. Registering the domain with Cloudflare is optional.

The next step depends on your starting point:

_Choosing a domain and where to register it_

| Situation | What you need to do |
| --- | --- |
| You already have an active domain on Cloudflare | Check its existing records and continue with the lab setup. |
| You have a domain registered elsewhere | Keep its registration where it is, add the zone to Cloudflare, and change its nameservers at the registrar. |
| You do not have a domain | Register one with Cloudflare Registrar or with a registrar that allows external nameservers. |

The registrar continues to handle domain renewal. Moving DNS to Cloudflare does not move the website to another hosting provider or transfer email accounts: the records must continue to point to their respective services.

### Registering a new domain

You can buy it directly from the Cloudflare dashboard under **Domains → Registrations → Buy domain**. This opens **Register domain**: search for the name, compare the available options, and select the one you want to buy. Before confirming, check the initial price, renewal price, registration period, and registrant contact details. The domain costs money even if you use the DNS Free plan.

Cloudflare does not register every extension: see the [list of supported TLDs](https://developers.cloudflare.com/registrar/top-level-domains/). Pricing depends on the name and extension; do not infer it from the cost of another domain.

Complete the required email verification and check automatic renewal, which is enabled by default. Domains purchased through Cloudflare Registrar already use Cloudflare nameservers. While their registration remains there, you cannot replace those nameservers with another DNS provider's. The [official registration procedure](https://developers.cloudflare.com/registrar/get-started/register-domain/) covers the requirements and purchase confirmation.

You can also register the domain with another provider. First check that it allows nameserver changes: you will need that provider's panel, rather than the hosting or Raspberry Pi settings, for the delegation in the next step.

### Connecting an existing domain to Cloudflare

In the approach used here, called **full setup**, **the domain's authoritative nameservers must become those assigned by Cloudflare**. Its registration can remain with the current provider. This is the standard setup available on the DNS Free plan; the documentation distinguishes other setup types, which we will not use in this lab. [Full setup](https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/).

In the dashboard observed for this guide, the path is **Domains → Overview → Add domain → Connect a domain**. Some versions of the documentation call it **Onboard a domain**. Choose the option to connect the domain: **Transfer a domain** would transfer its registration instead.

Enter the apex domain, such as `example.com`, without `https://`, paths, or `lab`. In **Import DNS records**, you can choose an automatic scan, manual entry, or a zone file. For a domain already in use, the scan helps, but you must compare its results with the current provider's records.

![Cloudflare DNS record import options](https://cdn.sanity.io/images/xrqkwry7/production/644a09192a5c7da38a5b94dd77a5de25c57b330a-706x308.jpg?w=1400&fit=max&auto=format)

DNS import: the automatic scan finds common records. The other options are manual entry and importing a zone file.

Select **Continue** and choose the Free plan when prompted. Onboarding may also show options for crawlers and the `robots.txt` file: these affect website behavior and do not perform DNS delegation. Assess them for your website without confusing them with user authorization in Access.

Before changing nameservers, save an inventory of your DNS records and check the following in Cloudflare:

- the records for the main website, `www`, and other subdomains in use;
- the MX, SPF, DKIM, and DMARC email records;
- TXT verification records and any CAA records required by existing services.

Keep the values specified by each provider. The scan may miss custom names, including some DKIM records: an incomplete import can interrupt the website or email when authoritative DNS changes. [Automatic scan limitations](https://developers.cloudflare.com/dns/zone-setups/reference/dns-quick-scan/).

Also check **Proxy status** for imported A, AAAA, and CNAME records. **Proxied**, shown with an orange cloud, routes web traffic through Cloudflare; **DNS only**, shown with a gray cloud, returns the record without using the proxy. Mail server addresses and CNAMEs used for verification or DKIM must remain **DNS only** according to the provider's instructions. MX and TXT records cannot be proxied. For an existing website, check its hosting provider's compatibility guidance before enabling the proxy; the lab CNAME, which the tunnel creates later, will be **Proxied**. [DNS proxy limitations](https://developers.cloudflare.com/dns/proxy-status/limitations/).

### Changing nameservers at the registrar

Cloudflare displays the nameservers assigned to your zone during activation; you can find them again on the domain's **Overview** page. Copy the values assigned to your zone, rather than reusing values from another guide.

Before replacing them, check DNSSEC. This protection signs DNS data; the DS record at the registrar connects the delegation to the zone's keys. TTL specifies how long data can remain cached.

If DNSSEC is enabled, complete this step first using [your registrar's instructions](https://developers.cloudflare.com/dns/dnssec/#disable-dnssec):

1. Before making changes, obtain and record the original TTL of the DS record and of the previous NS delegation. If the panel does not expose them, ask the registrar or DNS provider: the remaining TTL returned by a resolver is not enough to estimate the full waiting period.
2. Remove the old DS record and obtain confirmation that its removal has been published in the parent zone. Count the full recorded DS TTL from that point, rather than from the click in the panel.
3. Change nameservers only after that waiting period and confirmation that the old DS record is absent. After the new delegation is published, also wait for the previous NS TTL before adding the new DS record.

If you cannot obtain the timing and confirmations, have the provider complete the migration before continuing. A DS record still tied to the old keys can make the domain unreachable.

Then open the registrar's panel and find **Nameserver** management, often under the domain settings. Replace the previous nameservers with those assigned by Cloudflare and save. In a standard full setup, do not keep a mixed set of old and new nameservers. Menu names depend on the registrar; Cloudflare collects [instructions for common providers](https://developers.cloudflare.com/dns/nameservers/update-nameservers/).

You do not need to add an A record pointing to the Raspberry Pi for this step. Do not enter `1.1.1.1` either: it is a public resolver that devices use for DNS lookups, rather than an authoritative nameserver assigned to your domain.

Return to Cloudflare and wait for the **Active** status. **Pending Nameserver Update** means delegation has not yet been verified. After the change and expiry of the previous nameservers' TTL, enable DNSSEC in Cloudflare and publish the new DS record using the registrar's procedure. This migration approach involves a period without DNSSEC protection. [DNSSEC sequence](https://developers.cloudflare.com/dns/dnssec/).

### Verifying delegation and existing services

From the checks terminal on the Raspberry Pi:

```bash
dig +short NS example.com
dig +short MX example.com
dig +short TXT example.com
dig +short DS example.com
```

Compare the nameservers with those assigned by Cloudflare and the email records with your original inventory. Caches and TTLs can delay results. These queries help with the comparison, but do not by themselves validate the DNSSEC chain.

Also check that the website opens and email still works. Continue with the lab only after those checks. From now on, manage the domain's records under **DNS → Records** in Cloudflare: changing the old zone at the registrar will no longer update authoritative DNS.

Do not create a record with your home IP address now. Later, the tunnel route will connect `lab.example.com` to the connector, and the dashboard will create the corresponding DNS record.

## Starting an origin we can recognize

Before setting up the tunnel, get the local service working. Check that the chosen port is available:

```bash
ss -lnt 'sport = :18080'
```

No listener should appear. If the port is in use, identify the existing service and choose another port, updating every reference in the guide.

In the first terminal, create a new directory. The block stops if it already exists, so it does not overwrite an application you already have:

```bash
(
  set -eu
  mkdir "$HOME/tunnel-lab"
  cat > "$HOME/tunnel-lab/index.html" <<'HTML'
<!doctype html>
<html lang="it">
<head>
  <meta charset="utf-8">
  <title>Laboratorio Cloudflare Tunnel</title>
</head>
<body>
  <h1>Origine del laboratorio raggiunta</h1>
  <p>Questa pagina contiene soltanto dati dimostrativi.</p>
</body>
</html>
HTML
)
```

You wrote `index.html`; no installer generates it. The sample page retains its Italian marker, **Origine del laboratorio raggiunta** (“Lab origin reached”), so it matches the verification commands throughout the guide. Start the server and leave the terminal open:

```bash
python3 -m http.server 18080 \
  --bind 127.0.0.1 \
  --directory "$HOME/tunnel-lab"
```

From another terminal on the same Raspberry Pi:

```bash
curl --fail --show-error http://127.0.0.1:18080/
ss -lnt 'sport = :18080'
```

You should receive the HTML containing **Origine del laboratorio raggiunta** and see the listener on `127.0.0.1:18080`. `0.0.0.0` or `[::]` would indicate a different exposure from the one intended.

The web directory must contain only the demonstration page: no tokens, backups, `.env` files, or symbolic links to private directories. [Python's HTTP server](https://docs.python.org/3.13/library/http.server.html) is suitable for this test, not for a production application.

## Installing cloudflared from the official repository

This guide uses a **remotely managed** tunnel: its routes and settings live in the Cloudflare dashboard. The Raspberry Pi stores the program and the token it needs to connect. We do not need to create a local `config.yml` file, run `tunnel login`, or generate credentials for a locally managed tunnel.

First check whether the program and repository are already present:

```bash
command -v cloudflared || true
grep -R "pkg.cloudflare.com" \
  /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null || true
```

If you find an existing setup, reuse it and check that it is the current stable repository. Do not add a duplicate. For a new installation, the following block creates the two specified files and stops if either already exists:

```bash
(
  set -eu
  set -o pipefail
  test ! -e /usr/share/keyrings/cloudflare-main.gpg
  test ! -e /etc/apt/sources.list.d/cloudflared.sources

  LAB_KEY=$(mktemp)
  trap 'rm -f -- "$LAB_KEY"' EXIT
  curl --fail --show-error --silent \
    --output "$LAB_KEY" \
    https://pkg.cloudflare.com/cloudflare-main.gpg
  sudo install -d -m 0755 /usr/share/keyrings
  sudo install -m 0644 "$LAB_KEY" /usr/share/keyrings/cloudflare-main.gpg

  sudo tee /etc/apt/sources.list.d/cloudflared.sources >/dev/null <<'APT'
Types: deb
URIs: https://pkg.cloudflare.com/cloudflared
Suites: any
Components: main
Signed-By: /usr/share/keyrings/cloudflare-main.gpg
APT

  sudo apt-get update
  sudo apt-get install cloudflared
)
```

The key and repository definition are for APT, not for the tunnel. Use the [key currently published by the official repository](https://pkg.cloudflare.com/index.html), avoiding old instructions that use `apt-key`.

Verify the result:

```bash
command -v cloudflared
cloudflared --version
cloudflared tunnel run --help
```

The following commands assume `/usr/bin/cloudflared`, the package's installation path. If yours is different, also adjust the `systemd` units. The `--token-file` option requires **version 2025.4.0 or later**: it must appear in the help output. Record the actual version in your lab rather than inferring it from the date you followed the guide.

## Configuring Access before publishing the route

Open **Zero Trust** from your Cloudflare account. If you do not yet have an organization, complete the initial setup and choose a team name. That name forms the `NOME_TEAM.cloudflareaccess.com` domain; it is not your application's subdomain.

For this lab, we use a one-time code sent by email. In new organizations, you need to add it explicitly under **Integrations → Identity providers → Add new identity provider → One-time PIN**. Whoever controls that inbox can receive the codes: protect it with a unique password and your email provider's multifactor authentication. [One-time PIN setup](https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/one-time-pin/).

Now create the application under **Zero Trust → Access controls → Applications**. In the current dashboard:

1. Select **Create new application**, choose **Self-hosted and private**, and proceed with **Continue with Self-hosted and private**.
2. Under **Application details → Destinations**, configure the public hostname. With the separate fields, enter `lab` under **Subdomain** and select the active domain. If you use **Switch to custom input**, enter the full hostname `lab.example.com` instead, as shown in the screenshot.
3. Leave **Path** empty: protection must cover the entire hostname. The hostname field does not create a server or tunnel by itself: it declares which destination Access will protect.

![Cloudflare Access application hostname settings](https://cdn.sanity.io/images/xrqkwry7/production/c9549eca198f7cee83cd4ef646bdec7fae9d27f3-1040x310.jpg?w=1400&fit=max&auto=format)

Access application: the full hostname in custom input mode. `lab.example.com` is an example value. Use a hostname belonging to a domain already active in your Cloudflare account; the tunnel record will be created later with the route.

Scroll to **Access policies** and select **Create new policy**. Under **Include**, choose the **Emails** selector and enter the exact address to authorize. Give the policy a recognizable name, such as **Tunnel lab browser**, and keep the **Allow** action.

An Include rule admits identities that meet that criterion. Here, the list should contain only the intended email addresses; the screenshot's placeholder is not an address to authorize. **Require** rules add conditions that must all be met, while **Exclude** excludes specific identities or conditions. For this first lab, an explicit email list is enough.

![Cloudflare Access email policy settings](https://cdn.sanity.io/images/xrqkwry7/production/c9ed50dcb88987fdbdebed1b4aa89c60da19ff69-1280x470.jpg?w=1400&fit=max&auto=format)

Access rule: the Emails selector and Allow action. Fill in the email field and policy name; no real identities appear here.

Leave **Policy session duration** set to **Same as application session duration** and save with **Save policy**. Check that the policy is associated with the application.

Under **Authentication → Identity**, choose **One-time PIN**. If **Accept all available identity providers** is enabled, disable it so you can explicitly select this method for the lab. The email PIN is a login method; the Emails policy determines which addresses are allowed. Having access to just any inbox must not be enough.

Finally, under **Details**, set **Name** to **Tunnel lab** and **Session Duration** to one hour for this example. Review the destination, policy, and authentication method, then save the application with **Create**. The screenshots show form fields, rather than a configuration that is already active.

The chosen duration limits the application's token validity. When it expires, Access checks the policies again; if the global identity session is still valid, it can renew access without requesting another code. [Session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) distinguishes these two durations.

For this result, do not use a **Bypass** rule or an **Everyone** authorization. Also check that no other applications or rules are already associated with the chosen hostname.

[Prepare the Access application before the public route](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app/): saving the route makes the endpoint reachable. Access and the route must refer to exactly the same hostname.

Also retrieve two values for validation in the connector:

- The **team name**, visible in the Zero Trust settings: the prefix of `NOME_TEAM.cloudflareaccess.com`.
- The **Application Audience (AUD) Tag**, under **Access controls → Applications → Configure → Additional settings** for the application you just created.

The AUD identifies the application for which the Access token is valid. It is not the tunnel token.

## Creating the tunnel and storing its token

In the Cloudflare dashboard, open **Networking → Tunnels → Create a tunnel** and create a dedicated `cloudflared` tunnel, such as **raspberry-lab**.

The panel shows installation instructions and the connector token. Store that token in your secrets manager. In this procedure, <strong>do not also run `cloudflared service install`</strong>: we first run the connector in the foreground, and later create a dedicated service.

In the second Bash terminal on the Raspberry Pi, save only the token using hidden input:

```bash
(
  set -eu
  set +x
  umask 077
  mkdir -m 0700 "$HOME/tunnel-lab-secrets"
  read -r -s -p "Token del tunnel di laboratorio: " LAB_TUNNEL_TOKEN
  printf '\n'
  test -n "$LAB_TUNNEL_TOKEN"
  printf '%s' "$LAB_TUNNEL_TOKEN" \
    > "$HOME/tunnel-lab-secrets/tunnel.token"
  unset LAB_TUNNEL_TOKEN
)
```

The file contains only the token, without `TOKEN=`, quotation marks, or JSON. It sits outside Python's served directory and is created with restricted permissions. Do not put the secret directly in the command, in Git, or in a screenshot.

Start the connector:

```bash
/usr/bin/cloudflared tunnel --no-autoupdate run \
  --token-file "$HOME/tunnel-lab-secrets/tunnel.token"
```

Leave the terminal open and check that the dashboard reports the tunnel as **Healthy**. This confirms the connection to the connector; it does not yet verify the web service through the tunnel. The [token-file parameter](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/run-parameters/#token-file) keeps the secret out of process arguments.

## Connecting the hostname, origin, and Access validation

From the dashboard, open **Networking → Tunnels**, select the dedicated lab tunnel, and go to **Routes → Add route → Published application**. This step maps a public hostname to the local service. In the **Add published application** form, set:

_Connecting the hostname, origin, and Access validation_

| Field | Lab value |
| --- | --- |
| Subdomain | `lab` |
| Domain | Your active domain, represented here by `example.com` |
| Path | Empty |
| Service URL | `http://127.0.0.1:18080` |

![Cloudflare Tunnel published application route settings](https://cdn.sanity.io/images/xrqkwry7/production/c834d1554ff228710223894c4800be8c4025b74d-672x605.jpg?w=1400&fit=max&auto=format)

Tunnel route: the public hostname and Service URL are separate fields. The gray values are interface placeholders; for this guide, use [http://127.0.0.1:18080](http://127.0.0.1:18080) as the Service URL.

The **Domain** field may be prefilled: check that it is the zone you intend to use. The **Service URL** specifies what the connector must reach on the Raspberry Pi, rather than the public address you will open in the browser.

Under **Additional application settings**, enable **Protect with Access** and enter the team name and AUD of the application created earlier. The check must be required. This makes `cloudflared` validate the Access JWT's signature, issuer, and audience before forwarding the request. These remote settings do not require a local YAML file. [Origin Access parameters](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/origin-parameters/#access).

After checking the hostname, origin, and Access validation, confirm with **Add route**. The endpoint can be reached from this point onward, which is why the Access application must already be ready.

Return to the domain under **Domains** and open **DNS → Records**. In full setup, the dashboard configures the record linked to the route:

_Connecting the hostname, origin, and Access validation_

| Type | Name | Target | Status |
| --- | --- | --- | --- |
| CNAME | `lab` | `ID_DEL_TUNNEL.cfargotunnel.com` | Proxied |

Use your tunnel's identifier, generated by Cloudflare. Do not enter the Raspberry Pi's private IP or your home's public IP. If the name already has an incompatible record, resolve that conflict rather than blindly adding another definition. [Creating the tunnel and route](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/).

`127.0.0.1` works because the connector and Python run on the same host. In a container, `localhost` would mean the container itself: connecting two containers would require a shared network and the service name. We do not use Docker or a reverse proxy here because neither is needed to demonstrate this connection.

## Verifying the complete path

From the checks terminal on the Raspberry Pi, using the lab's real hostname:

```bash
dig +short lab.example.com A
dig +short lab.example.com AAAA
curl --silent --show-error --max-time 15 \
  --dump-header - --output /dev/null \
  https://lab.example.com/
```

With the proxy enabled, DNS may return Cloudflare addresses rather than the tunnel's CNAME target. Check the target in the DNS zone.

The `curl` request includes no credentials and does not follow redirects: it should encounter Access rather than receive the lab page. A `302` redirect to the login page is an expected result for the browser flow, rather than evidence of a fault.

Next, open a private browser window, visit the hostname, enter the authorized email address, and use the code you receive. Success means seeing **Origine del laboratorio raggiunta** after logging in. Seeing only the Access screen proves that you have reached the access control, not the backend.

To test an excluded identity, close all private windows and open a new one, or use a separate browser profile without Access sessions. A second private tab can share a successful login and invalidate the check.

Complete these lab tests:

_Verifying the complete path_

| Test | Expected result |
| --- | --- |
| Local `curl` request to `127.0.0.1:18080` | Lab HTML |
| Browser without a session | Login or rejection, without origin content |
| Authorized email and valid code | Lab page |
| Email not included in the policy | No access to the page |
| Local listener checked with `ss` | Only `127.0.0.1:18080` |
| Another LAN device connecting to the Raspberry Pi's IP and port 18080 | No response from the origin on that port |

For the last check, use the lab Raspberry Pi's LAN address. The loopback bind applies to this server: it does not certify the exposure of every other service on the Raspberry Pi.

With One-time PIN, the message announcing that a code has been sent may appear even for an excluded address. Do not therefore always expect an immediate `403`: the criterion is that the identity cannot obtain the content.

Also run the public check through an independent connection, such as a phone browser using mobile data. Then test two failures separately: stop Python with Ctrl+C, check the error, and restart it with the same command; only then stop `cloudflared` and repeat the test. This sequence helps distinguish an unavailable origin from a disconnected tunnel.

Before continuing, also restart `cloudflared` with the same `tunnel --no-autoupdate run --token-file` command used earlier. Wait for **Healthy** and check the lab phrase again in the authenticated browser.

## Extension: authorizing a script

A browser uses a personal identity; a script can use an **Access service token**, consisting of a Client ID and Client Secret. The token you saved on the Raspberry Pi starts the tunnel instead. These are different credentials.

Under **Access controls → Service credentials → Service Tokens**, create a dedicated lab token and choose a duration. Save the secret when it is displayed. In the **Tunnel lab** application, add a separate policy with the **Service Auth** action, including that specific service token. Keep the personal policy for the browser.

You can run the test from the checks terminal on the Raspberry Pi. To use another computer, Bash and `curl` must already be available; the browser alone cannot run this block.

The client sends `CF-Access-Client-Id` and `CF-Access-Client-Secret` with every request. This Bash block hides the Client Secret during entry and passes the headers to `curl` through standard input:

```bash
(
  set -eu
  set +x
  read -r -p "Hostname del laboratorio: " LAB_HOST
  read -r -p "Access Client ID: " LAB_ACCESS_ID
  read -r -s -p "Access Client Secret: " LAB_ACCESS_SECRET
  printf '\n'
  test -n "$LAB_HOST"
  test -n "$LAB_ACCESS_ID"
  test -n "$LAB_ACCESS_SECRET"

  printf 'header = "CF-Access-Client-Id: %s"\nheader = "CF-Access-Client-Secret: %s"\n' \
    "$LAB_ACCESS_ID" "$LAB_ACCESS_SECRET" \
    | curl --config - --fail --silent --show-error --max-time 15 \
        "https://$LAB_HOST/"

  unset LAB_ACCESS_ID LAB_ACCESS_SECRET
)
```

In the first field, enter only the hostname, without `https://` or a path. Do not use verbose mode or shell tracing with real credentials. For CI, store them in the platform's secrets manager.

Valid credentials should return the same page; an incorrect secret must not. **Strict service token authentication**, mandatory for organizations created on or after October 5, 2026, returns `401` or `403` on failure instead of redirecting to login, and does not issue a browser cookie. In older organizations, behavior depends on the setting. [Service tokens and strict mode](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/).

Keep **Protect with Access** enabled for this path too: the JWT forwarded to the connector can attest to authentication using a service token without requiring a browser cookie. [Access application tokens](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/application-token/).

The static page has no second login. If you replace it with an application that requires its own token or session, that check remains separate: passing Access does not automatically grant application permissions.

## Extension: restarting the lab automatically

So far, closing the terminals stops the processes. To keep the lab running after logout or a reboot, you need **two services**: one for the origin and one for the tunnel.

The following setup uses two accounts without login access and dedicated directories. It remains a persistent lab: making `http.server` persistent does not turn it into a production server.

First check that these names and paths are available. If you find existing resources, stop and choose different names in every command:

```bash
getent passwd tunnel-lab-web || true
getent passwd tunnel-lab-connector || true
getent group tunnel-lab-web || true
getent group tunnel-lab-connector || true
sudo ls -ld /srv/tunnel-lab /etc/cloudflared-lab \
  /etc/systemd/system/tunnel-lab-web.service \
  /etc/systemd/system/tunnel-lab-connector.service 2>/dev/null || true
```

Create the accounts and copy only the files you need:

```bash
sudo useradd --system --user-group --home-dir /nonexistent \
  --shell /usr/sbin/nologin tunnel-lab-web
sudo useradd --system --user-group --home-dir /nonexistent \
  --shell /usr/sbin/nologin tunnel-lab-connector

sudo install -d -o root -g root -m 0755 /srv/tunnel-lab
sudo install -o root -g root -m 0644 \
  "$HOME/tunnel-lab/index.html" /srv/tunnel-lab/index.html

sudo install -d -o root -g tunnel-lab-connector -m 0750 \
  /etc/cloudflared-lab
sudo install -o root -g tunnel-lab-connector -m 0640 \
  "$HOME/tunnel-lab-secrets/tunnel.token" \
  /etc/cloudflared-lab/tunnel.token
```

The web account can read the page but not the connector token. The token is readable by the connector's group and writable by root.

Write the first complete unit:

```bash
sudo tee /etc/systemd/system/tunnel-lab-web.service >/dev/null <<'UNIT'
[Unit]
Description=Tunnel lab demonstration website

[Service]
Type=simple
User=tunnel-lab-web
Group=tunnel-lab-web
ExecStart=/usr/bin/python3 -m http.server 18080 --bind 127.0.0.1 --directory /srv/tunnel-lab
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target
UNIT
```

Then the connector unit:

```bash
sudo tee /etc/systemd/system/tunnel-lab-connector.service >/dev/null <<'UNIT'
[Unit]
Description=Cloudflare Tunnel lab connector
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=tunnel-lab-connector
Group=tunnel-lab-connector
ExecStart=/usr/bin/cloudflared tunnel --no-autoupdate run --token-file /etc/cloudflared-lab/tunnel.token
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target
UNIT
```

These are files you write, rather than files generated by Cloudflare. Absolute paths avoid depending on the shell's directory; `ProtectHome=true` explains why the services use `/srv` and `/etc` rather than files under your home directory. Filesystem restrictions limit where the processes can write. [systemd execution environment](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html).

Stop the two processes still running in the foreground with Ctrl+C. First validate the units:

```bash
sudo systemd-analyze verify \
  /etc/systemd/system/tunnel-lab-web.service \
  /etc/systemd/system/tunnel-lab-connector.service
```

If verification reports errors relating to either unit, fix them and repeat it. Start the services only after verification succeeds:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now tunnel-lab-web.service tunnel-lab-connector.service
systemctl is-active tunnel-lab-web.service tunnel-lab-connector.service
systemctl is-enabled tunnel-lab-web.service tunnel-lab-connector.service
```

The expected results are `active` and `enabled` for both services. Repeat the local check and the public check with login.

To read the status and recent messages:

```bash
sudo systemctl status tunnel-lab-web.service tunnel-lab-connector.service --no-pager
sudo journalctl -u tunnel-lab-web.service -u tunnel-lab-connector.service \
  -n 80 --no-pager
```

`Restart=on-failure` lets systemd restart a process that exits with an error; `enable` configures it to start at boot. An explicit `systemctl stop` leaves the service stopped. [systemd service behavior](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html).

On a lab machine you can interrupt, reboot the Raspberry Pi with `sudo reboot`. After reconnecting, check the status, listener, local response, and public access again. Testing after a reboot is part of the result promised by this extension.

## Reading errors without changing everything at once

_Reading errors without changing everything at once_

| Symptom | First useful check |
| --- | --- |
| The name does not resolve | Active delegation, record in the authoritative zone, and hostname entered |
| `302` redirect to Access | Login is required; do not treat it as a successful API response |
| No access after login | Policy, chosen identity, and session duration |
| Connector rejection after Access | Team name, AUD, and reachability of validation keys |
| `502` related to the Tunnel origin | Python running, port, protocol, and local response |
| Cloudflare error `1033` | Available connector, its outbound network, and selected tunnel |
| Service token rejected | Client ID/Secret, expiry, Service Auth policy, and strict mode |
| Works until the shell closes | The process is still in the foreground, or one of the two services is missing |
| Works on the host but not in the container | The container's `localhost` is not the host's |

A **Healthy** tunnel and a `200` from the local service prove two different parts of the connection. Verifying the complete path requires the correct response from the public hostname after authentication. Read each HTTP status code alongside the returned page and the component's logs. [Official Tunnel troubleshooting](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/troubleshoot-tunnels/common-errors/).

## Maintenance and moving to a real application

To update the package installed through APT, during a period when you can interrupt the lab:

```bash
sudo apt-get update
sudo apt-get install --only-upgrade cloudflared
cloudflared --version
```

If you configured the systemd extension, restart the connector with `sudo systemctl restart tunnel-lab-connector.service`. If it is still running in the terminal, stop it with Ctrl+C and run it again using the same `tunnel --no-autoupdate run --token-file` command. In either case, wait for **Healthy** and repeat the access checks.

`--no-autoupdate` makes it explicit that the update cycle is managed through the package and service.

Rotating the tunnel token and handling an Access service token's expiry are different operations. After replacing the connector token in its file, restart the connector using your chosen method, either the systemd service or the terminal process, so it reads the file again. [Tunnel token rotation](https://developers.cloudflare.com/tunnel/reference/tunnel-tokens/) does not by itself close all established connections: account for every replica when planning the replacement.

For a real service, replace Python with a maintained application, define its update process, back up its data, and test a restore. Verify the application's authorization controls in addition to Access. If you publish Docker ports, test from another device whether the origin is reachable directly, without passing through Access.

A Raspberry Pi at home depends on power, storage, and connectivity. For a service with availability requirements, compare those operational costs with managed hosting before deciding where it should run.

## When private access is a better fit

This tutorial creates a public hostname with protected content. A private network path may be more suitable for administering restricted resources from a few controlled devices.

_When private access is a better fit_

| Option | Who can reach the service | Client requirements |
| --- | --- | --- |
| Cloudflare Tunnel with Access | People or automations authorized for the public hostname | Browser or machine credentials |
| Tailscale Serve | Tailnet users and devices according to its policies | Authorized access to the tailnet |
| Twingate | Users authorized for the specific Resource | Client and a path through a Connector |
| Cloudflare private routing | Clients admitted to the configured private routes | Cloudflare One Client and matching policies |

[Tailscale Serve](https://tailscale.com/docs/features/tailscale-serve) and [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel) serve different needs: Funnel publishes to the Internet, while Serve serves the tailnet. [Twingate](https://www.twingate.com/docs/how-twingate-works) organizes access around resources and permissions; its Connector must be able to reach the origin. These alternatives require their own setup and cannot be obtained just by changing an Access policy.

If you migrate to private access, check that the previous public route has not been left open by mistake. For deliberately public content, Tunnel can instead be used without Access: that is suitable for a website intended for anonymous visitors, rather than a workaround for a lab authentication error.

## Removing the lab without affecting other services

In the Cloudflare panel, remove the lab's published application route and verify that its DNS record has been removed. Do this before deleting the Access application. Then delete the dedicated Access app, revoke any service token, and delete the tunnel once it has no other uses. Do not remove policies reused by other applications or the domain's DNS zone.

If you used only foreground processes, stop them with Ctrl+C. If you completed the systemd extension:

```bash
sudo systemctl disable --now tunnel-lab-connector.service tunnel-lab-web.service
sudo rm -- \
  /etc/systemd/system/tunnel-lab-connector.service \
  /etc/systemd/system/tunnel-lab-web.service
sudo systemctl daemon-reload

sudo rm -- /etc/cloudflared-lab/tunnel.token /srv/tunnel-lab/index.html
sudo rmdir /etc/cloudflared-lab /srv/tunnel-lab
sudo userdel tunnel-lab-connector
sudo userdel tunnel-lab-web
```

Use `getent group` to check whether the two groups created for the lab remain and, only if they do, remove them with `sudo groupdel`. Do not run these commands for preexisting accounts.

Finally, delete the two initial files and their directories:

```bash
rm -- "$HOME/tunnel-lab/index.html" "$HOME/tunnel-lab-secrets/tunnel.token"
rmdir "$HOME/tunnel-lab" "$HOME/tunnel-lab-secrets"
ss -lnt 'sport = :18080'
```

`rmdir` stops if it finds other content: inspect that content before deleting it. Do not uninstall Python, system tools, or `cloudflared` if they are used elsewhere. On a dedicated machine, you can also remove the package and APT files added by this guide after checking that no other tunnel uses them.
