Infrastructure / Networking / Cloudflare Tunnel · IIS
Production · Verified
Runbook RB-CF-001

Expose IIS via
Cloudflare Tunnel

Zero-to-public-URL guide for exposing a Windows 10 IIS application through Cloudflare Tunnel on a Telus residential connection with CGNAT. Every command is copy-paste ready. Every expected output is shown.

Windows 10 IIS cloudflared Cloudflare Zero Trust guydev.ca Telus CGNAT v2.0 Post-Debug
§ 00

Pre-Flight Checklist

Complete every item before executing any step. Do not proceed until all boxes are checked.

Accounts & Access

  • Cloudflare account active — can log in at dash.cloudflare.com
  • Domain guydev.ca shows Active under Websites → guydev.ca → Overview
  • Cloudflare Zero Trust initialized — one.dash.cloudflare.com is accessible
  • Cloudflare nameservers are authoritative for the domain

Software

  • Windows 10 — confirmed via winver
  • IIS installed — confirmed in Windows Features
  • PowerShell 5.1+ — $PSVersionTable.PSVersion Major ≥ 5
  • winget available — winget --version returns a version string
  • curl.exe available — curl.exe --version returns a version string

Variable Registry — Fill in Before Continuing

VariableYour Value
TUNNEL_NAMEGuyStoreHome
TUNNEL_IDYOUR-TUNNEL-ID
DOMAINguydev.ca
PUBLIC_URL_WWWhttps://www.guydev.ca
PUBLIC_URL_APEXhttps://guydev.ca
IIS_SITE_NAMEGuyStore
WINDOWS_USERNAMEYOUR-USERNAME
§ 01

Verify IIS Is Ready

1.1 — Confirm GuyStore site is Started

Open IIS Manager (inetmgr in Run dialog) → Sites → GuyStore → Status must show Started.

1.2 — Confirm IIS redirects HTTP → HTTPS correctly

IIS on this machine redirects HTTP to HTTPS. Test the full redirect chain using curl.exe (not Invoke-WebRequest — it does not support -SkipCertificateCheck on PowerShell 5.1).

PS Administrator PowerShell
curl.exe -k -L http://localhost -o NUL -w "%{http_code} %{url_effective}"
✓ Expected Output
200 https://localhost/

200 https://localhost/ confirms IIS accepts HTTPS on port 443 and serves content. Any other result — fix IIS before continuing.

1.3 — Confirm port 443 is owned by IIS

PS Administrator PowerShell
netstat -ano | findstr ":443 "
✓ Expected Output
TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 4

PID 4 is Windows HTTP.sys (IIS). Any other PID means a conflicting process — stop it before continuing.

Stop — Confirm Before Continuing

curl.exe returned 200 https://localhost/ and port 443 is owned by PID 4.

§ 02

Install cloudflared

2.1 — Install via winget

PS Administrator PowerShell
winget install --id Cloudflare.cloudflared --exact --accept-source-agreements --accept-package-agreements
✓ Expected Output (final line)
Successfully installed: Cloudflare cloudflared

2.2 — Reload PATH

PS Administrator PowerShell
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")

2.3 — Verify installation

PS Administrator PowerShell
cloudflared --version
✓ Expected Output
cloudflared version 2025.x.x (built ...)
Stop — Confirm Before Continuing

cloudflared --version returns a version string without error.

§ 03

Authenticate cloudflared

3.1 — Run the login command

PS Administrator PowerShell
cloudflared tunnel login

A browser window opens to dash.cloudflare.com. On the page titled Authorize Cloudflare Tunnel: click guydev.ca → click Authorize.

3.2 — Confirm cert.pem exists

PS Administrator PowerShell
Test-Path "$env:USERPROFILE\.cloudflared\cert.pem"
✓ Expected Output
True
Stop — Confirm Before Continuing

Test-Path returns True. cert.pem is present.

§ 04

Create the Tunnel

4.1 — Create a named tunnel

PS Administrator PowerShell
cloudflared tunnel create GuyStoreHome
✓ Expected Output
Tunnel credentials written to C:\Users\YOUR-USERNAME\.cloudflared\YOUR-TUNNEL-ID.json Created tunnel GuyStoreHome with id YOUR-TUNNEL-ID

4.2 — Confirm credentials JSON exists

PS Administrator PowerShell
Get-ChildItem "$env:USERPROFILE\.cloudflared\" -Filter "*.json"
✓ Expected Output
YOUR-TUNNEL-ID.json
Stop — Confirm Before Continuing

TUNNEL_ID recorded. Credentials JSON file confirmed present.

§ 05

Write config.yml

Critical — SYSTEM account path The Windows service runs as the SYSTEM account, which cannot read C:\Users\YOUR-USERNAME\. All cloudflared files must be written to — and config.yml must reference paths under — C:\Windows\System32\config\systemprofile\.cloudflared\.

5.1 — Create the SYSTEM profile directory

PS Administrator PowerShell
New-Item -ItemType Directory -Path "C:\Windows\System32\config\systemprofile\.cloudflared" -Force

5.2 — Copy all cloudflared files to SYSTEM profile

PS Administrator PowerShell
Copy-Item -Path "$env:USERPROFILE\.cloudflared\*" -Destination "C:\Windows\System32\config\systemprofile\.cloudflared\" -Recurse -Force

5.3 — Write config.yml to SYSTEM profile

