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.
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.cashows Active under Websites → guydev.ca → Overview - Cloudflare Zero Trust initialized —
one.dash.cloudflare.comis accessible - Cloudflare nameservers are authoritative for the domain
Software
- Windows 10 — confirmed via
winver - IIS installed — confirmed in Windows Features
- PowerShell 5.1+ —
$PSVersionTable.PSVersionMajor ≥ 5 wingetavailable —winget --versionreturns a version stringcurl.exeavailable —curl.exe --versionreturns a version string
Variable Registry — Fill in Before Continuing
| Variable | Your Value |
|---|---|
| TUNNEL_NAME | GuyStoreHome |
| TUNNEL_ID | YOUR-TUNNEL-ID |
| DOMAIN | guydev.ca |
| PUBLIC_URL_WWW | https://www.guydev.ca |
| PUBLIC_URL_APEX | https://guydev.ca |
| IIS_SITE_NAME | GuyStore |
| WINDOWS_USERNAME | YOUR-USERNAME |
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).
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
PID 4 is Windows HTTP.sys (IIS). Any other PID means a conflicting process — stop it before continuing.
curl.exe returned 200 https://localhost/ and port 443 is owned by PID 4.
Install cloudflared
2.1 — Install via winget
2.2 — Reload PATH
2.3 — Verify installation
cloudflared --version returns a version string without error.
Authenticate cloudflared
3.1 — Run the login command
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
Test-Path returns True. cert.pem is present.
Create the Tunnel
4.1 — Create a named tunnel
4.2 — Confirm credentials JSON exists
TUNNEL_ID recorded. Credentials JSON file confirmed present.
Write config.yml
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
5.2 — Copy all cloudflared files to SYSTEM profile
5.3 — Write config.yml to SYSTEM profile
noTLSVerify: true is required to prevent cloudflared from rejecting it. This is safe — the self-signed cert is only used on the loopback interface.
5.4 — Validate config
ingress validate returns OK. credentials-file path uses the SYSTEM profile, not the user profile.
Configure Cloudflare DNS
6.1 — Create www CNAME record
6.2 — Create apex DNS record
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
6.4 — Verify apex resolves (query Cloudflare resolver directly)
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):
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.
Fix IIS Binding Host Header
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
7.2 — Clear host header on HTTPS binding
7.3 — Verify bindings are clear
7.4 — Restart IIS
7.5 — Confirm IIS still serves correctly
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.
Both bindings show no hostname restriction. curl.exe returns 200 https://localhost/.
Run the Tunnel — Foreground Test
8.1 — Start tunnel in foreground
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.
Both URLs loaded from cellular. Terminal showed protocol=http2 on all 4 connections. No ERR lines appeared. Tunnel is now stopped (Ctrl+C pressed).
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.
Both DNS records show orange cloud. SSL/TLS mode is Full (strict). Always Use HTTPS is On.
Install cloudflared as Windows Service
(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
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:
10.3 — Verify the binary path was set correctly
10.4 — Start the service
10.5 — Verify service status
10.6 — Reboot test
After login, confirm the service came up automatically — do not run any tunnel command manually:
After reboot: service is Running, no manual command was executed, and https://www.guydev.ca loads without intervention.
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 Healthy —
one.dash.cloudflare.com → Networks → Tunnels → GuyStoreHomeshows Status: Healthy, Replicas: 4 - Windows service is Running —
Get-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)
Lessons Learned — Debug Log
Seven distinct issues were encountered and resolved during initial deployment. All fixes are incorporated into this runbook.
www.guydev.ca didn't match DNS record guydev.ca — ingress catch-all fired instead of IIS ruletimeout: no recent network activityprotocol: http2 to config.yml — forces TCP which CGNAT handles correctly with much longer idle timeoutsguydev.ca apex — only www existedcloudflared tunnel route dns GuyStoreHome guydev.ca; apex uses Cloudflare CNAME flattening, verified with -Type A -Server 1.1.1.1cloudflared service install registered only the bare executable path with no --config flag — cloudflared started with no identity and immediately exited cleanlysc.exe config in CMD (not PowerShell) to manually inject the full binary path with --config argumentcredentials-file path pointed to C:/Users/YOUR-USERNAME/ — the Windows SYSTEM account has no access to user profile directoriesC:\Windows\System32\config\systemprofile\.cloudflared\ and rewrote config.yml with SYSTEM profile paths*:443:localhost binding — the :localhost host header restriction caused IIS to reject all requests from cloudflared carrying Host: www.guydev.caSet-WebBinding, changing to *:80: and *:443:Quick Reference
Key File Locations
| File | Path |
|---|---|
| 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 binary | C:\Program Files (x86)\cloudflared\cloudflared.exe |
Service Management
| Action | Command | Shell |
|---|---|---|
| Start service | net start Cloudflared | CMD (Admin) |
| Stop service | net stop Cloudflared | CMD (Admin) |
| Force kill if stuck | taskkill /F /IM cloudflared.exe | PS (Admin) |
| Check status | Get-Service -Name "Cloudflared" | PS (Admin) |
| View recent logs | Get-EventLog -LogName Application -Source "cloudflared" -Newest 10 | PS (Admin) |
| Uninstall service | cloudflared service uninstall | PS (Admin) |
| Delete stale service | sc.exe delete Cloudflared | CMD (Admin) |
Health Verification Commands
| Check | Command |
|---|---|
| IIS serving correctly | curl.exe -k -L http://localhost -o NUL -w "%{http_code} %{url_effective}" |
| IIS bindings | Get-WebBinding -Name "GuyStore" | Select-Object protocol, bindingInformation |
| www DNS record | Resolve-DnsName www.guydev.ca -Type CNAME |
| apex DNS record | Resolve-DnsName guydev.ca -Type A -Server 1.1.1.1 |
| Validate config.yml | cloudflared --config "C:\Windows\...\config.yml" tunnel ingress validate |
| Service binary path | sc.exe qc Cloudflared (in CMD) |
| Flush local DNS cache | Clear-DnsClientCache |
Cloudflare Dashboard Navigation
| Task | Path |
|---|---|
| DNS Records | dash.cloudflare.com → guydev.ca → DNS → Records |
| SSL/TLS Mode | dash.cloudflare.com → guydev.ca → SSL/TLS → Overview |
| Always Use HTTPS | dash.cloudflare.com → guydev.ca → SSL/TLS → Edge Certificates |
| Tunnel Status | one.dash.cloudflare.com → Networks → Tunnels → GuyStoreHome |
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.
https://app2.guydev.ca.
14.1 — Create the DNS record for the new subdomain
14.2 — Verify the DNS record exists
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.
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.
14.4 — Validate the updated config
14.5 — Confirm the new rule is matched correctly
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.
Confirm both bindings now end with a bare colon — no hostname after it:
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:
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
14.8 — Confirm IIS is serving on the new ports
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.
14.10 — Final verification
Then open a browser and navigate to:
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 URL | cloudflared routes to | IIS Site |
|---|---|---|
https://www.guydev.ca | https://localhost:443 | GuyStore |
https://guydev.ca | https://localhost:443 | GuyStore |
https://app2.guydev.ca | https://localhost:8082 | New 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.
Plain English Explainer
For anyone who wants to understand what this setup actually does — without reading a single command. Step through all 12 slides.
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.
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.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.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.First confirm what DNS name the record resolves to:
Then confirm what hostname is in config.yml:
The Name field from Resolve-DnsName must be character-for-character identical to the hostname: value in config.yml.
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.
ingress validate returns OK. After restarting the service, the public URL loads the app from cellular.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:
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.
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.
cfargotunnel.com), but there is no active cloudflared process maintaining a connection to the edge. Cloudflare cannot route the request anywhere and returns 1033.Check service status first:
If Stopped, start it:
If the service fails to start, check the event log for the crash reason:
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).
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.
-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.Step 1 — Verify via Cloudflare resolver directly (bypasses all local cache):
If this returns Cloudflare IPs — the record exists and local cache is the issue. Flush and wait:
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:
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.Start-Service -Name "Cloudflared" or net start Cloudflared returns an error:
Or from CMD:
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.
Step 1 — Check what arguments the service is actually using:
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:
Step 3 — Confirm config.yml uses SYSTEM profile paths:
The credentials-file line must start with C:/Windows/System32/config/systemprofile/ — not C:/Users/YOUR-USERNAME/.
net start Cloudflared returns "was started successfully". Get-Service -Name "Cloudflared" shows Status: Running.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):
B — Bare executable (no arguments, cloudflared exits immediately):
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.
Step 1 — Uninstall the current service:
Step 2 — Delete any stale entry (if uninstall reports already-uninstalled):
If sc.exe delete returns "marked for deletion" — reboot and retry from Step 1 after login.
Step 3 — Reinstall with explicit config path:
Step 4 — Inject arguments via sc.exe in CMD (not PowerShell — PowerShell misparses the x86 path):
Expected: [SC] ChangeServiceConfig SUCCESS
Step 5 — Verify the binary path was set:
BINARY_PATH_NAME must include --config and the full SYSTEM profile path.
--config C:\Windows\System32\config\systemprofile\.cloudflared\config.yml tunnel run. No --token flag. Service starts and stays running.
--config flag but the service still will not stay running.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.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.Step 1 — Create the SYSTEM profile .cloudflared directory:
Step 2 — Copy all cloudflared files into SYSTEM profile:
Step 3 — Verify all three files are present:
Must see: cert.pem, YOUR-TUNNEL-IDjson, config.yml
Step 4 — Rewrite config.yml in the SYSTEM profile with SYSTEM-profile paths:
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".This occurs when trying to delete the Cloudflared service entry from the registry. Subsequent cloudflared service install fails with a conflict warning.
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.Close all Services panel windows and any PowerShell sessions that interacted with the service. Then reboot:
After login, confirm the service is gone before reinstalling:
Expected: no output. Then reinstall fresh.
Get-Service -Name "Cloudflared" -ErrorAction SilentlyContinue returns nothing after reboot. cloudflared service install succeeds with no conflict warning.Running Invoke-WebRequest -Uri "http://localhost" returns an SSL error instead of a response:
Adding -SkipCertificateCheck also fails:
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.Use curl.exe instead — it ships with Windows 10 and supports both redirect following (-L) and certificate skipping (-k) in PowerShell 5.1:
Flags: -k skips certificate verification, -L follows redirects, -o NUL discards the body, -w prints status code and final URL.
200 https://localhost/ — confirms IIS serves content correctly on HTTPS with redirect from HTTP.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.*: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.Step 1 — Check current bindings:
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:
Step 3 — Clear hostname from HTTPS binding:
Step 4 — Restart IIS and verify:
Both bindings must end with a bare colon — no hostname: *:80: and *:443:
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.