Cloudflare Zero Trust & SASE - Capstone Lab

Zero Trust PS Bootcamp: Individually Auto-Graded Capstone. This is a single full-day lab. You stand up the entire Cloudflare One (SASE) posture for a customer that is still running on flat network trust, then a grader re-reads your live Zero Trust configuration and re-runs the enforcement checks to score all 16 deliverables.

⚠ Read this first: this lab is graded automatically

Unlike the course labs, this capstone is scored. When you are done, you run one command on your enrolled Windows workstation. It logs you in with your email and the capstone access code from your invitation email, reads your live Zero Trust configuration through a read-only token you provide, re-runs the enforcement checks, and emails you a per-item pass/fail rubric.

The grader verifies every item directly, either by reading your live config, by making its own request to a protected hostname, or by reading real enforcement behavior from your enrolled device. When you satisfy all of the requirements in your build, you will get a passing indication on all of the challenges.

You may run the grader as many times as you like. A failed item tells you why it is failing, but not the exact fix. Everything you need is a technique you have already practiced across the course; each phase names the technologies in play so you know which tool to reach for.

Before You Begin: Install Terraform & Create Your API Tokens

Two setup steps before the scenario. Phase 7 provisions your log sink with Terraform, which is not preinstalled on the lab workstations, and several steps need API tokens. Do both now so nothing blocks you later.

1. Install Terraform

Run this once on the Ubuntu server in your pod - it is a single binary, and that box is where you pull logs from anyway. Copy the whole line:

sudo apt-get update -qq && sudo apt-get install -y -qq unzip curl && curl -fsSL https://releases.hashicorp.com/terraform/1.9.8/terraform_1.9.8_linux_amd64.zip -o /tmp/terraform.zip && sudo unzip -o /tmp/terraform.zip -d /usr/local/bin && terraform version
Use Ubuntu for the log sink. The supplied logsink/deploy.sh is a Bash script, so installing Terraform in Windows PowerShell alone will not run it. In Phase 7, you will download the logsink folder to Ubuntu; it also contains ./install-terraform.sh, an installer with checks.

2. Create your API tokens

You need two tokens. Create both now at Cloudflare dashboard → User menu (top-right) → Profile → API Tokens → Create Token (dash.cloudflare.com/profile/api-tokens).

TokenUsed forHow to create it
Read-only grading tokenThe grader (Submit & Grade) reads your live config.Use the Read all resources template. Leave it account-scoped. Save it in the box below, then paste it into the grader when prompted.
Build tokenterraform apply for the Phase 7 sink; its Logs permissions also allow job management through the API. The dashboard wizard uses your signed-in user permissions, not this token.Use Create Custom Token with these Account permissions: Workers Scripts: Edit, D1: Edit, Logs: Edit, and Zero Trust: PII Read. Scope it to your account. Step-by-step below.
Fill in here: your two API tokens

Paste each token into its labeled box when the API Tokens page shows it. Use Copy when the grader or Terraform asks for it. The build token also fills the Terraform copy blocks below. Both tokens stay in this browser tab (including reloads), clear when you close the tab, and are not sent by this guide.

For the grader on the enrolled Windows workstation; do not use this for Terraform.
For Terraform and Logpush; the Phase 7 export commands use this value.
Why two tokens: the grader must never be able to change your configuration, so its token is read-only. Terraform needs edit rights, so the build token has them; Logpush jobs created in the dashboard use the permissions of the user signed in there. Keeping the tokens separate means the grading token cannot alter anything.

Create the build token (step by step)

The build token is the only credential in this capstone that can change your account, so create it by hand with exactly these four permissions - nothing more.

StepAction
1In the dashboard, click User menu (top-right) → Profile, then API Tokens in the My Profile sidebar (dash.cloudflare.com/profile/api-tokens).
2Click Create Token.
3At the top, on the Create Custom Token row, click Get started.
4Token name: enter something you'll recognize, e.g. zt-capstone-build.
5Under Permissions, add the four rows in the table below (leave the group selector on Account for all of them; use + Add more for each additional row).
6Under Account Resources, leave Include and select your account (not "All accounts").
7Leave Client IP Address Filtering and TTL blank.
8Click Continue to summary, confirm the four permissions are listed, then Create Token.
9Copy the token now - Cloudflare shows it only once. Paste it into the build-token box above (and export it on the box in Phase 7). If you lose it, just create another.

The four permissions to add in step 5 (all under the Account group):

GroupPermissionAccessWhy it's needed
AccountWorkers ScriptsEditterraform apply creates the sink Worker (zt-logsink).
AccountD1Editterraform apply creates the sink D1 database (zt-logsink-db).
AccountLogsEditAuthorizes Logpush job management through the API; the dashboard wizard uses your signed-in user account instead.
AccountZero Trust: PIIReadZero Trust datasets carry PII, so Logpush additionally requires PII read.

3. Add your account ID

Paste your account ID below. The Terraform commands in this guide use it alongside the build token you saved above. The build token is held in this browser tab only and clears when you close it; the account ID is saved to this browser. Nothing is sent anywhere.

Find your account ID on the right-side Account details card at Compute → Workers & Pages, or in the dashboard URL (the account-specific segment after dash.cloudflare.com/). The separate Zero Trust → Overview Account details card also shows your team name and domain.
Fill in here: your account ID
Do not export these values yet. The copy block in Phase 7 uses this account ID and the build token above when you provision the log sink. If you close this guide tab before then, re-enter the build token before copying that block.

The AcmeCorp Brief

AcmeCorp is the same company you may have secured from the outside. Inside, it still runs like a flat network: employees connect over a legacy VPN and are trusted the moment they are on it; internal apps answer to anyone who can reach them by IP; unmanaged laptops join without a health check; nothing inspects what leaves the building. Three weeks ago a contractor's unmanaged laptop on the VPN was compromised. Because the VPN grants flat network trust, the attacker pivoted straight to the internal billing intranet and the internal admin SSH box, then exfiltrated a file of customer PII to a personal cloud drive completely uninspected. In the same week the VPN concentrator started buckling under remote load, and a SOC 2 / privacy audit landed on top of the existing PCI posture.

You are the incoming SASE lead for AcmeCorp. Your mandate is to stand up Cloudflare One, close the breach path the contractor's laptop opened, and decommission the flat VPN before the audit closes. The audit maps four demands to NIST 800-207, and the 16 deliverables below are how you satisfy them:

Audit demand (NIST 800-207)You deliver
No implicit network trustPer-request Access policies, a Gateway deny-all floor, and a sealed breach path (Phases 3, 4, 6)
Device health enforcedDevice posture required on every app (Phase 1)
Per-application access decisionsIdentity in front of billing, admin SSH, the clientless contractor app, and the private host - all four use cases (Phases 2, 3)
Egress data controls & accountabilityGateway DNS / Network / HTTP + deny-all + DLP + Browser Isolation, with logs exported for audit (Phases 4, 5, 7)

Work top to bottom. Each phase assumes the ones before it: you establish device trust, publish the app through a tunnel, put identity in front of it, control every byte at the Gateway, stop the data exfil with DLP, then seal the flat path so none of it can be bypassed.

Your Environment

You build the entire posture in the Cloudflare Zero Trust dashboard from your own browser, exactly as you would for a real customer. Use the pod's Ubuntu origin for Terraform and the log sink, and its enrolled Windows workstation for the grading command: the device-side checks depend on evidence only an enrolled device can produce (the Gateway DNS block page, the DLP upload block, the admin-box segmentation, and the breach-path seal). That box has curl.exe available in PowerShell; the Cloudflare One client is not pre-installed - you download, install, and enroll it yourself in 1.2 (do not use any consumer "Cloudflare WARP" app that may already be on the box).