Why https://localhost:443 and noTLSVerify IIS is configured to redirect all HTTP traffic to HTTPS. cloudflared must therefore connect to IIS on port 443. Since IIS uses a self-signed certificate for localhost, noTLSVerify: true is required to prevent cloudflared from rejecting it. This is safe — the self-signed cert is only used on the loopback interface.
PS Administrator PowerShell
@" # Tunnel UUID — must match the tunnel created in Section 4 tunnel: YOUR-TUNNEL-ID # Absolute path using SYSTEM profile — NOT C:/Users/YOUR-USERNAME/ # The Windows service runs as SYSTEM and cannot read user profile paths credentials-file: C:/Windows/System32/config/systemprofile/.cloudflared/YOUR-TUNNEL-ID.json # Force HTTP/2 (TCP) — required on Telus CGNAT # QUIC (UDP) connections drop after ~4-5 min due to NAT state expiry protocol: http2 ingress: # Route www subdomain to IIS over HTTPS (IIS forces HTTP→HTTPS redirect) - hostname: www.guydev.ca service: https://localhost:443 originRequest: noTLSVerify: true # IIS uses self-signed cert on localhost # Route apex domain to IIS - hostname: guydev.ca service: https://localhost:443 originRequest: noTLSVerify: true # Required catch-all — cloudflared refuses to start without this - service: http_status:404 "@ | Set-Content -Path "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" -Encoding UTF8

5.4 — Validate config

PS Administrator PowerShell
cloudflared --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel ingress validate
✓ Expected Output
OK
Stop — Confirm Before Continuing

ingress validate returns OK. credentials-file path uses the SYSTEM profile, not the user profile.

§ 06

Configure Cloudflare DNS

6.1 — Create www CNAME record

PS Administrator PowerShell
cloudflared tunnel route dns GuyStoreHome www.guydev.ca
✓ Expected Output
INF Added CNAME www.guydev.ca which will route to this tunnel tunnelID=YOUR-TUNNEL-ID

6.2 — Create apex DNS record

PS Administrator PowerShell
cloudflared tunnel route dns GuyStoreHome guydev.ca
Apex CNAME Flattening The apex domain (guydev.ca with no subdomain) cannot be a standard CNAME in DNS. Cloudflare handles this with CNAME flattening — it stores it internally as a CNAME but serves it externally as an A record pointing to Cloudflare proxy IPs. Verification must use -Type A, not -Type CNAME.

6.3 — Verify www resolves correctly

PS Administrator PowerShell
Resolve-DnsName www.guydev.ca -Type CNAME | Select-Object Name, NameHost
✓ Expected Output
Name NameHost ---- -------- www.guydev.ca YOUR-TUNNEL-ID.cfargotunnel.com

6.4 — Verify apex resolves (query Cloudflare resolver directly)

PS Administrator PowerShell
Resolve-DnsName guydev.ca -Type A -Server 1.1.1.1 | Select-Object Name, IPAddress
✓ Expected Output
Name IPAddress ---- --------- guydev.ca 104.21.60.213 guydev.ca 172.67.201.201

These are Cloudflare proxy IPs — correct. If the local resolver still returns blank, flush the cache and wait for the Telus router cache to expire (up to 15 minutes):

PS Administrator PowerShell
Clear-DnsClientCache
Stop — Confirm Before Continuing

www.guydev.ca CNAME points to cfargotunnel.com. guydev.ca via 1.1.1.1 returns Cloudflare proxy IPs. Both records show orange cloud (Proxied) in the Cloudflare dashboard DNS → Records.

§ 07

Fix IIS Binding Host Header

Why This Step Is Required When cloudflared forwards a request from www.guydev.ca to https://localhost:443, it passes the original Host: www.guydev.ca header. If the IIS binding has a hostname restriction set (e.g. *:443:localhost), IIS rejects the request with 404 because the host header does not match. The binding must have no hostname restriction so IIS accepts requests for any host header.

7.1 — Clear host header on HTTP binding

PS Administrator PowerShell
Import-Module WebAdministration Set-WebBinding -Name "GuyStore" -BindingInformation "*:80:localhost" -PropertyName "bindingInformation" -Value "*:80:"

7.2 — Clear host header on HTTPS binding

PS Administrator PowerShell
Set-WebBinding -Name "GuyStore" -BindingInformation "*:443:localhost" -PropertyName "bindingInformation" -Value "*:443:"

7.3 — Verify bindings are clear

PS Administrator PowerShell
Get-WebBinding -Name "GuyStore" | Select-Object protocol, bindingInformation
✓ Expected Output — no hostname after final colon
protocol bindingInformation -------- ------------------ http *:80: https *:443:

7.4 — Restart IIS

PS Administrator PowerShell
iisreset /restart

7.5 — Confirm IIS still serves correctly

PS Administrator PowerShell
curl.exe -k -L http://localhost -o NUL -w "%{http_code} %{url_effective}"
✓ Expected Output
200 https://localhost/
Why the Host Name field must be blank — and why "localhost" in the ingress rule is unrelated

The localhost:443 in the config.yml ingress rule is the TCP destination address cloudflared dials to reach IIS — purely a network routing instruction meaning "connect to port 443 on this machine." It has nothing to do with the HTTP Host header that travels inside that connection.

The IIS Host Name binding field filters the Host header inside the HTTP request, after the TCP connection is already open. cloudflared preserves the original visitor hostname — so after connecting to localhost:443 it sends Host: www.guydev.ca inside the request. If the IIS binding has localhost in the Host Name field, IIS compares www.guydev.ca against localhost, finds no match, and returns 400.

With Host Name blank, IIS skips the check and serves based on port number alone. Hostname filtering is already handled upstream by cloudflared's ingress rules.

Stop — Confirm Before Continuing

Both bindings show no hostname restriction. curl.exe returns 200 https://localhost/.

§ 08

Run the Tunnel — Foreground Test

8.1 — Start tunnel in foreground

PS Administrator PowerShell
cloudflared --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel run GuyStoreHome
✓ Expected Output — 4 connections registered with protocol=http2
INF Starting tunnel tunnelID=YOUR-TUNNEL-ID INF Initial protocol http2 INF Registered tunnel connection connIndex=0 ... protocol=http2 INF Registered tunnel connection connIndex=1 ... protocol=http2 INF Registered tunnel connection connIndex=2 ... protocol=http2 INF Registered tunnel connection connIndex=3 ... protocol=http2

All four connections must show protocol=http2 — not protocol=quic. If QUIC appears, the config.yml was not read correctly.

8.2 — End-to-end test from cellular device

