Dev mode — no Cloudflare Access header present. Authenticated as dev@local.
EPL
dev@local

Lab 06 · Day 1, Session 6

Cloudflare Tunnel

Duration: 45 minutes

Not started
Environment:

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.

StepOperator console (Codespace, Local Dev, or Minimal local)
1. Create tunnelyes
2. Write configyes
3. DNS CNAMEyes
4. Test serviceyes
5. Run cloudflaredyes
6. Verify public URLyes (curl from the operator console or your laptop)
7. Persistyes
8. Record versionyes
Validationyes

Learning objectives

  • Understand Cloudflare Tunnel architecture: outbound connection from origin to edge, no inbound firewall rule required.
  • Write and deploy a cloudflared ingress 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 cloudflared shows connected.
  • Record the cloudflared version to labs/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.cloud with Advanced Certificate Manager / Total TLS enabled, so https://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 in handouts/workshop_domain_prerequisites.md covers 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 cloudflared binary 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.

# 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

  1. Open dash.cloudflare.com and select your account.
  2. Go to Zero Trust > Networks > Tunnels.
  3. Click Add a tunnel, select Cloudflared, click Next.
  4. Name the tunnel ep-<student>. Click Save tunnel.
  5. On the next screen, ignore the auto-install instructions. Click Next, then the Overview tab. Note the Tunnel ID (UUID).
  6. On the Connectors tab, click the three-dot menu, then Download credentials JSON. Save the file as <tunnel-id>.json.
  7. 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 the nginx command above again or move the config to /etc/nginx/conf.d/tunnel-test.conf (a location that persists). For the workshop session, /tmp is 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.

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 info shows HEALTHY with at least one registered connection.
  • curl https://api.<student>.eplabs.cloud/ returns HTTP 200 with the nginx body.
  • curl https://api.<student>.eplabs.cloud/health returns JSON {"status":"ok",...}.
  • cloudflared is running (systemd unit or background process) and will restart on operator console restart.
  • labs/output/build-manifest.json contains "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:

  1. curl https://api.<student>.eplabs.cloud/ returns HTTP 200.
  2. /health returns JSON body containing "status" and "ok".
  3. cloudflared process is running in the operator console and tunnel info reports HEALTHY.
  4. DNS CNAME for api.<student>.eplabs.cloud resolves to cfargotunnel.com (advisory check; a warning is emitted if dig is unavailable).
  5. The URL is accessible without auth headers (correct for Lab 06; Lab 08 will flip this expectation).
  6. labs/output/build-manifest.json contains cloudflared_version.
Validate Output Paste your validate.sh output below

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.

NameModelLicenseWhen to consider it
frp (fatedier)self-hosted; client + server binaries; TCP/UDP/HTTP(S)Apache-2.0the canonical OSS tunnel; you run frps on a VPS, frpc on the origin
ratholeself-hosted; Rust rewrite of frp’s wire modelMIT/Apache-2.0when frp’s resource overhead matters; smaller binary, faster
chisel (jpillora)self-hosted; HTTP-tunneled TCP/UDP multiplexer; single binaryMITwhen you want a single Go binary for both sides and do not need frp’s full config surface
boringproxyself-hosted; built around HTTPS reverse-proxy with automatic TLSMITwhen you specifically want Let’s Encrypt baked in at the server side
bore (ekzhang)minimal; Rust; one-shot tunnels over TCPMITfor one-off port forwarding, like ssh -R but without needing an SSH server
ngrokmanaged SaaS; cloud edgeproprietarydemos and dev/preview environments; generous free tier
localtunnelmanaged; minimal Node.js clientMITsmoke tests and CI; simpler invocation than ngrok, no account required
inlets PROclient + server you run; aimed at Kubernetes ingressMIT (OSS) / proprietary (PRO)when running Kubernetes ingress through a tunnel
Tailscale Funnelexposes a tailnet service to the public internetproprietary (Tailscale)when you already run Tailscale; Funnel is a feature on top of the mesh from Lab 05
WireGuard with port-forward at exitmesh primitive applied to egress through a known peerGPL-2.0when you want full network reachability rather than just HTTP(S) forwarding
SSH -Rbog-standard reverse port forwardOpenSSH (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.cloud a 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:

  1. In the Cloudflare dashboard, open your tunnel and go to the Public Hostnames tab.
  2. Confirm api.<student>.eplabs.cloud is listed as a public hostname pointing to http://localhost:8787.
  3. If it is missing, run cloudflared tunnel route dns <tunnel-id> api.<student>.eplabs.cloud or add the record manually via the dashboard.
Validate output paster — available in Wave 2D (ValidateOutputPaster lab="lab06")
Downloadable artifacts for lab06 — served from R2 after Wave 3B deployment