You do your configuration in the Cloudflare Zero Trust dashboard. Use the pod's Windows workstation for the optional Phase 0 recon; enroll it in Phase 1 before running the later per-phase Verify commands and final grader. Do not run them from your own laptop: the device-side checks rest on evidence gathered from the enrolled box (the DNS block page, the DLP block, the admin segmentation, and the seal), which only that WARP-enrolled device produces.
Find your Linux server IP: open the Linux server's web terminal from your pod and run hostname -I (the first 10.91.0.x address), or check your pod's resource list. The grader is IP-agnostic - it verifies the 10.91.0.0/24 range, not a specific address - so any octet your pod assigns is fine.
Fill in here: your team name and server IP

Find your team name on the Account details card at Zero Trust → Overview. Your pod's DNS zone is automatically filled as your team name plus .sxplab.com; you do not need to type it again. Find the Linux server IP with hostname -I in the Ubuntu web terminal (the first 10.91.0.x address). Paste your team name and server IP below. Each matching placeholder, including those in copy blocks, fills automatically. These non-secret values save in this browser only; the grader reads the zone from your token.

<your-domain>
ComponentAddressRole
Your Cloudflare One team<team-name>.cloudflareaccess.comYour Zero Trust organization for this pod. It starts empty - nothing is preconfigured. You connect the shared Trusted SAML Server lab IdP yourself in Phase 1.1 (users itadmin, usera, userb @acmecorp.com in groups such as Finance and IT).
Your DNS zone<your-domain>The registered zone in your account. You publish billing.<your-domain> here.
Windows workstationassigned by your podThe enrolled device (Cloudflare One Client). You run the grader and the device-side Verify commands on this box through the pod session, so you never type its address.
Linux server<ubuntu-ip>Its internal IP is assigned by your pod (it differs pod to pod - never assume a fixed address). Runs the billing intranet on :8000, a public website on :80, SSH on :22, and cloudflared. This is the box the contractor's laptop pivoted into. Use the value you saved above.

How Grading Works

In the lab portal, select the Win11 client tab instead of Ubuntu origin, then open Windows PowerShell on the Win11 desktop.

Check the machine before you copy: the Windows PowerShell prompt starts with PS C:\. If your prompt says cloudflare@origin-server:~$, you are in Ubuntu Bash; irm and iex will show command not found there. Use Ubuntu for the Terraform export TF_VAR_... commands and ./deploy.sh, but run the grader on the enrolled Win11 client.

When you are ready to be scored (or want to check progress), copy this command into that Win11 PowerShell window:

irm https://zt-capstone.cloudflareacademy.com/capstone | iex

The runner opens with a short "before you start" checklist of everything you will need - your course email, access code, a read-only API token, your domain, and the optional service-token and log-sink values - so you can gather them first. Then it walks you through four things:

1. Log in with your access code. Enter the work email you registered for the course, then the capstone access code from your invitation email. This anchors every submission to you.
2. Paste a read-only API token. In the Cloudflare dashboard on your laptop, create an API token from the Read all resources template. Leave it account-scoped so it can read your Zero Trust configuration (Access, Gateway, DLP, Tunnels, Devices) as well as your zone and DNS. Paste it into the runner. Because it is read-only it cannot change anything, and the grader re-reads config itself so a stale paste cannot fool it.
3. The runner gathers evidence from the enrolled device. It runs the enforcement checks from the WARP-enrolled workstation (so the DNS block page, the DLP block, the admin-box segmentation, and the direct-origin seal are read from the device where enforcement actually lands) and uploads the results with your session.
4. The grader scores authoritatively. It re-reads your config through your token, re-runs the checks it can reproduce itself from the edge (the Access redirect on billing.<your-domain>, the service-token path, the tunnel published through Cloudflare), and trusts client evidence only for the device-side enforcement it cannot physically reproduce. It stores the result, emails you the per-item rubric, and marks you passed when all 16 items pass.
It remembers your answers. After the first run the runner saves your inputs (email, access code, token, domain, service token, Ubuntu IP, and log-sink details) to %LOCALAPPDATA%\zt-capstone\answers.json on the enrolled box, with every secret encrypted to your Windows account (useless if the file is copied off the machine). Later runs reuse them automatically without prompting, unless a value is missing or your saved login is rejected. Delete that file to enter fresh values.
Every result is copied to your instructor. The instructor receives the same per-item rubric on every run so he can see where you are stuck and reach out. You do not have to ask for help, but if an item keeps failing, expect a nudge.
Why a token and not screenshots: Zero Trust mistakes hide in config detail a screenshot flatters over: a Gateway rule left on Allow instead of Block, an Access policy that includes a group but forgets to require posture, a DLP profile with no HTTP policy actually referencing it, a split-tunnel list that still excludes the internal range. Reading the live config directly is the only honest way to grade those, and it means you cannot pass by describing work you did not finish.
Names are a convention, not a gate. Every resource name this guide gives you (for example AcmeCorp-Firewall-On, AcmeCorp-Billing, Billing-Finance-Require) is a recommended label to keep the guide and your instructor speaking the same language. The grader identifies each control by its configuration - the posture check that a policy actually requires, the Access policy that includes Finance and requires posture + WARP, the Gateway rule that blocks the C2 domain, the HTTP policy that references a DLP profile - not by its name. If you build the right control but call it something else, it still passes. The only names that are load-bearing are the two hostnames billing.<your-domain> and contractor.<your-domain> (they are the control surface the grader connects to) and the accountability sink zt-logsink / zt-logsink-db (the grader has no other way to find it).

You do not have to guess whether an item passes. Each phase below ends with a Verify block: copy-pasteable commands to self-check that phase from the enrolled workstation before you run the full grader.

This is not a replay of the labs

Each phase hands you a situation and a set of acceptance criteria, not a click-by-click procedure. Your job is to read the incident, decide which control solves it and how to scope it, then build it. Several items are deliberately break-fix (a predecessor left something misconfigured) or turn on a detail the labs did not hand you: the order Gateway evaluates DNS then Network then HTTP, why an Access policy needs a require clause and not just an include, which CIDR a first-week split tunnel gets wrong, and why you inspect TLS everywhere except the one app that pins its certificate.

To keep you oriented, each phase names the technologies in play so you know which part of the Zero Trust dashboard to reach for. How to configure each one is knowledge you already have; the work here is deciding what this customer needs and how to scope it.

Phase 0 - Recon

The contractor's laptop on the old flat VPN could reach internal apps directly by IP, without an identity check or egress inspection. Check what your pod actually exposes before you build the Cloudflare path. Nothing here is graded, and a managed pod may already have its direct inbound path closed.
This phase is optional orientation. The managed pod's Windows workstation starts unenrolled in Cloudflare One; it may have no route to the Ubuntu server's private IP yet. A missing direct connection is not a reason to stop. Record what you see and move on to Phase 1.

0.1 Inspect Starting Reachability

First, in the Ubuntu web terminal, confirm the billing service itself is running (the tunnel in Phase 2 will connect to localhost:8000):

curl -sS -o /dev/null -w 'billing-local %{http_code}\n' --connect-timeout 5 http://localhost:8000/

Then, on the pod's Windows workstation in PowerShell, try the private IP. This is a reachability observation, not a required pass:

curl.exe -sS -o NUL -w "billing-direct %{http_code}`n" --connect-timeout 5 http://<ubuntu-ip>:8000/
Test-NetConnection <ubuntu-ip> -Port 22 -InformationLevel Quiet

billing-direct 200 means Windows can reach the origin directly. billing-direct 000 means no HTTP response; before enrollment this may simply be a missing route between the Windows and Ubuntu networks, or the managed pod may already block inbound :8000. If billing-local is 200 but Windows gets 000, the billing service is running; proceed to Phase 1. If billing-local is also 000, check the billing service on Ubuntu before building its tunnel. The SSH test returns True only if a TCP connection to port 22 succeeds; an HTTP-code curl against an SSH port cannot establish that.

Why: AcmeCorp's old flat-VPN breach path let a compromised device talk straight to billing and SSH. The managed lab does not have to recreate that exposure: the target is a working billing service reachable through Cloudflare with Access enforcement, without a direct-IP bypass.