Connect a second device to cellular data — not home Wi-Fi. Navigate to both URLs:

  • https://www.guydev.ca
  • https://guydev.ca

Wait at least 6 minutes idle between tests to confirm the HTTP/2 TCP connection survives Telus's NAT idle timeout (which kills QUIC/UDP connections after ~4–5 minutes).

8.3 — Stop the foreground tunnel

Press Ctrl+C in the terminal. Proceed to Section 9.

Stop — Confirm Before Continuing

Both URLs loaded from cellular. Terminal showed protocol=http2 on all 4 connections. No ERR lines appeared. Tunnel is now stopped (Ctrl+C pressed).

§ 09

Cloudflare Security Hardening

9.1 — Enable Cloudflare proxy (orange cloud)

Navigation: dash.cloudflare.com → guydev.ca → DNS → Records

Both www and @ records must show the orange cloud (Proxied). If either shows grey, click it to toggle to orange and click Save.

9.2 — Set SSL/TLS mode to Full (Strict)

Navigation: dash.cloudflare.com → guydev.ca → SSL/TLS → Overview

Select Full (strict). This enforces end-to-end encryption. Cloudflare validates the origin certificate presented by cloudflared, which is a valid Cloudflare-issued cert.

9.3 — Enable Always Use HTTPS

Navigation: dash.cloudflare.com → guydev.ca → SSL/TLS → Edge Certificates

Toggle Always Use HTTPS to On.

Stop — Confirm Before Continuing

Both DNS records show orange cloud. SSL/TLS mode is Full (strict). Always Use HTTPS is On.

§ 10

Install cloudflared as Windows Service

Critical — Use CMD for sc.exe commands PowerShell interprets (x86) as a command execution expression and fails. All sc.exe commands in this section must be run in Command Prompt as Administrator (cmd.exe), not PowerShell.

10.1 — Install the service with explicit config path

PS Administrator PowerShell
cloudflared --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" service install
✓ Expected Output
INF cloudflared agent service is installed windowsServiceName=Cloudflared INF Agent service for cloudflared installed successfully windowsServiceName=Cloudflared

10.2 — Set binary path with arguments via sc.exe

Open Command Prompt as Administrator (cmd.exe). Run the following — this injects the --config argument directly into the service registration so the SYSTEM account knows where to find the config:

CMD Administrator Command Prompt
sc.exe config Cloudflared binPath= "\"C:\Program Files (x86)\cloudflared\cloudflared.exe\" --config \"C:\Windows\System32\config\systemprofile\.cloudflared\config.yml\" tunnel run"
✓ Expected Output
[SC] ChangeServiceConfig SUCCESS

10.3 — Verify the binary path was set correctly

CMD Administrator Command Prompt
sc.exe qc Cloudflared
✓ Expected — BINARY_PATH_NAME must include --config
BINARY_PATH_NAME : "C:\Program Files (x86)\cloudflared\cloudflared.exe" --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel run

10.4 — Start the service

CMD Administrator Command Prompt
net start Cloudflared
✓ Expected Output
The Cloudflared agent service is starting. The Cloudflared agent service was started successfully.

10.5 — Verify service status

PS Administrator PowerShell
Get-Service -Name "Cloudflared" | Select-Object Name, Status, StartType
✓ Expected Output
Name Status StartType ---- ------ --------- Cloudflared Running Automatic

10.6 — Reboot test

PS Administrator PowerShell
Restart-Computer

After login, confirm the service came up automatically — do not run any tunnel command manually:

PS Administrator PowerShell (after reboot)
Get-Service -Name "Cloudflared" | Select-Object Name, Status
✓ Expected Output
Name Status ---- ------ Cloudflared Running
Stop — Confirm Before Continuing

After reboot: service is Running, no manual command was executed, and https://www.guydev.ca loads without intervention.

§ 11

Final Acceptance Test

All items must pass. Do not sign off until every box is checked.

  • https://www.guydev.ca loads from cellular — browser shows padlock, no security warnings, GuyStore app renders correctly
  • https://guydev.ca loads from cellular — same as above for apex domain
  • Cloudflare dashboard shows Healthyone.dash.cloudflare.com → Networks → Tunnels → GuyStoreHome shows Status: Healthy, Replicas: 4
  • Windows service is RunningGet-Service -Name "Cloudflared" returns Status: Running, StartType: Automatic
  • Post-reboot persistence confirmed — laptop was rebooted, service came up automatically, app loaded without any manual command
  • Connection survives 6-minute idle — app loads after 6+ minutes of no traffic (proves HTTP/2 TCP survives Telus NAT, unlike QUIC)
§ 12

Lessons Learned — Debug Log

Seven distinct issues were encountered and resolved during initial deployment. All fixes are incorporated into this runbook.

L-01
404 from Public URL
config.yml hostname www.guydev.ca didn't match DNS record guydev.ca — ingress catch-all fired instead of IIS rule
Aligned ingress hostnames to match DNS records exactly; created records for both www and apex
L-02
Tunnel Dropping Every ~4–5 Minutes
Telus CGNAT expires UDP NAT state after ~4–5 minutes of idle. QUIC runs over UDP — all 4 connections died simultaneously with timeout: no recent network activity
Added protocol: http2 to config.yml — forces TCP which CGNAT handles correctly with much longer idle timeouts
L-03
Apex Domain Not Resolving
No DNS record created for guydev.ca apex — only www existed
Ran cloudflared tunnel route dns GuyStoreHome guydev.ca; apex uses Cloudflare CNAME flattening, verified with -Type A -Server 1.1.1.1
L-04
Service Running with --token (Ignoring config.yml)
Service was installed in remote management mode via token — config.yml was completely ignored including protocol and ingress rules
Uninstalled token-based service; reinstalled as locally managed service reading config.yml
L-05
Service Starting with No Arguments
cloudflared service install registered only the bare executable path with no --config flag — cloudflared started with no identity and immediately exited cleanly
Used sc.exe config in CMD (not PowerShell) to manually inject the full binary path with --config argument
L-06
Service Crashing — SYSTEM Cannot Read User Profile
config.yml credentials-file path pointed to C:/Users/YOUR-USERNAME/ — the Windows SYSTEM account has no access to user profile directories
Copied all files to SYSTEM profile at C:\Windows\System32\config\systemprofile\.cloudflared\ and rewrote config.yml with SYSTEM profile paths
L-07
IIS Returning 404 Despite Working Tunnel
IIS GuyStore site had *:443:localhost binding — the :localhost host header restriction caused IIS to reject all requests from cloudflared carrying Host: www.guydev.ca
Cleared host name field on both HTTP and HTTPS bindings via Set-WebBinding, changing to *:80: and *:443:
§ 13

