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.
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
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).
| Token | Used for | How to create it |
|---|---|---|
| Read-only grading token | The 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 token | terraform 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. |
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.
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.
| Step | Action |
|---|---|
| 1 | In the dashboard, click User menu (top-right) → Profile, then API Tokens in the My Profile sidebar (dash.cloudflare.com/profile/api-tokens). |
| 2 | Click Create Token. |
| 3 | At the top, on the Create Custom Token row, click Get started. |
| 4 | Token name: enter something you'll recognize, e.g. zt-capstone-build. |
| 5 | Under 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). |
| 6 | Under Account Resources, leave Include and select your account (not "All accounts"). |
| 7 | Leave Client IP Address Filtering and TTL blank. |
| 8 | Click Continue to summary, confirm the four permissions are listed, then Create Token. |
| 9 | Copy 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):
| Group | Permission | Access | Why it's needed |
|---|---|---|---|
| Account | Workers Scripts | Edit | terraform apply creates the sink Worker (zt-logsink). |
| Account | D1 | Edit | terraform apply creates the sink D1 database (zt-logsink-db). |
| Account | Logs | Edit | Authorizes Logpush job management through the API; the dashboard wizard uses your signed-in user account instead. |
| Account | Zero Trust: PII | Read | Zero 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.
The AcmeCorp Brief
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 trust | Per-request Access policies, a Gateway deny-all floor, and a sealed breach path (Phases 3, 4, 6) |
| Device health enforced | Device posture required on every app (Phase 1) |
| Per-application access decisions | Identity in front of billing, admin SSH, the clientless contractor app, and the private host - all four use cases (Phases 2, 3) |
| Egress data controls & accountability | Gateway 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).
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.
| Component | Address | Role |
|---|---|---|
| Your Cloudflare One team | <team-name>.cloudflareaccess.com | Your 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 workstation | assigned by your pod | The 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.
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:
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.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.
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
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.
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>/
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
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.
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.
Step 2 - Add the SAML provider in Cloudflare. In the Zero Trust dashboard go to Integrations → Identity providers → Add an identity provider → SAML. Then:
- Under Populate details with .xml file, upload the metadata file from Step 1. The Single sign-on URL, IdP Entity ID / Issuer URL, and Signing certificate fields auto-populate - do not hand-type them.
- Set Name to
Trusted SAML Server. - Under SAML attributes, click Add attribute and enter
groups. This is what makes the Finance / IT group memberships available to your Access policies later - without it, a policy that includes the Finance group has nothing to match. - Leave Email attribute name as
email. Click Save.
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.
#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.
@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.
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.
100.64.0.0/10 through WARP (removed from Exclude, or present in Include) rather than bypassing it.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.
Phase 2 - Connectivity
<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.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.
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.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>/
Phase 3 - Access
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.
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.
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.
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.
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.
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.
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.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.
Phase 4 - Gateway
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.
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.
: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.
4.4 Build the Deny-All Floor
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.
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).
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.
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.
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.
Phase 5 - DLP
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.
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.
<ubuntu-ip>:8000, that path can bypass the controls above. The final step makes sure only the Cloudflare-protected path remains.Phase 6 - Seal
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.
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.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.
Phase 7 - Accountability
7.1 Stand Up Your Own Log Sink
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.
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
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
Terraform v1.9.8 or another installed version, not command not found.Deploy the log sink
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
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.
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>"
{"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
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.
zt-logsink Worker. The token= parameter supplies the sink's own shared token; dataset= labels each stream.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
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.
cloudflared does. You get retention and export with no new inbound attack surface.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.
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.
Deliverables Checklist
All 16 must pass. This is the exact list the grader scores, in phase order.
| # | Phase | Deliverable |
|---|---|---|
| 1 | Identity & Device | Device posture AcmeCorp-Firewall-On defined and referenced by a policy |
| 2 | Identity & Device | Split tunnel routes 100.64.0.0/10 (and internal 10.91.0.0/16) through WARP |
| 3 | Connectivity | Tunnel publishes billing.<your-domain> to http://localhost:8000 |
| 4 | Access | App AcmeCorp-Billing + policy Billing-Finance-Require (Finance include; posture + WARP require; 30m session) |
| 5 | Access | Service token + policy Billing-Monitor-Token (200 with token, 302/403 without) |
| 6 | Access | Access for Infrastructure SSH: IT-only, posture required, short-lived certs |
| 7 | Access | Clientless app AcmeCorp-Contractor (reverse-proxy, no agent) enforces login |
| 8 | Access | Browser-rendered infra app AcmeCorp-Infra (SSH/VNC): IT-only Allow, tunnel to the private host, no bastion |
| 9 | Gateway | DNS policy AcmeCorp-DNS-Block blocks the C2 domain, block page on |
| 10 | Gateway | Network policy AcmeCorp-Net-AdminLockdown blocks :22 except IT |
| 11 | Gateway | TLS decryption on + Do-Not-Inspect AcmeCorp-TLS-Bypass for the pinned host |
| 12 | Gateway | Deny-all floor: terminal Block last in HTTP and Network (AcmeCorp-HTTP-DenyAll / AcmeCorp-Net-DenyAll), allowlist above |
| 13 | Gateway | Browser Isolation AcmeCorp-Contractor-Isolate locks clipboard/download/upload/print |
| 14 | DLP | Profile AcmeCorp-PII + HTTP Block policy stops the customer-ID exfil |
| 15 | Seal | Direct :8000 path sealed; billing enforces Access only through Cloudflare |
| 16 | Accountability | Student-built Logpush sink (zt-logsink + zt-logsink-db): enabled ZT-dataset job to your sink, rows delivered, outbound pull works |
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| Posture item fails even though the check exists | A 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 WARP | The 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 prompt | The 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 login | The 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 down | Block page is off, so blocked queries return 0.0.0.0. Enable the Gateway block page. |
| Admin lockdown never blocks | The 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 decryption | Expected. Add the AcmeCorp-TLS-Bypass Do-Not-Inspect rule for that host; do not turn decryption off globally. |
| DLP never blocks the test upload | DLP 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 recognized | Use 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 config | The 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 login | It 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 fails | The 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 everything | The 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 fails | The 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 installed | Run ./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 auth | Check 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 401 | Destination 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 permissions | Stay 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 exists | Confirm 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.
| Layer | Phase | What it defends |
|---|---|---|
| Device posture & WARP profile | 1 | Only healthy, managed devices get a trust signal, and they can actually reach internal resources. |
| Cloudflare Tunnel | 2 | Publishes billing with zero inbound ports so the origin IP is no longer a target. |
| Access (apps, policies, service tokens, infra SSH) | 3 | Verifies identity and device health per request for humans, machines, and admin SSH. |
| Clientless Access + browser-rendered infra | 3 | Brings 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) | 4 | Blocks the C2 callback, segments the admin box, and opens up encrypted egress for inspection. |
| Gateway deny-all floor + Browser Isolation | 4 | Turns allow-by-default into deny-by-default with an explicit allowlist, and keeps contractor data inside an isolated remote browser. |
| DLP | 5 | Recognizes customer PII and blocks the exfil that the whole incident was about. |
| Sealed breach path | 6 | Removes the flat direct-IP route so none of the above can be bypassed. |
| Logpush sink (Workers + D1) | 7 | Exports Zero Trust logs off-platform for retention and audit, pulled outbound to a box you own - accountability with no new inbound door. |