Now check whether the public billing hostname has been set up yet:

curl.exe -s -o NUL -w "billing.cf %{http_code}`n" https://billing.<your-domain>/
Record your baseline: billing-local should return 200 on Ubuntu. From Windows, direct :8000 may return 200 on a routable pod or 000 on a managed pod without that path; billing.<your-domain> is not expected to work until you publish and protect it. The final seal is scored on Cloudflare Access enforcement; a pre-closed direct path does not need a before/after 200 → 000 transition.

Phase 1 - Identity & Device Trust

The breach started on an unmanaged laptop that the VPN trusted anyway. Before any app decision you establish the three things a Zero Trust posture needs: a way to prove who the user is (an identity provider), a device health signal you can require, and a device profile whose split tunnel actually lets a healthy device reach the internal range.
Deliverables: 2 (device posture + split tunnel)  |  Setup first: connect your identity provider (1.1) and install & enroll the Cloudflare One client (1.2) - neither is scored on its own, but every identity- and device-based item in Phases 2-6 depends on them.  |  Technologies in play: SAML identity provider, WARP device enrollment, device posture checks, and the WARP device profile (split tunnel / Include-Exclude).

1.1 Connect the Trusted SAML Server (identity) - required setup

Your pod's Zero Trust organization starts empty: no identity provider is connected, so the only login method is a one-time PIN. Before you can put identity in front of billing (Phase 3) or scope Gateway rules to a user, you must connect the shared lab identity provider, Trusted SAML Server. You do this exactly as you would for a real customer: register the IdP, hand Cloudflare its metadata, and test the round-trip.

Why this is not a numbered deliverable: connecting an IdP is a prerequisite, not one of the 16 graded items. But the grader's identity checks (the billing Finance policy, the SSH group exception, the contractor app) all assume a working login. If SAML is broken, those items fail for reasons that look unrelated - so get the Test green before you move on.

Step 1 - Register your org on the lab SAML server. In your browser, go to https://labs.cloudflare.com/saml/register and register using your team name (the <team-name> part of <team-name>.cloudflareaccess.com, shown on the Account details card at Zero Trust → Overview, or under Settings → Team name and domain). This provisions a SAML application for your org on the shared authentik server and gives you a metadata XML download. Save that file - it contains the SSO URL, the issuer/entity ID, and the signing certificate.

If you ever need the metadata again it is served at https://labs.cloudflare.com/saml/data/<team-name>/metadata.

Step 2 - Add the SAML provider in Cloudflare. In the Zero Trust dashboard go to Integrations → Identity providers → Add an identity provider → SAML. Then:

Step 3 - Test the round-trip. Back on the Identity providers list, click Test next to Trusted SAML Server (it opens in a new tab). Log in as itadmin@acmecorp.com with password #Savetheinternet. You should land on a Cloudflare page reading "Your connection works!" showing the identity Access will use. If you instead see "Unable to find the requested identity provider" immediately after saving, wait a few seconds and reload - the provider is still propagating.