Quick Reference

Key File Locations

FilePath
config.yml (SYSTEM — authoritative)C:\Windows\System32\config\systemprofile\.cloudflared\config.yml
Tunnel credentials JSON (SYSTEM)C:\Windows\System32\config\systemprofile\.cloudflared\YOUR-TUNNEL-IDjson
cert.pem (SYSTEM)C:\Windows\System32\config\systemprofile\.cloudflared\cert.pem
config.yml (user profile — backup)C:\Users\YOUR-USERNAME\.cloudflared\config.yml
cloudflared binaryC:\Program Files (x86)\cloudflared\cloudflared.exe

Service Management

ActionCommandShell
Start servicenet start CloudflaredCMD (Admin)
Stop servicenet stop CloudflaredCMD (Admin)
Force kill if stucktaskkill /F /IM cloudflared.exePS (Admin)
Check statusGet-Service -Name "Cloudflared"PS (Admin)
View recent logsGet-EventLog -LogName Application -Source "cloudflared" -Newest 10PS (Admin)
Uninstall servicecloudflared service uninstallPS (Admin)
Delete stale servicesc.exe delete CloudflaredCMD (Admin)

Health Verification Commands

CheckCommand
IIS serving correctlycurl.exe -k -L http://localhost -o NUL -w "%{http_code} %{url_effective}"
IIS bindingsGet-WebBinding -Name "GuyStore" | Select-Object protocol, bindingInformation
www DNS recordResolve-DnsName www.guydev.ca -Type CNAME
apex DNS recordResolve-DnsName guydev.ca -Type A -Server 1.1.1.1
Validate config.ymlcloudflared --config "C:\Windows\...\config.yml" tunnel ingress validate
Service binary pathsc.exe qc Cloudflared (in CMD)
Flush local DNS cacheClear-DnsClientCache

Cloudflare Dashboard Navigation

TaskPath
DNS Recordsdash.cloudflare.com → guydev.ca → DNS → Records
SSL/TLS Modedash.cloudflare.com → guydev.ca → SSL/TLS → Overview
Always Use HTTPSdash.cloudflare.com → guydev.ca → SSL/TLS → Edge Certificates
Tunnel Statusone.dash.cloudflare.com → Networks → Tunnels → GuyStoreHome
§ 14

Add a Second IIS App to the Same Tunnel

The existing GuyStoreHome tunnel, Windows service, and four connections to Cloudflare's edge are all reused as-is. You only need to add a DNS record, update config.yml with a new ingress rule, and clear the IIS binding host header on the new site. No new tunnel, no new service, no new authentication.

Environment for this section New IIS site listens on HTTP port 8081 and HTTPS port 8082. IIS forces HTTP → HTTPS redirect on this site, same as GuyStore. Public URL will be https://app2.guydev.ca.

14.1 — Create the DNS record for the new subdomain

PS Administrator PowerShell
cloudflared tunnel route dns GuyStoreHome app2.guydev.ca
✓ Expected Output
INF Added CNAME app2.guydev.ca which will route to this tunnel tunnelID=YOUR-TUNNEL-ID

14.2 — Verify the DNS record exists

PS PowerShell
Resolve-DnsName app2.guydev.ca -Type CNAME | Select-Object Name, NameHost
✓ Expected Output
Name NameHost ---- -------- app2.guydev.ca YOUR-TUNNEL-ID.cfargotunnel.com

14.3 — Update config.yml with the new ingress rule

Add one ingress rule for the new hostname. Do not add two rules for the same hostname — cloudflared matches top-to-bottom and the second rule would never fire. Since IIS forces HTTPS on this site, point cloudflared at the HTTPS port with noTLSVerify: true.

Stop — Confirm before continuing

Each hostname must appear exactly once in the ingress block. If the same hostname appears twice, only the first rule ever matches. The second is dead code.

PS Administrator PowerShell
@" tunnel: YOUR-TUNNEL-ID credentials-file: C:/Windows/System32/config/systemprofile/.cloudflared/YOUR-TUNNEL-ID.json protocol: http2 ingress: - hostname: www.guydev.ca service: https://localhost:443 originRequest: noTLSVerify: true - hostname: guydev.ca service: https://localhost:443 originRequest: noTLSVerify: true - hostname: app2.guydev.ca service: https://localhost:8082 originRequest: noTLSVerify: true - service: http_status:404 "@ | Set-Content -Path "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" -Encoding UTF8

14.4 — Validate the updated config

PS Administrator PowerShell
cloudflared --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel ingress validate
✓ Expected Output
OK

14.5 — Confirm the new rule is matched correctly

PS Administrator PowerShell
cloudflared --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel ingress rule https://app2.guydev.ca
✓ Expected Output
Matched rule #3 hostname: app2.guydev.ca service: https://localhost:8082

If the matched rule shows http_status:404 instead, the hostname in config.yml does not match — recheck for typos and rerun Step 14.3.

14.6 — Clear the IIS binding host header on the new site

IIS rejects any request whose Host header does not match the binding's hostname restriction. cloudflared forwards Host: app2.guydev.ca — so the binding must have no hostname restriction. Replace "NewApp" with the actual IIS site name.

