The idea that I need to manually create a Cloudflare tunnel to use it with cloudflared
module, and then store its credentials in some file never sat well with me. Why all this trouble when CF offers API tokens, and it’s actually all you need. You can literally do everything with a token. Sadly, the default cloudflared
module is very limeted. So I created my own process. It’s a work in progress, obviously
I’m not big on sharing half-baked code, so here’s my thought process, use it if you like.
Pre-requisites:
- CF account (obviously)
- CF super-token (see below; I store it in SOPS-nix)
- A domain name, registered anywhere, but with DNS servers pointing to
*.ns.cloudlare.com
tunnelIngressMap
that looks like this:
tunnelIngressMap = {
"books.mydomain.zzz" = "http://calibre-web.lan:8083";
"ha.mydomain.zzz" = "http://rpi4.lan:8123";
"jp.mydomain.zzz" = "http://joplin.lan:8080";
"sp.mydomain.zzz" = "http://speedtest.lan";
"tb.mydomain.zzz" = "http://rpi4.lan:8443";
"whoami.mydomain.zzz" = "http://whoami.lan";
};
Super-token permissions:
Account
→Cloudflare Tunnel
→Edit
- Scope:
Include
→Your Account
Zone
→Zone Settings
→Read
Zone
→Zone
→Read
Zone
→Zone
→Edit
Zone
→DNS
→Read
Zone
→DNS
→Edit
That’s it. But without tunnel credentials, how do you opreate one? Turns out, you don’t need credentials: using the API you can get a JWT and run the tunnel using it. Nothing extra to store in SOPS-nix.
The whole thing runs in a non-privileged LXC container, so attack surface is ~zero.
SCRIPT LOGIC: provision-tunnel-and-dns
This script is the declarative heart of the server’s cloud infrastructure. It is designed to be fully idempotent and self-healing, meaning it can be run on every boot to verify and, if necessary, repair the required Cloudflare configuration from a zero-knowledge state. It ensures that the Cloudflare Zone, DNS records, and Tunnel are all correctly configured before the main cloudflared
daemon starts.
The script executes the following steps in order:
STEP 1: Zone Verification and Creation
-
API Call: The script first makes a
GET
request to the Cloudflare API to
search for a Zone with the specified name (e.g.,mydomain.zzz
). -
Conditional Logic:
- If the Zone exists: The script extracts the
Zone ID
andAccount ID
from the successful response and proceeds to the next step. - If the Zone does NOT exist: This triggers a multi-stage
creation process to prevent errors and provide clear feedback.
a. Public DNS Check: Before attempting to create the zone via the API,
it uses thedog
command to perform a public DNS lookup for the
domain’sNS
(nameserver) records. (dog --json
is a nice alternative todig
)
b. Nameserver Validation: It checks if the returned nameservers belong
to Cloudflare. This is a critical pre-flight check.
c. Error Handling: If the nameservers are not pointing to Cloudflare,
the script halts with a fatal error. It prints a user-friendly
message explaining that the domain must be delegated at the registrar
before the automation can work. This prevents confusing API errors.
d. Zone Creation: If the nameservers are correct, the script proceeds.
It discovers theAccount ID
from the API and then makes aPOST
request to create the zone under that account.
STEP 2: Wildcard A Record Provisioning
- Why: In addition to CF tunnels I run some services without proxying (
Jellyfin
,XTLS/Xray
, etc). This A-record is later maintained bycloudflare-dyndns
. For this to work properly I also get my own certificate from LetsEncrypt via lego/acme, in addition to CF’s cert that CF uses for its proxied services. - IP Discovery: The script determines the server’s current public IPv4 address.
- Check for Existence: It queries the API for a wildcard
A
record for the
zone (e.g.,*.mydomain.zzz
). - Create if Missing: If no such record exists, it creates one, pointing to the
server’s public IP. This record is explicitly set to “DNS Only” (not proxied).
STEP 3: Tunnel Provisioning
- Search for Active Tunnel: The script queries the API for all tunnels associated
with the account. It then filters this list to find a tunnel that both
matches the desiredTUNNEL_NAME
and is not deleted (i.e., itsdeleted_at
field isnull
). - Create if Missing: If no active tunnel with the correct name is found, the
script makes aPOST
request to create a new one. It includes robust error
checking to ensure the creation was successful. - Store ID: The script stores the ID of either the found or newly created
tunnel in theTUNNEL_ID
variable for later steps. Notably, all credentials data for the tunnel is immediately discarded.
STEP 4: CNAME Record Idempotency
The script loops through every hostname defined in the tunnelIngressMap
.
- Check Record: For each hostname, it queries the API for an existing
CNAME
record. - Extract Details: It extracts both the
ID
and thecontent
(the target it points to) of the found record. - Validate Content: It compares the
content
of the existing record against the correct target for the currently active tunnel (e.g.,
<current-tunnel-id>.cfargotunnel.com
). - Repair if Incorrect: If the record does not exist, or if it exists but points to the wrong tunnel ID (a stale record), the script takes corrective action:
a. Delete: If an incorrect record exists, it is first deleted using its ID.
b. Create: A new, correctCNAME
record is created, pointing to the
correct tunnel target. This record is set to “Proxied”. - Do Nothing if Correct: If the record exists and is already correct, the
script reports this and moves on.
STEP 5: Fetch Ephemeral JWT
- With the guaranteed-correct
Tunnel ID
, the script makes a final API call to fetch a short-lived, ephemeral JSON Web Token (JWT). This token is used by thecloudflared
daemon to authenticate its connection to the Cloudflare network.
STEP 6: Save JWT for the Service
- Write to File: The script writes the fetched JWT to a temporary file at
/run/cloudflared/ephemeral-token.jwt
. - Format for systemd: It writes the token in the
TUNNEL_TOKEN=<jwt>
format, which is required by systemd’sEnvironmentFile
directive. - Set Permissions: It securely sets the ownership and permissions on the file so that only the
cloudflared
user can read it.