Lab 06 — Cloudflare Tunnel
Duration: 45 minutes
You will expose a service from inside the operator console to the public internet using
Cloudflare Tunnel (cloudflared). No inbound firewall ports. No public IP. No port
forwarding. The tunnel makes an outbound connection to Cloudflare’s edge and serves
traffic at api.<student>.eplabs.cloud.
The service exposed here is a trivial nginx response, a placeholder to confirm the tunnel works end-to-end. Lab 07 replaces it with a Cloudflare Worker. Lab 13 turns the same tunnel into a C2 redirector. Lab 14 uses it for signed-URL artifact uploads from the Mango. The infrastructure you stand up in this lab runs unchanged through all four.
The Mango is not involved here. The tunnel origin is the operator console (Debian). The Mango is already in the tailnet from Lab 05 and is reached from the operator console via Tailscale, not via the public tunnel. That architectural distinction matters in Lab 13.
Where each step runs
This lab is marked codespaces: "supported" because every step runs from the operator
console. The Mango is not needed.
| Step | Operator console (Codespace, Local Dev, or Minimal local) |
|---|---|
| 1. Create tunnel | yes |
| 2. Write config | yes |
| 3. DNS CNAME | yes |
| 4. Test service | yes |
| 5. Run cloudflared | yes |
| 6. Verify public URL | yes (curl from the operator console or your laptop) |
| 7. Persist | yes |
| 8. Record version | yes |
| Validation | yes |
Learning objectives
- Understand Cloudflare Tunnel architecture: outbound connection from origin to edge, no inbound firewall rule required.
- Write and deploy a
cloudflaredingress configuration with a named tunnel and a hostname rule. - Serve a real HTTP response from inside the operator console to a public HTTPS URL.
- Verify the tunnel is live from outside the operator console and confirm
cloudflaredshows connected. - Record the
cloudflaredversion tolabs/output/build-manifest.json. - Understand how CF Access protection integrates (Lab 08 adds it; Lab 06 intentionally prepares the tunnel without it so the tunnel itself can be verified without authentication complexity).
Pre-state
Before starting this lab confirm all of the following.
# Lab 05 tailnet is up; both nodes appear in status
tailscale status
# cloudflared is present (installed via Cloudflare apt repo in the Dockerfile)
cloudflared --version
# Expected output starts with: cloudflared version 20...
# Your eplabs.cloud subdomain is active (Lab 04 verified this)
# The DNS record api.<student>.eplabs.cloud will point to the tunnel after this lab.
# Cloudflare dashboard access
# You need to be able to log in to dash.cloudflare.com to create a tunnel.
About the cloudflared installation. The operator console has cloudflared installed
via Cloudflare’s apt repository (pkg.cloudflare.com/cloudflared), with GPG signature
verification, as part of the Debian devcontainer Dockerfile. The version in the
container is the latest stable at image-build time. You do not need to install or pin
cloudflared manually.
TLS coverage on the shared parent zone. This workshop runs against
eplabs.cloudwith Advanced Certificate Manager / Total TLS enabled, sohttps://api.<slot>.eplabs.cloud/automatically gets a per-hostname certificate. If you see a TLS handshake error, the ACM cert pack hasn’t finished provisioning yet (5–30 min after the DNS record is created); retry shortly. The take-home BYO-domain variant inhandouts/workshop_domain_prerequisites.mdcovers students who manage their own zone instead.
Codespace users: the operator console is the Codespace terminal. All cloudflared commands in this lab run directly in that terminal. You do not need a local Docker daemon. The
cloudflaredbinary is already on PATH.
Local Dev Container users: open a terminal inside the Dev Container (VS Code integrated terminal or
docker exec -it ep-devcontainer bash). All cloudflared commands run there.
Walkthrough
A named tunnel is a persistent Cloudflare object: it has a UUID, a name, and a
credential file. You create it once; cloudflared uses the credential file to
authenticate on startup. Two creation paths are available.
Option A: CLI (recommended)
# Inside the operator console
cloudflared tunnel login
# Opens a browser window (or prints a URL to open). Authenticate with your
# Cloudflare account. A certificate is written to ~/.cloudflared/cert.pem.
cloudflared tunnel create ep-<student>
# Replace <student> with your slot name, e.g.: cloudflared tunnel create ep-alpha
Expected output:
Created tunnel ep-alpha with id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Tunnel credentials written to /root/.cloudflared/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json.
Note the tunnel ID (UUID). You will use it in Step 2.
# Confirm the tunnel is registered
cloudflared tunnel list
Option B: dashboard
- Open dash.cloudflare.com and select your account.
- Go to Zero Trust > Networks > Tunnels.
- Click Add a tunnel, select Cloudflared, click Next.
- Name the tunnel
ep-<student>. Click Save tunnel. - On the next screen, ignore the auto-install instructions. Click Next, then the Overview tab. Note the Tunnel ID (UUID).
- On the Connectors tab, click the three-dot menu, then Download credentials
JSON. Save the file as
<tunnel-id>.json. - Copy it into the operator console:
# From your laptop (replace TUNNEL_ID and the path to your download)
TUNNEL_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
docker cp ~/Downloads/${TUNNEL_ID}.json ep-devcontainer:/root/.cloudflared/${TUNNEL_ID}.json
Verify the credential file is present:
ls ~/.cloudflared/
# Expected: cert.pem <tunnel-id>.json
Create the tunnel configuration file in the operator console. The ingress rule maps your public hostname to the local nginx placeholder on port 8787.
# Replace TUNNEL_ID and STUDENT with your values
TUNNEL_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
STUDENT="alpha"
mkdir -p /etc/cloudflared
cat > /etc/cloudflared/config.yml << EOF
tunnel: ${TUNNEL_ID}
credentials-file: /root/.cloudflared/${TUNNEL_ID}.json
ingress:
- hostname: api.${STUDENT}.eplabs.cloud
service: http://localhost:8787
- service: http_status:404
EOF
Verify it:
cat /etc/cloudflared/config.yml
Expected output:
tunnel: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
credentials-file: /root/.cloudflared/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json
ingress:
- hostname: api.alpha.eplabs.cloud
service: http://localhost:8787
- service: http_status:404
The service: http_status:404 catch-all is required by cloudflared. Any request that
does not match an explicit hostname rule returns a 404 from the tunnel itself.
Route the hostname to the tunnel. cloudflared can create the DNS record directly if
your account certificate is present (from the tunnel login step):
# Inside the operator console
TUNNEL_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
STUDENT="alpha"
cloudflared tunnel route dns ${TUNNEL_ID} api.${STUDENT}.eplabs.cloud
Expected output:
2024/xx/xx xx:xx:xx INF Added CNAME api.alpha.eplabs.cloud which will route to this tunnel tunnelID=xxxxxxxx-...
Verify the record was created:
dig CNAME api.${STUDENT}.eplabs.cloud +short
# Expected: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.cfargotunnel.com.
DNS propagation for Cloudflare-managed zones is typically under 30 seconds.
If cloudflared tunnel route dns fails because the operator console account cert is not
present (option B tunnel creation), add the CNAME manually via the Cloudflare dashboard:
DNS > Records > Add record. Type: CNAME, name: api.<student>, target:
<tunnel-id>.cfargotunnel.com, proxy: on (orange cloud).
The tunnel maps api.<student>.eplabs.cloud to http://localhost:8787. Start a minimal
nginx response on that port to confirm the end-to-end path before adding the Worker in
Lab 07.
nginx is installed in the operator console. Configure a minimal server block:
# Inside the operator console
cat > /tmp/nginx-test.conf << 'EOF'
worker_processes 1;
daemon off;
events { worker_connections 16; }
http {
server {
listen 8787;
location / {
default_type text/plain;
return 200 "EPL engagement platform: tunnel ok\n";
}
location /health {
default_type application/json;
return 200 '{"status":"ok","origin":"operator-console"}';
}
}
}
EOF
nginx -c /tmp/nginx-test.conf &
NGINX_PID=$!
echo "nginx PID: $NGINX_PID"
Verify nginx is listening:
curl -s http://localhost:8787/
# Expected: EPL engagement platform: tunnel ok
curl -s http://localhost:8787/health
# Expected: {"status":"ok","origin":"operator-console"}
Persistence note. The nginx config is written to
/tmp/nginx-test.conf, which does not survive a container restart. If you restart the operator console and need to re-run validation, either run thenginxcommand above again or move the config to/etc/nginx/conf.d/tunnel-test.conf(a location that persists). For the workshop session,/tmpis sufficient.
# Inside the operator console
cloudflared tunnel --config /etc/cloudflared/config.yml run &
CLOUDFLARED_PID=$!
echo "cloudflared PID: $CLOUDFLARED_PID"
Watch the startup output. After a few seconds you should see:
...INF Connection ... registered connIndex=0 ...
...INF Connection ... registered connIndex=1 ...
...INF Registered tunnel connection ...
At least two connections to the Cloudflare edge should register. Four connections are normal and indicate full redundancy.
Check the tunnel status from a second terminal:
cloudflared tunnel --config /etc/cloudflared/config.yml info
Expected output (abbreviated):
Tunnel ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Status: HEALTHY
Connections: [4]
From outside the operator console, curl the public URL. You can run this from your laptop or from a second terminal in the operator console:
# Replace with your student subdomain
curl -sv https://api.${STUDENT}.eplabs.cloud/
# Expected HTTP status: 200
# Expected body: EPL engagement platform: tunnel ok
Also verify the health endpoint:
curl -s https://api.${STUDENT}.eplabs.cloud/health
# Expected: {"status":"ok","origin":"operator-console"}
If you get a 525 SSL Handshake Error or a Cloudflare error page, the tunnel
connection may still be establishing. Wait 10 to 15 seconds and retry.
If you get a 404 from cloudflared’s catch-all rule, the hostname in the ingress rule
does not match the URL. Double-check the config file and confirm the CNAME record was
created in Step 3.
Running cloudflared in the background with & is fine for the lab session. To keep the
tunnel running after an operator console restart, you have two options.
Option A: systemd unit (recommended for local Dev Container)
The operator console runs Debian with systemd available. Use cloudflared service install to write and enable a systemd unit:
# cloudflared service install writes /etc/systemd/system/cloudflared.service
# and enables it. It reads the config from /etc/cloudflared/config.yml.
cloudflared service install
# Start the service
systemctl start cloudflared
systemctl status cloudflared
Expected status output:
● cloudflared.service - cloudflared
Loaded: loaded (/etc/systemd/system/cloudflared.service; enabled)
Active: active (running) since ...
Stop the foreground process if it is still running before enabling the service:
kill $CLOUDFLARED_PID 2>/dev/null || true
systemctl start cloudflared
Option B: re-start on session open (Codespace or ephemeral container)
Codespaces restart between sessions. The simplest approach is a one-line restart in
.devcontainer/post-start.sh:
# .devcontainer/post-start.sh (add this line)
cloudflared tunnel --config /etc/cloudflared/config.yml run >> /var/log/cloudflared.log 2>&1 &
This keeps the credential file and config on the persistent /root/.cloudflared/ volume
and relaunches cloudflared on every Codespace resume.
Verify the tunnel is still connected after whichever path you chose:
cloudflared tunnel --config /etc/cloudflared/config.yml info
# From the operator console, run from the course root
MANIFEST="labs/output/build-manifest.json"
CF_VERSION=$(cloudflared --version 2>&1 | awk '{print $3}' | head -1)
# If the manifest does not exist yet, create a minimal stub
if [ ! -f "$MANIFEST" ]; then
mkdir -p labs/output
printf '{"role":"engagement-platform","openwrt_version":"23.05.3","created_at":"%s"}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$MANIFEST"
fi
UPDATED=$(jq --arg v "$CF_VERSION" '. + {cloudflared_version: $v}' "$MANIFEST")
printf '%s\n' "$UPDATED" > "$MANIFEST"
echo "cloudflared_version recorded: $CF_VERSION"
cat "$MANIFEST"
The manifest now contains a cloudflared_version field. The validate script checks for
this field.
Post-state
When this lab is complete all of the following should be true:
-
cloudflared tunnel --config /etc/cloudflared/config.yml infoshowsHEALTHYwith at least one registered connection. -
curl https://api.<student>.eplabs.cloud/returns HTTP 200 with the nginx body. -
curl https://api.<student>.eplabs.cloud/healthreturns JSON{"status":"ok",...}. -
cloudflaredis running (systemd unit or background process) and will restart on operator console restart. -
labs/output/build-manifest.jsoncontains"cloudflared_version": "...". - The tunnel credential JSON lives in
/root/.cloudflared/and will persist across container restarts.
What CF Access does not do yet. The tunnel is publicly accessible; anyone who knows the URL can reach it. Lab 08 adds Cloudflare Access in front, enforcing JWT or service token authentication. For now, the open URL is intentional so you can verify the tunnel without authentication complexity.
Validation
Three validation paths. Pick whichever matches your setup. All three call the same
validate.sh and post the result to the same backend.
Path 1: epl CLI (recommended from the operator console).
export STUDENT=yourname
epl validate lab06-cloudflare-tunnel
Path 2: direct script.
export STUDENT=yourname
bash courses/engagement-platform-labs/labs/lab06-cloudflare-tunnel/validate.sh
Or via the Makefile:
cd courses/engagement-platform-labs/labs
STUDENT=yourname make validate-lab06-cloudflare-tunnel
Path 3: paste output into the widget.
Run validate.sh anywhere, copy the full output, paste it into the widget below.
What the script checks:
curl https://api.<student>.eplabs.cloud/returns HTTP 200./healthreturns JSON body containing"status"and"ok".cloudflaredprocess is running in the operator console andtunnel inforeportsHEALTHY.- DNS CNAME for
api.<student>.eplabs.cloudresolves tocfargotunnel.com(advisory check; a warning is emitted ifdigis unavailable). - The URL is accessible without auth headers (correct for Lab 06; Lab 08 will flip this expectation).
labs/output/build-manifest.jsoncontainscloudflared_version.
Primitives and drop-in substitutes
Cloudflared connects an origin to Cloudflare’s edge over a long-lived outbound connection so you can serve HTTPS without opening an inbound port. The primitive itself predates Cloudflare Tunnel by decades. SSH -R is the smallest case; managed SaaS like ngrok and self-hosted tools like frp are the broad middle. The substitution surface is mostly about who runs the public exit, who terminates TLS, and what the wire protocol is.
| Name | Model | License | When to consider it |
|---|---|---|---|
| frp (fatedier) | self-hosted; client + server binaries; TCP/UDP/HTTP(S) | Apache-2.0 | the canonical OSS tunnel; you run frps on a VPS, frpc on the origin |
| rathole | self-hosted; Rust rewrite of frp’s wire model | MIT/Apache-2.0 | when frp’s resource overhead matters; smaller binary, faster |
| chisel (jpillora) | self-hosted; HTTP-tunneled TCP/UDP multiplexer; single binary | MIT | when you want a single Go binary for both sides and do not need frp’s full config surface |
| boringproxy | self-hosted; built around HTTPS reverse-proxy with automatic TLS | MIT | when you specifically want Let’s Encrypt baked in at the server side |
| bore (ekzhang) | minimal; Rust; one-shot tunnels over TCP | MIT | for one-off port forwarding, like ssh -R but without needing an SSH server |
| ngrok | managed SaaS; cloud edge | proprietary | demos and dev/preview environments; generous free tier |
| localtunnel | managed; minimal Node.js client | MIT | smoke tests and CI; simpler invocation than ngrok, no account required |
| inlets PRO | client + server you run; aimed at Kubernetes ingress | MIT (OSS) / proprietary (PRO) | when running Kubernetes ingress through a tunnel |
| Tailscale Funnel | exposes a tailnet service to the public internet | proprietary (Tailscale) | when you already run Tailscale; Funnel is a feature on top of the mesh from Lab 05 |
| WireGuard with port-forward at exit | mesh primitive applied to egress through a known peer | GPL-2.0 | when you want full network reachability rather than just HTTP(S) forwarding |
SSH -R | bog-standard reverse port forward | OpenSSH (BSD) | when both ends already have SSH and you do not need TLS termination at the public end |
client ──HTTPS──> public exit <── persistent outbound ── origin
(CF edge / (cloudflared /
frps / frpc /
ngrok) rathole / bore)
The public exit owns TLS termination in every managed-SaaS row above. With self-hosted tools (frp, rathole, chisel, boringproxy), you bring your own certificate or wire up an ACME client at the exit server. SSH -R and WireGuard defer TLS entirely to the application layer.
Take-home prompt
Run an frp server on a $5 VPS and rerun Lab 06 against your own exit. Compare TLS termination: Cloudflare Tunnel gives you their certificate chain; frps requires your own ACME flow (certbot or the built-in TLS support in newer frp releases). Then try bore for the same thing in a one-line invocation and compare the setup cost.
Further reading
Cloudflare Tunnel:
- Connect networks overview. Cloudflare’s documentation on Tunnel concepts and architecture: outbound connectors, edge routing, and the relationship between named tunnels and public hostnames.
- Create a locally-managed tunnel. The step-by-step upstream guide this lab follows. Useful if you want to go beyond the workshop configuration (multiple ingress rules, private network routing, WARP access).
- cloudflared CLI reference. Full config file options: ingress rules, proxy settings, metrics server, logging.
Workshop-internal:
- Lab 04: Domain verification.
The DNS delegation setup that makes
api.<student>.eplabs.clouda valid target. If your CNAME is not propagating, that lab’s troubleshooting section covers the delegation chain. - Lab 07: First Worker.
Deploys a Cloudflare Worker to
api.<student>.eplabs.cloud. The nginx placeholder from Lab 06 is replaced; the tunnel itself does not change. - Lab 08: CF Access. Adds Cloudflare Access in front of the tunnel. The open-access URL from Lab 06 becomes token-gated. Lab 08 validate.sh will reject what Lab 06 validate.sh accepts, and vice versa.
View this page’s source:
labs/lab06-cloudflare-tunnel/README.mdx
Troubleshooting
curl to public URL returns 502 or “tunnel not found”
The CNAME record may not have propagated yet, or the tunnel may not be running.
# Check DNS resolves to the tunnel
dig CNAME api.${STUDENT}.eplabs.cloud +short
# Expected: <tunnel-id>.cfargotunnel.com.
# Check cloudflared is running in the operator console
cloudflared tunnel --config /etc/cloudflared/config.yml info
If DNS is correct but the tunnel shows unhealthy, restart cloudflared:
# systemd path
systemctl restart cloudflared
# or manual background path
kill $(pgrep cloudflared) 2>/dev/null || true
cloudflared tunnel --config /etc/cloudflared/config.yml run &
cloudflared fails to authenticate: “failed to unmarshal tunnel credentials”
The credential JSON file is either missing or malformed. Re-download it from the
Cloudflare dashboard (Zero Trust > Networks > Tunnels > your tunnel > Connectors >
Download credentials JSON) and copy it to /root/.cloudflared/<tunnel-id>.json in
the operator console.
The file path must exactly match the credentials-file: value in config.yml.
nginx on port 8787 returns “connection refused”
nginx may not have started or may have exited. Check:
ss -tlnp | grep 8787
If nothing is listening, start nginx again:
nginx -c /tmp/nginx-test.conf &
If nginx is running but cloudflared returns 502, verify the ingress rule uses
http://localhost:8787 (not https; the nginx config does not run TLS).
cloudflared service install fails or systemctl not available
In a Codespace or minimal container, systemd may not be running. Use the background
process approach from Step 5 (cloudflared tunnel run &) and add a restart line to
.devcontainer/post-start.sh as described in Step 7 Option B.
The tunnel connects but I get a Cloudflare 1033 “tunneled virtual network” error
This usually means the tunnel was created in a private network routing mode rather than a public hostname mode. Verify:
- In the Cloudflare dashboard, open your tunnel and go to the Public Hostnames tab.
- Confirm
api.<student>.eplabs.cloudis listed as a public hostname pointing tohttp://localhost:8787. - If it is missing, run
cloudflared tunnel route dns <tunnel-id> api.<student>.eplabs.cloudor add the record manually via the dashboard.
ValidateOutputPaster lab="lab06")