PS Administrator PowerShell
Import-Module WebAdministration # Clear hostname from HTTP binding Set-WebBinding -Name "NewApp" -BindingInformation "*:8081:localhost" -PropertyName "bindingInformation" -Value "*:8081:" # Clear hostname from HTTPS binding Set-WebBinding -Name "NewApp" -BindingInformation "*:8082:localhost" -PropertyName "bindingInformation" -Value "*:8082:"

Confirm both bindings now end with a bare colon — no hostname after it:

PS Administrator PowerShell
Get-WebBinding -Name "NewApp" | Select-Object protocol, bindingInformation
✓ Expected Output
protocol bindingInformation -------- ------------------ http *:8081: https *:8082:
Why the Host Name field must be blank — and why "localhost" in the ingress rule is unrelated

There are two separate uses of the word "localhost" in this setup that look similar but operate at completely different layers.

The localhost:8082 in the ingress rule is the TCP destination address cloudflared dials to reach IIS — it is purely a network routing instruction meaning "connect to port 8082 on this machine." It has nothing to do with the HTTP Host header that travels inside that connection.

The IIS Host Name binding field is a filter that checks the Host header inside the HTTP request, after the TCP connection is already established. cloudflared preserves the original visitor hostname — so after connecting to localhost:8082 it sends Host: app2.guydev.ca inside the request. IIS then compares that header against the binding filter.

The full sequence:

1. cloudflared reads ingress rule: "connect to localhost:8082" ← TCP destination only 2. cloudflared opens TCP connection to localhost:8082 (the connection itself carries no hostname) 3. cloudflared sends HTTP request over that connection: GET / HTTP/1.1 Host: app2.guydev.ca ← original visitor hostname preserved 4. IIS receives on port 8082 and checks Host Name binding filter: Host Name = "localhost" → app2.guydev.ca ≠ localhost → 400 ✕ Host Name = blank → skip check, serve it → 200 ✓

With Host Name blank, IIS skips the hostname check and serves based on port number alone. The hostname filtering is already handled upstream by cloudflared's ingress rules — IIS does not need to repeat it.

14.7 — Restart IIS

PS Administrator PowerShell
iisreset /restart

14.8 — Confirm IIS is serving on the new ports

PS Administrator PowerShell
netstat -ano | findstr ":8081 " curl.exe -k -L http://localhost:8081 -o NUL -w "%{http_code} %{url_effective}"
✓ Expected Output
TCP 0.0.0.0:8081 0.0.0.0:0 LISTENING 4 200 https://localhost:8082/

The 307 redirect from port 8081 to 8082 followed by 200 on 8082 is correct — it confirms IIS is alive on both ports and serving content. The self-signed cert warning is expected and suppressed by -k.

14.9 — Restart the cloudflared service

The service must be restarted to pick up the updated config.yml. Run the two commands separately — the & operator is not supported in CMD for this service.

CMD Administrator Command Prompt — Step 1
net stop Cloudflared
✓ Expected Output
The Cloudflared agent service was stopped successfully.
CMD Administrator Command Prompt — Step 2
net start Cloudflared
✓ Expected Output
The Cloudflared agent service was started successfully.

14.10 — Final verification

PS Administrator PowerShell
Get-Service -Name "Cloudflared" | Select-Object Name, Status
✓ Expected Output
Name Status ---- ------ Cloudflared Running

Then open a browser and navigate to:

Browser — do not include a port number in the URL
https://app2.guydev.ca
Common mistake — never include the port in the public URL

The public URL is always https://app2.guydev.ca — never https://app2.guydev.ca:8082. Adding a port number tells the browser to connect directly to your laptop on that port, bypassing Cloudflare entirely. CGNAT will block it. The port translation from 443 → 8082 happens invisibly inside the tunnel.

Routing summary — all apps on one tunnel

Public URLcloudflared routes toIIS Site
https://www.guydev.cahttps://localhost:443GuyStore
https://guydev.cahttps://localhost:443GuyStore
https://app2.guydev.cahttps://localhost:8082New App

To add further apps in future, repeat Steps 14.1 through 14.10 with a new subdomain and port. Each new app needs one DNS record, one ingress rule, and one IIS binding fix. The tunnel, service, and Cloudflare configuration require no other changes.

§ EX

Plain English Explainer

For anyone who wants to understand what this setup actually does — without reading a single command. Step through all 12 slides.

Non-technical overview
How the whole thing works
A gated community, a secret tunnel, and a shopfront on Main Street — step through 12 slides to understand every piece.
Appendix T — Troubleshooting Log

Every Issue Encountered
End-to-End Debug Record

All issues encountered during the initial deployment of this tunnel, in chronological order. Each entry records the symptom, root cause, why it happened, the exact fix applied, and how to verify the fix worked. Click any card to expand it.

T-01 Browser Returns 404 — Public URL Not Found
Symptom
Navigating to https://www.guydev.ca from an external device returns a Cloudflare-styled 404 page. The tunnel dashboard shows Healthy — meaning the tunnel itself is connected. The 404 comes through the tunnel, not from a connection failure.
Root Cause
The hostname value in config.yml ingress rules did not match the hostname Cloudflare was routing requests to. The ingress rules are evaluated top-to-bottom; if no rule matches the incoming Host header, the mandatory catch-all rule at the bottom fires and returns http_status:404. A single character difference — trailing dot, different subdomain, capitalisation — causes the rule to be skipped entirely.
Why It Happens
cloudflared matches ingress rules against the Host header of the incoming request, which is set by Cloudflare's edge to exactly the hostname the browser requested. If the DNS record is for guydev.ca but config.yml says www.guydev.ca, requests for guydev.ca will never match and will fall through to the 404 catch-all.
Diagnostic Commands

First confirm what DNS name the record resolves to:

PS PowerShell
Resolve-DnsName www.guydev.ca -Type CNAME | Select-Object Name, NameHost

Then confirm what hostname is in config.yml:

PS PowerShell
Get-Content "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml"

The Name field from Resolve-DnsName must be character-for-character identical to the hostname: value in config.yml.

Fix Applied