The lab users (all password #Savetheinternet): itadmin@acmecorp.com - IT admin, use this one to enroll the Cloudflare One client in 1.2; usera@acmecorp.com - in the Finance and IT groups, the user who should pass the billing policy; userb@acmecorp.com - a second user for testing denies. Group membership arrives in the groups SAML attribute you added above.

1.2 Install & enroll the Cloudflare One client - required setup

Your pod's Windows workstation is not enrolled yet - the managed pod ships greenfield. A device only produces trust signals (posture, WARP routing, Gateway enforcement) once it is running the Cloudflare One client and enrolled into your Zero Trust organization. Do this now, as itadmin@acmecorp.com, so the rest of Phase 1 has something to enforce on.

Step 1 - Permit enrollment. A greenfield org allows no enrollments by default. In the Zero Trust dashboard go to Team & Resources → Devices → Management tab → Device enrollment → Manage, add a policy whose rule includes Emails ending in @acmecorp.com with action Allow, and save. Without at least one enrollment policy the client login is rejected.

Step 2 - Download the Cloudflare One client. In the dashboard go to Settings → Resources → Downloads and under Download the Cloudflare One Client pick Windows (Download release, or Download beta for the newest build). You can also grab the Windows build directly from the Cloudflare One client beta-releases page. Do not use any pre-existing consumer "Cloudflare WARP" (1.1.1.1) app - it cannot log in to a Zero Trust team. Run the installer on the Windows workstation.

Step 3 - Enroll the device. Open the Cloudflare One client, go to its menu (gear / Preferences → Account) and choose Login with Cloudflare Zero Trust. Enter your team name (the <team-name> part of <team-name>.cloudflareaccess.com), then authenticate through the Trusted SAML Server as itadmin@acmecorp.com / #Savetheinternet. When it finishes the client status reads Connected and the device appears under Team & Resources → Devices → Your devices.

Why this is not a numbered deliverable: enrolling a device is a prerequisite, not one of the 16 graded items - but every device-side check (posture in 1.3, the DNS block, the DLP block, the admin segmentation, the seal) is read from this enrolled box. If the client is not Connected as an @acmecorp.com identity, those items fail for reasons that look unrelated.

1.3 Enforce Device Health

The auditor's first finding is that unhealthy and unmanaged devices reach internal apps. Create a device posture check that proves a baseline of device health, and make it something a later Access or Gateway policy can require (it is not enough to define it; it has to be referenced so it actually gates something). Name the check AcmeCorp-Firewall-On and base it on the host firewall being enabled.

How this is graded: the grader reads your device posture configuration through your token and confirms a device-posture check (host firewall) exists and is referenced by at least one policy (the Access require clause in 3.1 or the Network policy in 4.2). On the enrolled device, with the firewall on the protected path is reachable; turn the Windows firewall off and the same path fails closed.

1.4 Fix the Split-Tunnel Reach

The workstation you just enrolled can authenticate, but internal resources are still unreachable over WARP, so people keep falling back to the VPN. This is the most common first-week mistake: the default WARP profile excludes the CGNAT range 100.64.0.0/10, which the internal overlay relies on, so traffic to it never enters the tunnel. Correct the device profile's split-tunnel configuration so the internal range is carried by WARP.

Include vs Exclude changes everything. In Exclude mode, anything listed bypasses WARP; in Include mode, only what is listed uses WARP. Whichever mode the profile is in, make sure 100.64.0.0/10 (and the internal 10.91.0.0/16) end up routed through WARP, not around it.
How this is graded: the grader reads your WARP device profile through your token and confirms the split-tunnel configuration routes 100.64.0.0/10 through WARP (removed from Exclude, or present in Include) rather than bypassing it.
Verify Phase 1 on the enrolled Windows workstation.

1.1 Identity. On the Identity providers list, the Test next to Trusted SAML Server completes with "Your connection works!" for itadmin@acmecorp.com. If it fails, no identity-based item downstream can pass.

1.2 Enrollment. The Cloudflare One client reads Connected and the workstation appears under Team & Resources → Devices → Your devices as an @acmecorp.com identity.

1.3 Posture. Confirm the Cloudflare One Client shows a healthy posture and that your AcmeCorp-Firewall-On check evaluates. (There is no curl for the posture definition; the grader reads it from your config. The device-side effect is validated in 3.1 and 4.2.)

1.4 Split tunnel. With WARP connected, the internal overlay range should now be reachable. Confirm WARP is connected and routing:

curl.exe -s https://www.cloudflare.com/cdn-cgi/trace | findstr /R "warp="

A line reading warp=on (or warp=plus) confirms traffic is entering WARP; warp=off means the client is not routing and the split tunnel cannot help yet.

Devices now carry a health signal and can reach the internal overlay through WARP. But the billing intranet is still published to the flat network by raw IP. Time to give it a front door that has no open inbound ports.

Phase 2 - Connectivity

The billing intranet answers on <ubuntu-ip>:8000 to anyone who can route to it. You replace that with an outbound-only Cloudflare Tunnel, publishing the app as billing.<your-domain> so it is reachable only through Cloudflare, with zero inbound ports open on the server.
Deliverables: 1  |  Technologies in play: Cloudflare Tunnel (cloudflared), public hostname routes, and the DNS record that fronts the tunnel.

2.1 Publish Billing Without Open Ports

Stand up (or reuse) the tunnel on the Linux server and add a public hostname route so billing.<your-domain> points at the local billing service http://localhost:8000. The result must be that the app is served through Cloudflare, not by hitting the origin IP.

Policy first, hostname second. Publishing the hostname before you put an Access policy in front of it (Phase 3) leaves billing briefly open to the world. Have your Access app ready, or expect a short window where billing.<your-domain> is unprotected.
How this is graded: the grader reads your tunnel and its ingress rules through your token and confirms a public hostname billing.<your-domain> routes to http://localhost:8000, with a DNS record fronting the tunnel. Connectivity is then proven by the Phase 3 fetches, which reach the app through Cloudflare rather than the raw IP.
Verify Phase 2 on the enrolled Windows workstation.

The hostname should now resolve to Cloudflare and answer (it will redirect to login once Phase 3 is in place; before that it returns the app).

nslookup billing.<your-domain>
curl.exe -s -o NUL -w "billing.cf %{http_code}`n" https://billing.<your-domain>/
Billing now has a Cloudflare front door and no open ports. But right now anyone who reaches that hostname gets in. Put identity and device posture in front of it.

Phase 3 - Access

This is the per-request verification the auditor demanded. You put an Access application in front of billing that requires the right identity and a healthy device, let an automated monitor through with a service token (not an IP bypass), and bring the admin SSH box under Access for Infrastructure with short-lived certificates.
Deliverables: 5  |  Technologies in play: Access applications and policies (include vs require), IdP group selectors, service tokens, session duration, Access for Infrastructure (SSH with short-lived certificates), a clientless reverse-proxy app for an unmanaged device, and browser-rendered SSH/VNC.

3.1 Put Identity in Front of Billing

Create a self-hosted Access application named AcmeCorp-Billing for billing.<your-domain>, with a policy named Billing-Finance-Require. Finance is the only team that should reach billing, and the auditor requires that reaching it also proves device health and that the request comes through the managed client. Set a session short enough that a walk-away laptop re-authenticates within half an hour.

Include is not require. An include clause says who is allowed in; a require clause adds conditions every allowed user must also satisfy. Finance goes in Include; the device posture AcmeCorp-Firewall-On and the Cloudflare One Client (WARP) go in Require. Do not use an IP allowlist: WARP users all share a CGNAT egress, so an IP bypass would let the whole fleet in.
How this is graded: the grader reads the app and policy through your token and confirms an Access app on billing.<your-domain> exists with a policy whose Include is the Finance group, whose Require carries both the device posture and the WARP/One Client check, and whose session duration is 30 minutes. It then fetches https://billing.<your-domain>/ with no session and confirms a 302 redirect to your <team-name>.cloudflareaccess.com login. That DENY also lands in your Access audit log, which the grader uses as corroboration (logs corroborate; they never fail a correctly-built item on their own).

3.2 Let the Monitor Bot In

AcmeCorp runs an automated uptime monitor against billing that has no browser and no human to log in. Issue a service token for it and add a policy named Billing-Monitor-Token on the same app that admits requests presenting that token, without weakening the human policy.

Copy the Client ID and Client Secret when you create the service token at Access controls → Service credentials → Service Tokens tab → Add a service token, then paste them here.

Fill in here: billing monitor service token

These values fill the service-token verification command below. Use Copy when the grader asks for the ID and secret. Both stay in this browser tab only and clear when you close it.

How this is graded: the grader reads your service tokens and policies through your token and confirms a service token exists and that a Service Auth policy on the billing app references it. It then fetches billing.<your-domain> with the CF-Access-Client-Id / CF-Access-Client-Secret headers and expects 200, and without them expects a 302/403. That token ALLOW also appears in your Access audit log, which the grader accepts as corroboration of the allow.

3.3 Lock Down Admin SSH

The attacker also reached the admin box on :22. Bring SSH to the Linux server under Access for Infrastructure so only the IT team can connect, connections require the same device posture, and access uses short-lived certificates instead of static keys (which is what kills the "rotate every key after a breach" fire drill). Command logging should be on.

Config-only item. The grader reads this from your Access for Infrastructure target and policy; there is no scripted SSH handshake in the run. Make sure the target covers the server's SSH, the policy Include is the IT group, the Require carries AcmeCorp-Firewall-On, and short-lived SSH certificates are enabled.
How this is graded: the grader reads your infrastructure target, its SSH policy, and the short-lived certificate configuration through your token, and confirms IT-only Include, the posture Require, and short-lived certs enabled.

3.4 Bring In the Contractor (Clientless)

A contractor on a laptop you do not manage needs one internal app. You cannot install WARP and you cannot run a posture check, so identity-plus-agent is off the table - this is the fourth use case, clientless. Publish the app as a self-hosted (reverse-proxy) Access application named AcmeCorp-Contractor on contractor.<your-domain>, with an Allow policy for the contractor identity. Because it is reverse-proxied, the contractor reaches it in a browser and logs in with no client at all.

Clientless = reverse-proxy Access. A self-hosted Access app is reached through Cloudflare's reverse proxy, so an unmanaged device needs no WARP and no agent - identity alone gates it. The data-control half of this use case (locking what the browser can do) is a Browser Isolation rule you add in 4.5.
How this is graded: the grader confirms a self-hosted Access app on contractor.<your-domain> exists with an Allow policy, then fetches https://contractor.<your-domain>/ with no session and confirms it redirects into the Access login flow.

3.5 Reach the Private Host Without a Bastion

The principle is reach a server in the private space from the public space, identity-brokered, with no bastion host. Stand up a browser-rendered infrastructure app: a self-hosted Access application named AcmeCorp-Infra with browser rendering set to SSH for the private host, an Allow policy for the IT group (no Bypass, no Service Auth), and a tunnel ingress that maps the app hostname to localhost:22. The session runs entirely in the browser - no client, no exposed port.

SSH or VNC - and the audit tradeoff (be precise). The grader accepts either vehicle. Browser-rendered SSH gives you terminal reach and command-level audit (who ran what). Browser-rendered VNC gives graphical reach with connection-level audit only - it does not record keystrokes. SSH is the recommended vehicle here; if you use VNC, do not claim keystroke recording. There is no port seal on this item (sealing the listener would risk locking you out).
How this is graded: the grader confirms a browser-rendered SSH or VNC app exists, has an Allow policy with no Bypass and no Service Auth, and a tunnel ingress to localhost:22 (or the VNC port). The enrolled box then checks that the app's configured hostname responds through Access. That HTTP check cannot prove the SSH/VNC listener works; your interactive browser session above verifies the listener.
Verify Phase 3 on the enrolled Windows workstation.

3.1 Access redirect. With no Access session, billing should redirect (302) to your team login.

curl.exe -s -o NUL -w "no-session %{http_code}`n" https://billing.<your-domain>/

3.2 Service token. The command below uses the Client ID and Client Secret you saved in 3.2; with the headers you should get 200, without them a 302/403.

curl.exe -s -o NUL -w "with-token %{http_code}`n" -H "CF-Access-Client-Id: <service-client-id>" -H "CF-Access-Client-Secret: <service-client-secret>" https://billing.<your-domain>/

3.3 Admin SSH. Config-only, no curl check. Confirm in the dashboard that the infrastructure target, the IT-only policy with posture, and short-lived SSH certs are all in place.

3.4 Clientless contractor app. With no session, the contractor app should redirect into Access login just like billing.

curl.exe -s -o NUL -w "contractor %{http_code}`n" https://contractor.<your-domain>/

3.5 Browser-rendered infra. Open the exact hostname you assigned to your SSH/VNC Access app in a browser through Cloudflare, log in as an IT user, and confirm the in-browser session reaches the private host. The runner prints the hostname and HTTP status it checks from the enrolled box; your interactive session proves the listener works.

Billing and admin SSH now demand identity and device health per request. Next, control what every managed device can do on the network: block the malware callback, segment the admin box, and start inspecting encrypted traffic.

Phase 4 - Gateway

The contractor's laptop called out to a command-and-control domain and pivoted laterally to the admin box, all uninspected. You stand up the Secure Web Gateway in the order Gateway evaluates traffic: DNS to block the C2 domain, Network to segment the admin box, and HTTP with TLS decryption so Phase 5 can see inside egress.
Deliverables: 5  |  Technologies in play: Gateway DNS policies and the block page, Gateway Network policies (identity-aware L4 segmentation), Gateway HTTP with TLS decryption plus Do-Not-Inspect rules, a terminal deny-all floor (rule precedence), and Browser Isolation data controls.

4.1 Block the C2 Domain

Threat intel identified the command-and-control domain the compromised laptop was beaconing to: malware-c2-test.example. Add a Gateway DNS policy named AcmeCorp-DNS-Block that blocks resolution of that specific domain — match on the Domain selector (value malware-c2-test.example), not a security category: this is a synthetic domain that threat-intel categories do not cover, so a category-based rule will never fire on it. Make sure the block page is enabled so a blocked user sees an explanation instead of a silent failure.

The block page is not cosmetic. With the block page off, a blocked DNS query returns 0.0.0.0 and users think the network is broken. Turning it on is what makes the block observable and supportable, and it is what the device-side check looks for.
How this is graded: the grader reads your Gateway DNS policies through your token and confirms a DNS policy has action Block matching the C2 domain, with the block page enabled. On the enrolled device, resolving the domain returns the Cloudflare block-page marker rather than a silent 0.0.0.0.

4.2 Segment the Admin Box

Lateral movement to the admin box on :22 should have been impossible for a non-IT device. Add a Gateway Network policy named AcmeCorp-Net-AdminLockdown that blocks TCP to the admin box on port 22 for everyone except the IT group, and order it so it is evaluated before any broad allow.

Order and identity both matter. A Network policy that blocks <ubuntu-ip>:22 but sits below a permissive allow never fires; and the exception must be keyed on the IT identity group, not an IP, so it follows the user. Reference the same device posture from Phase 1 if you want the belt-and-suspenders version.
How this is graded: the grader reads your Gateway Network policies through your token and confirms a Network policy blocks TCP to the admin box on :22, excludes the IT group, and is ordered above the default allow. On a non-IT enrolled session the connection to :22 is refused.

4.3 Inspect TLS (and What Not To)

The PII exfil in Phase 5 rode out over HTTPS, so the Gateway has to be able to see inside TLS. Turn on TLS decryption account-wide. Then add one Do-Not-Inspect rule named AcmeCorp-TLS-Bypass for a cert-pinned application that breaks under decryption (the number-one post-decryption support ticket). For this lab, use www.google.com as that pinned host: match on the host www.google.com with action Do Not Inspect. Leave every other site decrypted — in particular do not bypass www.example.com, which must stay decrypted so the check can confirm it presents the Cloudflare certificate.

Inspect broadly, bypass narrowly. Decryption is what makes HTTP and DLP policies effective, but a cert-pinned app (a software updater, a mobile store, certain SaaS clients) will fail the handshake through a decrypting proxy. The fix is a targeted Do-Not-Inspect for that host, not turning decryption off.
How this is graded: the grader reads your account Gateway settings and HTTP policies through your token and confirms TLS decryption is on and that a Do-Not-Inspect rule exists for the pinned host. On the enrolled device, a normal HTTPS site presents the Cloudflare certificate while the bypassed host presents its origin certificate.

4.4 Build the Deny-All Floor

Why add a floor? Gateway normally allows traffic that matches no policy. Build explicit exceptions first, then put a Block at the bottom of each policy list so unmatched web traffic and unmatched private-network traffic are denied.

In your Cloudflare account dashboard, open Zero Trust → Traffic controls → Firewall policies → HTTP and select Add a policy.

In Scope → And traffic matches, add a Host condition with operator in; click Copy on each block below, paste it into the Value field, and press Enter before adding the next host.

Allow these six hosts, one Value entry per block:
  • TLS test site:
    www.example.com
  • Phase 5 DLP test target:
    httpbin.org
  • Capstone guide and grader:
    zt-capstone.cloudflareacademy.com
  • Your billing Access app:
    billing.<your-domain>
  • Your Access login:
    <team-name>.cloudflareaccess.com
  • Lab identity provider:
    sso.samlserver.net

Set Then → Action to Allow; in Details → Policy name, paste the copied name below, leave Set policy as active on, and select Create policy.

AcmeCorp-HTTP-Allow

On the same HTTP tab, select Add a policy again; under Scope → And traffic matches, choose Host, operator matches regex, and paste the copied pattern below into Value to match any HTTP hostname not already allowed.

.*

Set the action to Block, paste the copied name below into Details → Policy name, leave the policy active, and select Create policy.

AcmeCorp-HTTP-DenyAll

Drag AcmeCorp-HTTP-DenyAll to the last row of the HTTP policy list, below AcmeCorp-HTTP-Allow and your other HTTP policies.

Open Traffic controls → Firewall policies → Network and select Add a policy.

For the private SSH exception, use And traffic matches with Destination IP is and Destination Port is; paste the first and second copied values below into their respective fields, then under And identity matches use User Group Names in and paste the third value (the IT group from your SAML identity provider, not an Access rule group).

Copy each Network policy value into its labeled field:
  • Destination IP:
    <ubuntu-ip>
  • Destination Port:
    22
  • User Group Names:
    IT

Set the action to Allow, paste the copied name below into Details → Policy name, leave the policy active, and select Create policy.

AcmeCorp-Net-Allow-IT-SSH

On the Network tab, select Add a policy again and set And traffic matches → Destination IP → in; paste the copied range below into Value and leave identity and posture conditions empty so this rule catches all other connections to the lab's private range.

10.91.0.0/16

Set the action to Block, paste the copied name below into Details → Policy name, leave the policy active, and select Create policy.

AcmeCorp-Net-DenyAll

Drag AcmeCorp-Net-DenyAll to the last row of the Network list, below the non-IT SSH Block from 4.2 and the IT SSH Allow; leave the non-IT SSH Block above that Allow.

Check the scope and order:
  • Gateway evaluates each list from top to bottom (lowest precedence number first). The two active DenyAll Blocks must be last in their respective lists; the HTTP Allow must be above its floor.
  • The Network floor covers 10.91.0.0/16, not every Internet IP: Network policies run before HTTP policies, so a Network Block for all public IPs would prevent even allowlisted HTTP sites from loading. Add only specific private-IP/port exceptions above this floor as needed; do not allow direct access to billing on :8000.
  • For HTTPS, the 4.3 Do Not Inspect rule is evaluated before HTTP Allow/Block even if it appears elsewhere in the list. When you add the Phase 5 DLP Block, keep it above the ordinary HTTP Allow; do not use a broad Do Not Inspect rule for the DLP test host.
  • After creating the 4.5 Isolation and 5.1 DLP policies, return to both policy lists and keep each DenyAll Block in the last row. New policies may otherwise land below the floor.
How this is graded: the grader finds an enabled Block last in both the HTTP and Network lists, an enabled HTTP Allow above the HTTP Block, and a blocked request to non-allowlisted example.org from the enrolled Windows box. You can corroborate the block in Gateway HTTP request logs. The policy names help you find the rows but are not graded.

4.5 Isolate the Contractor's Browser

The clientless contractor app from 3.4 still lets data land on a device you do not control. Add a Gateway HTTP policy named AcmeCorp-Contractor-Isolate with the Isolate action for the contractor app, and turn on the Browser Isolation admin controls that disable clipboard (copy/paste), downloads, uploads, and printing. The app now runs in an isolated remote browser, so nothing leaves the isolated session onto the untrusted endpoint.

Config-only item. This is graded from your Gateway HTTP Isolate rule and its data-control flags; there is no scripted isolation handshake in the run. It completes the clientless use case: reverse-proxy Access for identity, Browser Isolation for the data control.
How this is graded: the grader confirms an HTTP policy with the Isolate action whose Browser Isolation admin controls disable clipboard, download, upload, and print.
Verify Phase 4 on the enrolled Windows workstation.

4.1 DNS block. Resolving malware-c2-test.example should return the Gateway block response (the Cloudflare block page), not a normal answer or 0.0.0.0.

nslookup malware-c2-test.example

4.2 Admin segmentation. From a non-IT enrolled session, a connection to the admin box on :22 should be refused/timed out.

curl.exe -s -o NUL -w "admin-ssh %{http_code}`n" --connect-timeout 5 http://<ubuntu-ip>:22/

4.3 Decrypted site. On the enrolled Win11 client, open https://www.example.com/ in the browser, open the connection certificate from the site information icon beside the address, and confirm its issuer is Cloudflare.

4.3 Bypassed site. On that same client, open https://www.google.com/, view its connection certificate, and confirm the issuer is Google rather than Cloudflare.

4.4 Allowed host. On the enrolled Win11 client, confirm www.example.com still responds through Gateway:

curl.exe --ssl-no-revoke -s -o NUL -w "allowlisted %{http_code}`n" https://www.example.com/

4.4 Blocked host. From the same client, test example.org, which you did not put in the HTTP allowlist:

curl.exe --ssl-no-revoke -s -L --connect-timeout 5 --max-time 12 -o NUL -w "not-allowlisted %{http_code}`n" https://example.org/

Expect the allowlisted site to return a successful HTTP response and example.org to show a Gateway block response or connection failure; a normal 200 for example.org means the floor did not catch it.

In Zero Trust → Insights & Logs → Logs → HTTP request logs, select + Add filter, set Field: Policy and Operator: equals, paste the copied policy name below into Value, then find the example.org row with Action: Block.

AcmeCorp-HTTP-DenyAll

If a required host is blocked instead, add that exact Host to AcmeCorp-HTTP-Allow above the floor and repeat the Windows request.

4.5 Browser Isolation. Config-only, no curl check. Confirm the AcmeCorp-Contractor-Isolate HTTP policy uses the Isolate action with clipboard, download, upload and print disabled.

The Gateway now blocks the callback, segments the admin box, and can see inside TLS. That last capability is the one that lets you finally stop the thing the whole incident was about: customer data walking out the door.

Phase 5 - DLP

This is the breach-stopping beat. The attacker uploaded a file of customer records to a personal cloud drive and nothing inspected it. You build a Data Loss Prevention profile that recognizes AcmeCorp's customer-ID pattern and an HTTP policy that blocks uploads containing it.
Deliverables: 1  |  Technologies in play: DLP profiles and custom detection entries (regex), and a Gateway HTTP policy that acts on a DLP match. Requires TLS decryption from 4.3.

5.1 Stop the PII Exfil

AcmeCorp customer IDs look like ACME-1234-5678. Create a DLP profile named AcmeCorp-PII with a custom detection entry that matches that pattern (regex ACME-\d{4}-\d{4}), then add a Gateway HTTP policy with action Block that references the profile and stops uploads carrying that data to personal cloud storage.

Log first, block second, in the real world. Production teams run DLP in Log for a few days to tune out false positives before flipping to Block, or they drown in tickets. For the capstone the accepted end-state is Block, so the exfil is actually stopped, but know why you would stage it.
How this is graded: the grader reads your DLP profile, its custom entry, and your HTTP policies through your token and confirms a DLP profile exists with the customer-ID regex and that an HTTP policy with action Block references it. On the enrolled device (with TLS decryption on and the Cloudflare certificate trusted), an upload containing a matching customer ID is blocked by Gateway, and that block is visible in your Gateway activity log - the log-before-enforce principle made provable.
Verify Phase 5 on the enrolled Windows workstation. This needs TLS decryption (4.3) on and the Cloudflare certificate trusted on the box.

Attempt to POST a payload containing a matching customer ID to the DLP test target; Gateway should block it.

curl.exe --ssl-no-revoke -s -o NUL -w "dlp-upload %{http_code}`n" -X POST https://httpbin.org/post -d "customer=ACME-1234-5678"

A blocked upload shows the Gateway block response rather than a normal 200 from the target.

Identity, device health, segmentation, and egress controls are all in place. If the billing intranet still answers directly on <ubuntu-ip>:8000, that path can bypass the controls above. The final step makes sure only the Cloudflare-protected path remains.

Phase 6 - Seal

You seal any remaining direct path into billing, then replay the opening recon: on a pod that exposed the origin you watch that path die; on an already-closed managed pod you confirm it stays closed. In both cases billing works through Cloudflare Access. This is the proof AcmeCorp needs to decommission the VPN.
Deliverables: 1  |  Technologies in play: origin lockdown (removing the inbound :8000 / :80 path so only the outbound tunnel remains) and enforcement replay.

6.1 Seal the Breach Path & Validate

Ensure the billing intranet cannot be reached directly by IP; the only route to it must be the Cloudflare Tunnel behind your Access policy. If your direct request worked in Phase 0, it must now fail. If the managed pod already closed that route, confirm it stays closed while billing.<your-domain> enforces Access.

How the seal is scored. The authoritative signal is that billing.<your-domain> enforces Access only through Cloudflare - 302 with no session, 200 with the service token, require clause standing. The enrolled device's direct-path reading is environment-aware corroboration: on a self-provisioned pod that could reach <ubuntu-ip>:8000 directly at recon, the grader also confirms that path is dead after your seal; on a managed pod without an initial direct route, the item passes on the Cloudflare-enforcement signal alone. If inbound exposure exists, remove it so cloudflared is the only path in.
How this is graded: the authoritative pass is that through Cloudflare billing.<your-domain> is 302 without a session and 200 with the service token, with the require clause still standing. If your pod could reach the billing intranet directly by IP at recon, the grader additionally confirms that direct path is no longer reachable after the seal; if it was already unreachable from Windows, no before/after transition is required. This is the item that proves the flat breach path is gone.
Why sealing is last: Access, Gateway, and DLP are all meaningless if a device on the flat network can still reach billing directly by IP. Building the posture outward and sealing the direct path last is what turns a set of Zero Trust features into an enforceable perimeter.
Verify Phase 6 on the enrolled Windows workstation. Compare the direct path (should now fail) with the Cloudflare path (should still enforce Access).
curl.exe -s -o NUL -w "direct   %{http_code}`n" --connect-timeout 5 http://<ubuntu-ip>:8000/
curl.exe -s -o NUL -w "cf-none  %{http_code}`n" https://billing.<your-domain>/

The direct request should time out or refuse; the Cloudflare request should return 302 (redirect to login) because Access is still in front.

The breach path is sealed and every control is in force. The last thing the auditor demands is accountability: prove you can account for every byte that leaves, and export those logs off-platform for retention and SIEM - the Zero Trust way, by pulling them down to a private box rather than standing one up to receive them.

Phase 7 - Accountability

Reading logs in the dashboard is not the same as owning them. The SOC 2 auditor wants the Zero Trust logs exported for retention and review. You will stand up your own log sink - a Cloudflare Worker plus a D1 database, in your own account - point Logpush at it, and pull the logs down to the Linux box over an outbound-only connection. No inbound port, no R2, no third-party SIEM: just the leverage a single Worker gives you.
Deliverables: 1  |  Technologies in play: Terraform (to provision the sink), Logpush (HTTP destination, Zero Trust datasets gateway_http and access_requests), Cloudflare Workers + D1 (the sink itself), and an outbound pull from the Linux box.

7.1 Stand Up Your Own Log Sink

Where each part happens: download the files, run Terraform, check the sink, and pull logs in the lab portal's Ubuntu origin Bash terminal. Create the Logpush jobs in your Cloudflare account dashboard. The final grader runs later on the Win11 client in PowerShell.

In your Cloudflare account dashboard, open Compute → Workers & Pages and check the Account details card on the right for a Subdomain ending in .workers.dev.

Check before continuing: The same card also shows your Account ID. Opening Workers & Pages registered the subdomain automatically in a new student account. If the Subdomain field remains empty, use Cloudflare's workers.dev setup instructions before running Terraform.

Get the files on Ubuntu

In the lab portal, select Ubuntu origin and open its Bash terminal (the prompt looks like cloudflare@origin-server:~$).

Copy the next block into that terminal to download the log-sink files from this capstone site to ~/zt-capstone-logsink.

curl -fsSL https://zt-capstone.cloudflareacademy.com/logsink/download.sh -o /tmp/zt-capstone-logsink-download.sh && bash /tmp/zt-capstone-logsink-download.sh && cd "$HOME/zt-capstone-logsink" && ls deploy.sh worker/zt-logsink.js
Check before continuing: The last line lists deploy.sh and worker/zt-logsink.js. The file deploy.sh is now at ~/zt-capstone-logsink/deploy.sh on the Ubuntu machine; it was not already on the pod.

In the Ubuntu terminal, copy this block to install Terraform if needed and print its version; it is safe to run again if you installed Terraform already.

cd "$HOME/zt-capstone-logsink" && ./install-terraform.sh
Check: You should see Terraform v1.9.8 or another installed version, not command not found.

Deploy the log sink

Check your Terraform values before copying the deploy command

Values you already entered appear here automatically. If either box is empty, fill it in here.

Find your account ID on the right-side Account details card at Compute → Workers & Pages. For the build token, use the custom account token you made at API Tokens; if you need a new one, give it Workers Scripts: Edit, D1: Edit, Logs: Edit, and Zero Trust: PII Read for this account. Do not use the read-only grading token here.

Check that both export lines in the deploy block show your real values rather than orange angle-bracket placeholders.

Copy the whole block below into the Ubuntu origin Bash terminal; it moves into the downloaded folder, supplies the two values to Terraform, and runs deploy.sh.

cd "$HOME/zt-capstone-logsink" &&
export TF_VAR_cloudflare_account_id="<cf-account-id>" &&
export TF_VAR_cloudflare_api_token="<cf-api-token>" &&
./deploy.sh
Check the output: Look for Authenticated sink check passed (HTTP 200), zt-logsink is LIVE, a Sink URL ending in .workers.dev, and a Sink token. Re-running the block is safe: it keeps your existing database and token. The Sink URL uses your account's workers.dev subdomain from the Account details card, not the team name used for your lab's .sxplab.com zone. Terraform creates the Worker zt-logsink and its database zt-logsink-db in your account.

Copy the Sink URL and Sink token from the Ubuntu terminal output into the matching fields below.

Fill in here: your log sink details

The sink URL and SINK_TOKEN fill the health check, both Logpush destinations, and the log-pull command. The URL saves in your browser; the token stays in this tab only and clears when you close it. Nothing is sent by this guide.

In the Ubuntu origin Bash terminal, copy this authenticated check after filling both sink fields. It verifies that the deployed Worker accepts your token and can read its database; the public /health route cannot check either one.

curl -fsS "<sink-url>/stats?token=<sink-token>"
Check the result: Look for {"ok":true,"total":0,...} (the total can be higher if logs have already landed). If the request returns 401 after deploy.sh reports authenticated HTTP 200, give your instructor this command's output and the deploy output so the two requests can be compared. If the first request reports an SSL/TLS handshake error, wait 2-5 minutes for the new workers.dev hostname to become ready, then rerun ./deploy.sh.

Connect the first Logpush job: Gateway HTTP

In your Cloudflare account dashboard, open Observability → Export → Logpush and stay in the Account-scoped Logpush section (not the zone/domain section).

Click Create a Logpush job and choose HTTP destination.

In Enter destination details, paste the first line below into the required HTTP endpoint field (the same gateway_http URL printed by deploy.sh); this is a destination URL for the dashboard, not a terminal command.

<sink-url>/ingest?token=<sink-token>&dataset=gateway_http
If the dashboard reports error uploading to https: status:401: The destination rejected authentication. Give your instructor the exact HTTP endpoint value and the authenticated check result above. A 401 alone does not identify whether the copied value, the received request, or the deployed binding differs.

Click Continue to validate the HTTP endpoint; the log-sink Worker answers Cloudflare's test request for you.

Under Select dataset, choose the Zero Trust Gateway HTTP dataset.

Under Configure logpush job, give the job a name, leave it enabled, and create it.

Connect the second Logpush job: Access requests

Return to Observability → Export → Logpush → Account-scoped Logpush, click Create a Logpush job, and choose HTTP destination again.

In Enter destination details, paste this second line into HTTP endpoint instead of the Gateway HTTP line.

<sink-url>/ingest?token=<sink-token>&dataset=access_requests

Click Continue to validate the endpoint, select the Zero Trust Access requests dataset, name the job, leave it enabled, and create it.

Check the Jobs tab: Both Gateway HTTP and Access requests jobs should be listed as enabled, with HTTP destinations pointing to your zt-logsink Worker. The token= parameter supplies the sink's own shared token; dataset= labels each stream.
If the dashboard reports insufficient permissions: confirm that you are signed in to the correct student account and show the exact message to your instructor. The dashboard uses your user permissions, not the Terraform build token. API-based job creation requires Logs Edit and Zero Trust: PII Read on the API token.

Pull and check your logs

On the enrolled Win11 client, open billing.<your-domain> in the browser and visit a website your Gateway allowlist permits so the two jobs have traffic to send.

Return to the Ubuntu origin Bash terminal and copy this block to pull the Gateway HTTP logs into a readable table.

cd "$HOME/zt-capstone-logsink" && ./pull-logs.sh "<sink-url>" "<sink-token>" gateway_http 50
Check the result: Look for Pulled 1 record(s) or a higher number and a table of log entries. If it says Pulled 0 record(s), wait a few minutes for Logpush to deliver a batch, generate more traffic on the Win11 client, and copy the same pull block again.

You can also open the lightweight sink viewer in a browser to see what has landed.

Why pull, not push: a traditional log stack stands up a syslog/SIEM box listening on an inbound port - another door to defend. The Zero Trust pattern inverts it: Logpush delivers to a Worker you own, and your box reaches outbound to pull, exactly like cloudflared does. You get retention and export with no new inbound attack surface.
Honest about freshness: Logpush ships near-real-time batches, not an instant stream, so allow a short delay between generating traffic and seeing rows. It is fresher than the dashboard audit views, but it is not live tail.
How this is graded: the grader finds your sink by its pinned names and checks three signals: an enabled Logpush job for a Zero Trust dataset (gateway_http or access_requests) whose HTTP destination is your zt-logsink Worker; your zt-logsink-db database exists; and the enrolled Win11 runner fetches rows from the sink over outbound HTTPS. The Ubuntu pull above is your own verification; it does not supply the runner's evidence. Do not rename the Worker or the database.

Submit & Grade

When you have worked through the phases, switch to the lab portal's Win11 client tab and open Windows PowerShell on that desktop.

Run this on Windows, not Ubuntu: look for a PS C:\ prompt. The cloudflare@origin-server:~$ prompt is Ubuntu Bash; it does not have PowerShell's irm or iex commands. Your Terraform exports on Ubuntu are for the log sink, not for running the grader.

Copy the grading command into that Win11 PowerShell window:

irm https://zt-capstone.cloudflareacademy.com/capstone | iex

Log in with your course email and your capstone access code, paste your read-only account-scoped token, and give the runner the Sink URL and SINK_TOKEN from the Phase 7 deploy output when it prompts for them. It fetches sink rows on Win11, prints a per-item pass/fail list, and emails you the same rubric; all 16 items must pass. A failed item tells you why it is failing but not the fix. Correct it and re-run: there is no retry limit.

Give propagation a moment. Freshly published Access apps, Gateway rules, and device profile changes can take a minute or two to reach the edge and the enrolled client. If an item you just built fails, wait a couple of minutes (and re-connect WARP if you changed the profile) before assuming it is wrong.
Re-runs skip the typing. The runner prints a "before you start" checklist of what to have ready, then automatically reuses your saved answers (secrets stay encrypted to your Windows account). It only asks for missing values or a replacement email and code when your saved login is rejected. Delete %LOCALAPPDATA%\zt-capstone\answers.json to start clean.

Deliverables Checklist

All 16 must pass. This is the exact list the grader scores, in phase order.

#PhaseDeliverable
1Identity & DeviceDevice posture AcmeCorp-Firewall-On defined and referenced by a policy
2Identity & DeviceSplit tunnel routes 100.64.0.0/10 (and internal 10.91.0.0/16) through WARP
3ConnectivityTunnel publishes billing.<your-domain> to http://localhost:8000
4AccessApp AcmeCorp-Billing + policy Billing-Finance-Require (Finance include; posture + WARP require; 30m session)
5AccessService token + policy Billing-Monitor-Token (200 with token, 302/403 without)
6AccessAccess for Infrastructure SSH: IT-only, posture required, short-lived certs
7AccessClientless app AcmeCorp-Contractor (reverse-proxy, no agent) enforces login
8AccessBrowser-rendered infra app AcmeCorp-Infra (SSH/VNC): IT-only Allow, tunnel to the private host, no bastion
9GatewayDNS policy AcmeCorp-DNS-Block blocks the C2 domain, block page on
10GatewayNetwork policy AcmeCorp-Net-AdminLockdown blocks :22 except IT
11GatewayTLS decryption on + Do-Not-Inspect AcmeCorp-TLS-Bypass for the pinned host
12GatewayDeny-all floor: terminal Block last in HTTP and Network (AcmeCorp-HTTP-DenyAll / AcmeCorp-Net-DenyAll), allowlist above
13GatewayBrowser Isolation AcmeCorp-Contractor-Isolate locks clipboard/download/upload/print
14DLPProfile AcmeCorp-PII + HTTP Block policy stops the customer-ID exfil
15SealDirect :8000 path sealed; billing enforces Access only through Cloudflare
16AccountabilityStudent-built Logpush sink (zt-logsink + zt-logsink-db): enabled ZT-dataset job to your sink, rows delivered, outbound pull works

Troubleshooting

SymptomCause & fix
Posture item fails even though the check existsA posture check that is defined but not referenced gates nothing. Add AcmeCorp-Firewall-On to the Access require clause (3.1) or the Network policy (4.2) so it is actually in force.
Internal resources still unreachable over WARPThe device profile still excludes 100.64.0.0/10. Remove it from Exclude (or add it to Include), then reconnect WARP so the new profile downloads.
Billing lets anyone in / no login promptThe policy has an Include but no Require, or the app is not actually attached to billing.<your-domain>. Add the posture + WARP require and confirm the app path.
Service token request still redirects to loginThe Billing-Monitor-Token policy is below the human policy or does not reference the token. Service-token policies use the Service Auth decision and must reference your token.
DNS block "works" but users report the internet is downBlock page is off, so blocked queries return 0.0.0.0. Enable the Gateway block page.
Admin lockdown never blocksThe Network policy sits below a broad allow, or keys the exception on an IP instead of the IT group. Order it above the allow and use the identity group.
A pinned app breaks after you enable TLS decryptionExpected. Add the AcmeCorp-TLS-Bypass Do-Not-Inspect rule for that host; do not turn decryption off globally.
DLP never blocks the test uploadDLP over HTTPS needs TLS decryption on (4.3) and the Cloudflare certificate trusted on the enrolled box. Confirm both, and that the HTTP policy action is Block and references AcmeCorp-PII.
Login says your email is not recognizedUse the exact work email you registered for the Zero Trust course. The grader is gated to that allow-list.
Runner cannot read your Zero Trust configThe token is zone-scoped only. Use the Read all resources template left account-scoped so it can read Access, Gateway, DLP, Tunnels, and Devices.
Contractor app does not prompt for loginIt must be a self-hosted (reverse-proxy) Access app on contractor.<your-domain> with an Allow policy - not a Bypass. Clientless means reverse-proxy: no WARP, identity gates it.
Browser-rendered infra item failsThe app must be a browser-rendered SSH or VNC app (not a plain web app), have an Allow policy with no Bypass and no Service Auth, and a tunnel ingress to localhost:22 (SSH) or the VNC port. The brokered session must actually reach the listener.
Deny-all floor never blocks / blocks everythingThe terminal Block must be ordered last (highest precedence value) in both HTTP and Network, with your allowlist ordered above it (lower precedence). If everything is blocked, your allow rules are below the floor; if nothing is blocked, the floor is not last or not set to Block.
Browser Isolation item failsThe AcmeCorp-Contractor-Isolate HTTP policy must use the Isolate action with the admin controls that disable clipboard, download, upload, and print. Config-only - no device check.
deploy.sh says Terraform is not installedRun ./install-terraform.sh from ~/zt-capstone-logsink on Ubuntu until it prints a Terraform version, then copy the Phase 7 deploy block again.
deploy.sh / terraform apply errors on authCheck the account ID and build-token fields beside the deploy block in Phase 7, then copy the full block into Ubuntu again. Re-running is safe: Terraform reuses your database and token.
Logpush HTTP endpoint test returns 401Destination authentication failed. Give your instructor the copied HTTP endpoint and the Phase 7 authenticated /stats result so they can compare the requests.
Logpush dashboard reports insufficient permissionsStay on the Account-scoped Logpush page and record the exact message and account name for your instructor. The signed-in dashboard user's access controls job creation; the API build token is used by Terraform, not by this wizard.
Accountability item fails though the job existsConfirm the job is enabled, its destination is your zt-logsink URL, and you did not rename the Worker or zt-logsink-db. Logpush sends batches, so generate traffic and wait for rows; then run the grader on Win11 and give it the Sink URL and SINK_TOKEN when prompted. Earlier blank optional answers will be requested again on the next run.

Architecture Recap

You took AcmeCorp from a flat, VPN-trusted network with a live breach path to a sealed, validated Cloudflare One posture. Here is what each layer defends.

LayerPhaseWhat it defends
Device posture & WARP profile1Only healthy, managed devices get a trust signal, and they can actually reach internal resources.
Cloudflare Tunnel2Publishes billing with zero inbound ports so the origin IP is no longer a target.
Access (apps, policies, service tokens, infra SSH)3Verifies identity and device health per request for humans, machines, and admin SSH.
Clientless Access + browser-rendered infra3Brings an unmanaged contractor and a private host under identity without an agent - the clientless and infrastructure use cases, no bastion.
Gateway (DNS, Network, HTTP/TLS)4Blocks the C2 callback, segments the admin box, and opens up encrypted egress for inspection.
Gateway deny-all floor + Browser Isolation4Turns allow-by-default into deny-by-default with an explicit allowlist, and keeps contractor data inside an isolated remote browser.
DLP5Recognizes customer PII and blocks the exfil that the whole incident was about.
Sealed breach path6Removes the flat direct-IP route so none of the above can be bypassed.
Logpush sink (Workers + D1)7Exports Zero Trust logs off-platform for retention and audit, pulled outbound to a box you own - accountability with no new inbound door.
Why this ordering matters: each layer assumes the ones before it. Establishing device trust first, publishing through a tunnel, then layering Access and Gateway, and sealing the flat path last is what turns a set of Zero Trust features into an enforceable perimeter, and replaying the Phase 0 recon is how you prove it to the auditor.