Added ingress rules for both hostnames with exact names matching the DNS records — www.guydev.ca and guydev.ca. Both rules route to https://localhost:443.

PS Administrator PowerShell
cloudflared --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel ingress validate
Verify Fix
ingress validate returns OK. After restarting the service, the public URL loads the app from cellular.
T-02 Tunnel Drops All 4 Connections Every ~4–5 Minutes
Symptom

All four tunnel connections drop simultaneously after exactly ~4–5 minutes of idle. Terminal shows a burst of ERR lines all at the same timestamp, followed by reconnect attempts:

Error Log Pattern
ERR failed to run the datagram handler error="timeout: no recent network activity" connIndex=0 ERR failed to run the datagram handler error="timeout: no recent network activity" connIndex=1 ERR failed to run the datagram handler error="timeout: no recent network activity" connIndex=2 ERR failed to run the datagram handler error="timeout: no recent network activity" connIndex=3 INF Retrying connection in up to 1s connIndex=0 INF Retrying connection in up to 1s connIndex=1

The dashboard shows the tunnel as Healthy moments before the drop, then Degraded/Down, then Healthy again after reconnect — a cyclic pattern every ~5 minutes.

Root Cause
Telus residential CGNAT maintains a state table for all active network flows. For UDP flows (which QUIC uses), Telus's CGNAT device has an idle timeout of approximately 4–5 minutes. When no UDP packets cross a flow within that window, the NAT mapping is silently deleted. cloudflared's keepalive packets never reach Cloudflare's edge because the NAT device has already discarded the mapping. Since all four connections share the same physical NAT device, they all die at the same moment.
Why It Happens
QUIC is a UDP-based protocol. cloudflared defaults to QUIC because it performs better than HTTP/2 in ideal conditions. However, residential CGNAT devices treat UDP as stateless and expire UDP flows aggressively — typically 30–120 seconds for unrecognised UDP, or 4–5 minutes for protocols they recognise as QUIC. TCP flows (which HTTP/2 uses) have much longer idle timeouts (typically 30+ minutes) and have explicit connection state that CGNAT devices track more reliably.
Fix Applied

Added protocol: http2 to config.yml. This forces cloudflared to use HTTP/2 over TCP instead of QUIC over UDP. Verified by watching startup logs — all four connections must show protocol=http2, not protocol=quic.

config.yml — Required Line
protocol: http2
PS Administrator PowerShell — verify at startup
# Watch for this in the tunnel startup output: # INF Registered tunnel connection connIndex=0 ... protocol=http2 # INF Registered tunnel connection connIndex=1 ... protocol=http2 # INF Registered tunnel connection connIndex=2 ... protocol=http2 # INF Registered tunnel connection connIndex=3 ... protocol=http2
Verify Fix
Run the tunnel in the foreground and wait 8+ minutes idle. No ERR lines appear. All four connections remain registered throughout. The tunnel dashboard stays Healthy continuously.
T-03 Cloudflare Error 1033 — Tunnel Cannot Be Resolved
Symptom
Browser shows a Cloudflare error page: "Error 1033 — Cloudflare Tunnel error. The host is configured as a Cloudflare Tunnel, and Cloudflare is currently unable to resolve it." The tunnel dashboard shows Down with 0 Replicas.
Root Cause
The cloudflared Windows service has stopped. Cloudflare's edge knows a tunnel is configured for this hostname (because the DNS CNAME record exists pointing to cfargotunnel.com), but there is no active cloudflared process maintaining a connection to the edge. Cloudflare cannot route the request anywhere and returns 1033.
Why It Happens
Common causes: service was stopped manually, the service crashed due to a config error and did not recover, the machine was rebooted before the service was configured as Automatic startup, or a config.yml change caused the service to exit cleanly without an error.
Fix Applied

Check service status first:

PS Administrator PowerShell
Get-Service -Name "Cloudflared" | Select-Object Name, Status, StartType

If Stopped, start it:

CMD Administrator Command Prompt
net start Cloudflared

If the service fails to start, check the event log for the crash reason:

PS Administrator PowerShell
Get-EventLog -LogName Application -Source "cloudflared" -Newest 10 | Select-Object TimeGenerated, Message | Format-List
Verify Fix
Dashboard refreshes to show Healthy with 4 Replicas within 15 seconds of the service starting. Public URL loads from browser.
T-04 DNS Not Resolving — Apex Domain Returns Blank or SOA
Symptom

Resolve-DnsName guydev.ca -Type A returns a blank IPAddress column, or Resolve-DnsName guydev.ca returns only a SOA record with no A or CNAME answer.

Resolve-DnsName guydev.ca -Type CNAME also returns blank — this is expected and normal for apex domains (see Why below).

Root Cause

Cause A — No DNS record exists: The apex route was never created. Only www.guydev.ca had a record; guydev.ca itself had none.

Cause B — Local/router DNS cache: The record was recently created but the Telus router is still serving a cached negative (NXDOMAIN) response from before the record existed. The record is live in Cloudflare but your resolver hasn't seen it yet.

Why It Happens
Apex domains cannot be CNAME records in standard DNS — RFC prohibits CNAME at the zone apex. Cloudflare implements CNAME flattening: internally it stores a CNAME, but it resolves and serves the result as an A record to external resolvers. This means -Type CNAME will always return blank for guydev.ca even when the record is correctly configured. Always use -Type A -Server 1.1.1.1 to verify apex records, querying Cloudflare's own resolver directly to bypass any local cache.
Fix Applied

Step 1 — Verify via Cloudflare resolver directly (bypasses all local cache):

PS PowerShell
Resolve-DnsName guydev.ca -Type A -Server 1.1.1.1 | Select-Object Name, IPAddress

If this returns Cloudflare IPs — the record exists and local cache is the issue. Flush and wait:

PS PowerShell
Clear-DnsClientCache

If the Telus router is still caching, wait 5–15 minutes for its TTL to expire. No further action needed — the record is correct.

If the record does not exist — create it:

PS Administrator PowerShell
cloudflared tunnel route dns GuyStoreHome guydev.ca
Verify Fix
Resolve-DnsName guydev.ca -Type A -Server 1.1.1.1 returns two Cloudflare proxy IPs (e.g. 104.21.x.x and 172.67.x.x). After router cache expires, plain Resolve-DnsName guydev.ca -Type A also returns Cloudflare IPs.
T-05 Service Fails to Start — CouldNotStartService Error
Symptom

Start-Service -Name "Cloudflared" or net start Cloudflared returns an error:

Error Output
Start-Service : Failed to start service 'Cloudflared agent (Cloudflared)'. FullyQualifiedErrorId : StartServiceFailed

Or from CMD:

Error Output
System error 1053 has occurred. The service did not respond to the start or control request in a timely fashion.
Root Cause

cloudflared starts, encounters a fatal error immediately, and exits before Windows considers it "started". Common causes on this system:

A. The credentials-file path in config.yml points to C:/Users/YOUR-USERNAME/ which the SYSTEM account cannot read — cloudflared exits with an access error.

B. The service binary path has no --config argument — cloudflared starts with no tunnel identity and exits cleanly.

C. A previous failed install left a corrupted service registration.

Diagnostic Steps

Step 1 — Check what arguments the service is actually using:

CMD Administrator Command Prompt
sc.exe qc Cloudflared

The BINARY_PATH_NAME must include --config and the SYSTEM profile path. If it shows only the bare executable, proceed to T-06.

Step 2 — Check the event log for the crash reason:

PS Administrator PowerShell
Get-EventLog -LogName Application -Source "cloudflared" -Newest 10 | Select-Object TimeGenerated, Message | Format-List
PS Administrator PowerShell
Get-EventLog -LogName System -Newest 10 | Where-Object {$_.Message -like "*Cloudflared*"} | Select-Object TimeGenerated, Message | Format-List

Step 3 — Confirm config.yml uses SYSTEM profile paths:

PS Administrator PowerShell
Get-Content "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml"

The credentials-file line must start with C:/Windows/System32/config/systemprofile/ — not C:/Users/YOUR-USERNAME/.

Verify Fix
net start Cloudflared returns "was started successfully". Get-Service -Name "Cloudflared" shows Status: Running.
T-06 Service Runs But Ignores config.yml — Token Mode or No Arguments
Symptom

The service appears to run but protocol: http2 and ingress rules have no effect. Event log shows the service was started with either:

A — Token mode (remote management, config.yml completely ignored):

Event Log Message
Cloudflared service arguments: [cloudflared.exe tunnel run --token eyJh...]

B — Bare executable (no arguments, cloudflared exits immediately):

Event Log Message
Cloudflared service arguments: [C:\Program Files (x86)\cloudflared\cloudflared.exe]
Root Cause

Token mode: The service was originally installed from the Cloudflare dashboard using a token — this is the remote management mode. In this mode cloudflared fetches its configuration from Cloudflare's API, completely ignoring any local config.yml. The protocol, ingress rules, and credentials-file in config.yml are all bypassed.

Bare executable: cloudflared service install did not persist the --config flag into the Windows service registration. cloudflared starts, finds no configuration, and exits cleanly with no error — which Windows interprets as a successful start followed by an unexpected stop.

Fix Applied

Step 1 — Uninstall the current service:

PS Administrator PowerShell
cloudflared service uninstall

Step 2 — Delete any stale entry (if uninstall reports already-uninstalled):

CMD Administrator Command Prompt
sc.exe delete Cloudflared

If sc.exe delete returns "marked for deletion" — reboot and retry from Step 1 after login.

Step 3 — Reinstall with explicit config path:

PS Administrator PowerShell
cloudflared --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" service install

Step 4 — Inject arguments via sc.exe in CMD (not PowerShell — PowerShell misparses the x86 path):

CMD Administrator Command Prompt — MUST use CMD not PowerShell
sc.exe config Cloudflared binPath= "\"C:\Program Files (x86)\cloudflared\cloudflared.exe\" --config \"C:\Windows\System32\config\systemprofile\.cloudflared\config.yml\" tunnel run"

Expected: [SC] ChangeServiceConfig SUCCESS

Step 5 — Verify the binary path was set:

CMD Administrator Command Prompt
sc.exe qc Cloudflared

BINARY_PATH_NAME must include --config and the full SYSTEM profile path.

Verify Fix
Event log arguments line shows --config C:\Windows\System32\config\systemprofile\.cloudflared\config.yml tunnel run. No --token flag. Service starts and stays running.
PS Administrator PowerShell
Get-EventLog -LogName Application -Source "cloudflared" -Newest 5 | Select-Object TimeGenerated, Message | Format-List
T-07 Service Crashes Instantly — SYSTEM Account Cannot Read User Profile
Symptom
Service starts, then immediately stops. Event log shows "terminated unexpectedly" repeated many times — Windows keeps restarting it and it keeps crashing. The arguments line shows the correct --config flag but the service still will not stay running.
Root Cause
The Windows service runs under the SYSTEM account (not your user account YOUR-USERNAME). The SYSTEM account's home directory is C:\Windows\System32\config\systemprofile — it has no access to C:\Users\YOUR-USERNAME\. If config.yml's credentials-file path points to the user profile, cloudflared cannot open the JSON file, cannot authenticate to Cloudflare, and exits with an access error. This happens even though you can read the file fine when running cloudflared manually in your own PowerShell session.
Why It Happens
All cloudflared setup steps (tunnel login, tunnel create) write files to the current user's home directory (%USERPROFILE%\.cloudflared\ = C:\Users\YOUR-USERNAME\.cloudflared\). When the Windows service then runs as SYSTEM, it looks for files in the SYSTEM profile instead. The service needs its own copy of all files in a location the SYSTEM account can read.
Fix Applied

Step 1 — Create the SYSTEM profile .cloudflared directory:

PS Administrator PowerShell
New-Item -ItemType Directory -Path "C:\Windows\System32\config\systemprofile\.cloudflared" -Force

Step 2 — Copy all cloudflared files into SYSTEM profile:

PS Administrator PowerShell
Copy-Item -Path "$env:USERPROFILE\.cloudflared\*" -Destination "C:\Windows\System32\config\systemprofile\.cloudflared\" -Recurse -Force

Step 3 — Verify all three files are present:

PS Administrator PowerShell
Get-ChildItem "C:\Windows\System32\config\systemprofile\.cloudflared\"

Must see: cert.pem, YOUR-TUNNEL-IDjson, config.yml

Step 4 — Rewrite config.yml in the SYSTEM profile with SYSTEM-profile paths:

PS Administrator PowerShell
# credentials-file must point to SYSTEM profile, not C:/Users/YOUR-USERNAME/ credentials-file: C:/Windows/System32/config/systemprofile/.cloudflared/YOUR-TUNNEL-ID.json
Verify Fix
Get-Content of the SYSTEM profile config.yml shows credentials-file starting with C:/Windows/System32/config/systemprofile/. Service starts and stays running. Event log no longer shows repeated "terminated unexpectedly".
T-08 sc.exe delete Returns "Marked for Deletion" — Stale Service Entry
Symptom
Error Output
[SC] DeleteService FAILED 1072: The specified service has been marked for deletion.

This occurs when trying to delete the Cloudflared service entry from the registry. Subsequent cloudflared service install fails with a conflict warning.

Root Cause
Windows has queued the service for deletion but cannot complete it because another process still holds an open handle to the service — typically the Services panel (services.msc), a PowerShell session that ran Get-Service, or the Service Control Manager itself. The deletion completes automatically on the next reboot when no process can hold a handle to a non-existent service.
Fix Applied

Close all Services panel windows and any PowerShell sessions that interacted with the service. Then reboot:

PS Administrator PowerShell
Restart-Computer

After login, confirm the service is gone before reinstalling:

PS Administrator PowerShell
Get-Service -Name "Cloudflared" -ErrorAction SilentlyContinue

Expected: no output. Then reinstall fresh.

Verify Fix
Get-Service -Name "Cloudflared" -ErrorAction SilentlyContinue returns nothing after reboot. cloudflared service install succeeds with no conflict warning.
T-09 Invoke-WebRequest SSL/TLS Error on localhost — Cannot Test IIS
Symptom

Running Invoke-WebRequest -Uri "http://localhost" returns an SSL error instead of a response:

Error Output
Invoke-WebRequest : The underlying connection was closed: Could not establish trust relationship for the SSL/TLS secure channel.

Adding -SkipCertificateCheck also fails:

Error Output
A parameter cannot be found that matches parameter name 'SkipCertificateCheck'.
Root Cause
IIS is configured to redirect all HTTP traffic to HTTPS. Invoke-WebRequest follows the redirect to https://localhost but then rejects the self-signed TLS certificate that IIS uses for localhost — because it is not issued by a trusted CA. The -SkipCertificateCheck parameter was only added in PowerShell 6+; it does not exist in PowerShell 5.1 which ships with Windows 10.
Fix Applied

Use curl.exe instead — it ships with Windows 10 and supports both redirect following (-L) and certificate skipping (-k) in PowerShell 5.1:

PS PowerShell 5.1 — use curl.exe not Invoke-WebRequest
curl.exe -k -L http://localhost -o NUL -w "%{http_code} %{url_effective}"

Flags: -k skips certificate verification, -L follows redirects, -o NUL discards the body, -w prints status code and final URL.

Verify Fix
Output shows 200 https://localhost/ — confirms IIS serves content correctly on HTTPS with redirect from HTTP.
T-10 IIS Returns 404 Despite Tunnel Working — Host Header Restriction
Symptom
The tunnel is Healthy. curl.exe -k -L http://localhost returns 200 — IIS serves the app correctly on localhost. But the public URL (https://www.guydev.ca) returns an IIS-styled HTTP 404, not a Cloudflare 404. The error comes from inside IIS, not from cloudflared's catch-all rule.
Root Cause
The IIS site binding has a hostname restriction set — e.g. *:443:localhost. When cloudflared forwards a request to https://localhost:443, it passes the original Host header from the browser: Host: www.guydev.ca. IIS compares this header against all its bindings. Since no binding matches www.guydev.ca, IIS returns 404. The binding *:443:localhost only matches requests where Host: localhost — which cloudflared never sends.
Why It Happens
IIS uses host headers to multiplex multiple sites on the same IP and port. When a hostname is set in the binding, IIS acts as a virtual host and only serves content for that exact hostname. This is correct behaviour for a machine hosting multiple sites. It is unexpected here because cloudflared's proxy-to-localhost pattern requires IIS to accept any host header — the hostname validation is already handled by cloudflared's ingress rules before the request reaches IIS.
Diagnostic & Fix

Step 1 — Check current bindings:

PS Administrator PowerShell
Import-Module WebAdministration Get-WebBinding -Name "GuyStore" | Select-Object protocol, bindingInformation

Any binding with a hostname after the final colon (e.g. *:443:localhost) must have that hostname cleared.

Step 2 — Clear hostname from HTTP binding:

PS Administrator PowerShell
Set-WebBinding -Name "GuyStore" -BindingInformation "*:80:localhost" -PropertyName "bindingInformation" -Value "*:80:"

Step 3 — Clear hostname from HTTPS binding:

PS Administrator PowerShell
Set-WebBinding -Name "GuyStore" -BindingInformation "*:443:localhost" -PropertyName "bindingInformation" -Value "*:443:"

Step 4 — Restart IIS and verify:

PS Administrator PowerShell
iisreset /restart Get-WebBinding -Name "GuyStore" | Select-Object protocol, bindingInformation

Both bindings must end with a bare colon — no hostname: *:80: and *:443:

Verify Fix
curl.exe -k -L http://localhost still returns 200. Public URL https://www.guydev.ca now loads the GuyStore application from a cellular device. No 404.