§ 00

Architecture Overview

Before writing a single command, understand how all the pieces connect. Your Windows machine runs two parallel web stacks sharing one Cloudflare Tunnel.

Internet │ ▼ Cloudflare Edge (guydev.ca zone) │ CNAME: guydev.ca → GuyStoreHome tunnel │ CNAME: www.guydev.ca → GuyStoreHome tunnel │ CNAME: www2.guydev.ca → GuyStoreHome tunnel ← NEW │ ▼ cloudflared Windows Service (your PC) │ ├─ Ingress rule: guydev.ca, www.guydev.ca ──► http://localhost:80 (IIS · GuyStore) │ └─ Ingress rule: www2.guydev.ca ──► http://localhost:5080 ← NEW │ ▼ WSL 2 Linux VM └─ Docker container └─ ASP.NET Core 9 MVC Port mapping: 5080:80
Why port 5080 on Windows?

IIS already owns Windows port 80. The Docker container internally listens on port 80 (standard for HTTP in a container), but Docker Desktop maps it to Windows port 5080. Cloudflare Tunnel then connects to localhost:5080. No IIS reconfiguration needed.

§ 01

Port Conflict Strategy

Your machine has exactly one conflict to avoid: IIS holds port 80 on all Windows network interfaces. Here is the full picture.

Port (Windows)OwnerPurposeStatus
80IIS (GuyStore)Serves guydev.ca / www.guydev.caKEEP — do not touch
5080Docker DesktopMaps to container port 80 (www2)NEW — will be created
443IIS (optional)HTTPS — cloudflared handles TLSIrrelevant for tunnel
Never use -p 80:80 in this environment

Mapping container port 80 to Windows port 80 will fail silently or crash the container because IIS holds that binding. Always use -p 5080:80 (Windows 5080 → container 80).

Pre-flight check — confirm port 5080 is free

Run this in PowerShell before continuing. If nothing is returned, the port is available.

PowerShell (Admin)
netstat -ano | findstr :5080

Expected output: blank (no output means nothing is using 5080).

§ 02

Install WSL 2

WSL 2 is the Linux kernel that Docker Desktop uses under the hood. Even though you interact with Docker Desktop on Windows, your containers run inside this Linux VM.

2.1 — Enable WSL 2

Open PowerShell as Administrator and run:

PowerShell (Admin)
wsl --install

This command enables the required Windows features, downloads the Linux kernel update, and installs Ubuntu as the default distro. Reboot when prompted.

Already have WSL?

Run wsl --status — if it reports Default Version: 2 you are already on WSL 2 and can skip the install. Just run wsl --update to ensure the kernel is current.

2.2 — Verify after reboot

PowerShell
wsl --status wsl --list --verbose

Expected: Default Version: 2 and your Ubuntu distro listed with state Running or Stopped.

Stop — Confirm Before Continuing

wsl --status reports Default Version: 2. Ubuntu distro is listed. Machine has been rebooted.

§ 03

Install Docker Desktop

Docker Desktop for Windows provides a GUI, CLI tools, and integrates with WSL 2 so containers run natively in Linux — not in a slow Hyper-V VM.

3.1 — Download and install

Download from https://www.docker.com/products/docker-desktop and run the installer. On the installation options screen, ensure "Use WSL 2 instead of Hyper-V" is checked (it is by default).

Reboot after installation.

3.2 — Configure WSL integration

Open Docker Desktop → Settings → Resources → WSL Integration. Enable integration for your Ubuntu distro (toggle it on). Click Apply & Restart.

3.3 — Verify Docker is working

PowerShell
docker --version docker run --rm hello-world

Expected: version string, then the "Hello from Docker!" message. Docker is confirmed working.

Stop — Confirm Before Continuing

docker run hello-world printed "Hello from Docker!" with no errors.

§ 04

Publish the GuyStore App

You need a self-contained published output of GuyStore to copy into the Docker image. "Self-contained" means the .NET runtime is bundled — the Docker image does not need .NET pre-installed separately.

4.1 — Choose your publish method

Recommended: Use the Dockerfile multi-stage build (§5) instead

Section 5 uses a multi-stage Dockerfile that compiles and publishes inside Docker itself — you do not need to manually publish from Visual Studio. This is the industry-standard approach. Skip §4.1 and go directly to §5 if you want to use that approach (recommended).

If you prefer to publish from Visual Studio first (simpler for first-timers):

4.2 — Publish from Visual Studio 2022

Right-click the GuyStore project → PublishFolder. Set the target folder to:

C:\Guy\Docker\guystore-publish

Click Publish. Visual Studio runs dotnet publish -c Release and drops all files into that folder.

4.3 — Confirm output

PowerShell
dir C:\Guy\Docker\guystore-publish\

Expected: you see GuyStore.dll, GuyStore.exe, web.config, wwwroot\ folder, and other DLLs.

§ 05

Write the Dockerfile

The template used here is the official Microsoft multi-stage ASP.NET Core 9 Dockerfile — the most widely used pattern for .NET web apps. It compiles in a SDK image, then copies only the output into a slim runtime image.

Why multi-stage?

The final image only contains the runtime (~220 MB), not the full SDK (~800 MB). It also means your Docker image always reflects a clean build — no leftover obj/ or bin/ folders.

5.1 — Create the project folder structure

In PowerShell, create a Docker build context folder:

PowerShell
New-Item -ItemType Directory -Force -Path "C:\Guy\Docker\www2"

5.2 — Copy your GuyStore source into the build context

The multi-stage build needs your source code. Copy the entire GuyStore solution into the Docker build folder. Adjust the source path to match your actual project location:

PowerShell
# Adjust the source path to your actual project location $src = "C:\Guy\Notes\Claud Projects\Guy_ResponsiveVersion\GuyStore" $dst = "C:\Guy\Docker\www2\GuyStore" Copy-Item -Recurse -Force $src $dst

5.3 — Create the Dockerfile

Create this file at C:\Guy\Docker\www2\Dockerfile

Open Notepad or VS Code, paste the content below, and save as Dockerfile (no extension):

Dockerfile
# ── Stage 1: Build ────────────────────────────────────── FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build WORKDIR /src # Copy the .csproj and restore packages (cached layer) COPY GuyStore/GuyStore.csproj GuyStore/ RUN dotnet restore GuyStore/GuyStore.csproj # Copy all source and publish in Release mode COPY GuyStore/ GuyStore/ RUN dotnet publish GuyStore/GuyStore.csproj \ -c Release \ -o /app/publish \ --no-restore # ── Stage 2: Runtime ──────────────────────────────────── FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime WORKDIR /app # Copy only the published output from Stage 1 COPY --from=build /app/publish . # ASP.NET Core listens on port 80 inside the container ENV ASPNETCORE_URLS=http://+:80 EXPOSE 80 # Start the app ENTRYPOINT ["dotnet", "GuyStore.dll"]
Adjust GuyStore.dll if your assembly name differs

The ENTRYPOINT uses GuyStore.dll. If your project's assembly name is different (check the .csproj <AssemblyName> tag), update this line. After a successful build you can also run docker run --rm www2 ls /app to see the exact filename.

5.4 — Create a .dockerignore file

Create C:\Guy\Docker\www2\.dockerignore to keep the build fast:

dockerignore
GuyStore/bin/ GuyStore/obj/ GuyStore/.vs/ GuyStore/*.user **/*.log
§ 06

Build & Run the Container

6.1 — Build the Docker image

Open PowerShell and navigate to the build context folder, then build:

PowerShell
cd C:\Guy\Docker\www2 docker build -t guystore-www2 .

First build takes 2–5 minutes (downloading base images). Subsequent builds are fast due to layer caching.

Expected final lines:

Expected Output
... Successfully built a1b2c3d4e5f6 Successfully tagged guystore-www2:latest

6.2 — Run the container

The critical line: port 5080 on Windows maps to port 80 inside the container. The --name flag makes it easy to stop/start later.

PowerShell
docker run -d ` --name www2 ` -p 5080:80 ` --restart unless-stopped ` guystore-www2
FlagMeaning
-dDetached mode — runs in background
--name www2Container name for easy management
-p 5080:80Windows port 5080 → container port 80
--restart unless-stoppedAuto-restarts on crash or Docker Desktop restart

6.3 — Check container status

PowerShell
docker ps

Expected: one row showing www2 with status Up N seconds and 0.0.0.0:5080->80/tcp.

Stop — Confirm Before Continuing

docker ps shows www2 with status Up. Port mapping shows 0.0.0.0:5080->80/tcp.

§ 07

Verify the App Locally

Before touching Cloudflare, confirm the container is serving your app correctly from Windows.

7.1 — Test via curl

PowerShell
curl.exe -s -o NUL -w "%{http_code}" http://localhost:5080/

Expected output: 200

7.2 — Test via browser

Open http://localhost:5080 in your browser. You should see the GuyStore MVC app loading correctly — same layout, same pages as the IIS-hosted version.

If you get a connection refused

Run docker logs www2 to see the app's startup output. Common issues: (a) the app binds to localhost only — fix by ensuring ASPNETCORE_URLS=http://+:80 is set in the Dockerfile (already included above); (b) the app crashes on startup — check for missing connection strings in appsettings.

7.3 — Check app logs

PowerShell
docker logs www2 --tail 30

Expected: ASP.NET Core startup messages including Now listening on: http://[::]:80 and Application started.

Stop — Confirm Before Continuing

Browser at http://localhost:5080 shows the GuyStore app. Curl returns 200. Docker logs show Application started.

§ 08

Add Tunnel Ingress Rule

The existing Cloudflare Tunnel (GuyStoreHome) needs one new ingress rule to route www2.guydev.ca to the Docker container at localhost:5080.

8.1 — Locate the config.yml file

The config.yml you set up previously is at:

C:\Windows\System32\config\systemprofile\.cloudflared\config.yml

Why this path?

The cloudflared Windows service runs as the SYSTEM account, so its config and credentials must live under the SYSTEM profile. This was established in the original runbook session.

8.2 — Open the file

You must use an elevated editor. Run this in PowerShell as Admin to open in Notepad:

PowerShell (Admin)
notepad "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml"

8.3 — Current config structure

Your existing config.yml looks something like this (hostnames may vary slightly):

config.yml — BEFORE (existing)
tunnel: YOUR-TUNNEL-ID credentials-file: C:\Windows\System32\config\systemprofile\.cloudflared\YOUR-TUNNEL-ID.json protocol: http2 ingress: - hostname: guydev.ca service: http://localhost:80 - hostname: www.guydev.ca service: http://localhost:80 - service: http_status:404

8.4 — Add the www2 rule

Insert the new rule before the catch-all service: http_status:404 line. The order matters — cloudflared matches top-to-bottom.

config.yml — AFTER (with www2 added)
tunnel: YOUR-TUNNEL-ID credentials-file: C:\Windows\System32\config\systemprofile\.cloudflared\YOUR-TUNNEL-ID.json protocol: http2 ingress: - hostname: guydev.ca service: http://localhost:80 - hostname: www.guydev.ca service: http://localhost:80 - hostname: www2.guydev.ca service: http://localhost:5080 - service: http_status:404

Save the file (Ctrl+S) and close Notepad.

8.5 — Validate the config

Run in CMD as Administrator (not PowerShell — the (x86) path trips up PowerShell):

CMD (Admin)
"C:\Program Files (x86)\cloudflared\cloudflared.exe" --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel ingress validate

Expected output: Validating rules from C:\...\config.yml followed by each hostname listed and OK. No errors.

Stop — Confirm Before Continuing

ingress validate shows all three hostnames (guydev.ca, www.guydev.ca, www2.guydev.ca) listed as OK. No validation errors.

§ 09

Add DNS Record in Cloudflare

The DNS CNAME record tells Cloudflare's edge where to send traffic for www2.guydev.ca. It must point to the tunnel, not to your IP.

9.1 — Navigate to DNS Records

dash.cloudflare.com → guydev.ca → DNSRecords

9.2 — Add the record

Click Add record and fill in exactly:

FieldValue
TypeCNAME
Namewww2
TargetYOUR-TUNNEL-ID.cfargotunnel.com
Proxy statusProxied (orange cloud ON)
TTLAuto
How to find YOUR-TUNNEL-ID

It is in your config.yml at the top — the tunnel: value. You can also find it at one.dash.cloudflare.com → Networks → Tunnels → GuyStoreHome — copy the UUID from the tunnel detail page.

Click Save.

Stop — Confirm Before Continuing

DNS Records list now shows a www2 CNAME row with orange cloud (Proxied). Target ends in .cfargotunnel.com.

§ 10

Restart Cloudflared & Verify

The cloudflared Windows service must be restarted to pick up the new ingress rule in config.yml.

10.1 — Restart the service

Run in CMD as Administrator:

CMD (Admin)
sc stop Cloudflared sc start Cloudflared

10.2 — Confirm service is running

PowerShell
Get-Service Cloudflared

Expected: Status: Running

10.3 — Check tunnel status in dashboard

one.dash.cloudflare.com → Networks → Tunnels → GuyStoreHome

Status must show Healthy (green dot). If it shows Degraded or Inactive, wait 30 seconds and refresh.

10.4 — Test www2 from PowerShell

PowerShell
curl.exe -s -o NUL -w "%{http_code} %{url_effective}" https://www2.guydev.ca/

Expected: 200 https://www2.guydev.ca/

10.5 — Confirm original site still works

PowerShell
curl.exe -s -o NUL -w "%{http_code}" https://guydev.ca/

Expected: 200 — confirming the IIS-hosted site is unaffected.

Stop — Confirm Before Continuing

Cloudflare dashboard shows GuyStoreHome tunnel as Healthy. https://www2.guydev.ca returns 200. https://guydev.ca still returns 200. The original site is unaffected.

§ 11

Auto-start on Boot

Two things must start automatically when your PC reboots: Docker Desktop (which also starts the container) and cloudflared (already a Windows service). This section verifies both.

11.1 — Docker Desktop auto-start

Open Docker Desktop → Settings → General. Confirm "Start Docker Desktop when you log in" is checked. Click Apply.

Container restarts automatically

The --restart unless-stopped flag set in §6.2 means Docker will restart the www2 container every time Docker Desktop starts up. No additional configuration needed.

11.2 — Verify cloudflared auto-start

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

Expected: StartType: Automatic. If it shows Manual, run:

CMD (Admin)
sc config Cloudflared start= auto

11.3 — Boot order consideration

Docker Desktop starts after Windows login (it needs the WSL 2 subsystem to initialize). There is a ~30–60 second window after login before the container is ready. The cloudflared service starts earlier. This means www2.guydev.ca may return a brief 502 immediately after a reboot — this is normal and self-resolves once Docker is up.

§ 12

Acceptance Test Checklist

Click each item to mark it complete. All must pass before this runbook is considered done.

§ 13

Troubleshooting

All issues below were encountered during the actual setup of this runbook and are documented with their exact symptoms, root causes, and fixes.

Issue 1 — wsl --install reports "A distribution with the supplied name already exists"

Symptom

Running wsl --install returns: A distribution with the supplied name already exists. Use --name to choose a different name.

Root cause: WSL was already installed on the machine from a previous setup. The install command is not needed.

Fix: This is not an error — WSL is already present. Run the following to confirm WSL 2 is active and update the kernel:

PowerShell
wsl --status wsl --list --verbose wsl --update

Confirm Default Version: 2 is shown. If it shows Version 1, run wsl --set-default-version 2. Then proceed to §3 Docker Desktop.

Issue 2 — curl returns 000 at §7.1 (container not yet started)

Symptom

curl.exe -s -o NUL -w "%{http_code}" http://localhost:5080/ returns 000.

Root cause: 000 is not an HTTP response code — it means curl could not establish a TCP connection at all. Nothing is listening on port 5080 yet because docker run in §6.2 has not been executed.

Fix: Complete §6 in order before running §7.1. Build the image first, then run the container:

PowerShell
cd C:\Guy\Notes\Docker\www2 docker build -t guystore-www2 . docker run -d --name www2 -p 5080:80 --restart unless-stopped guystore-www2 docker ps

Once docker ps shows www2 with status Up and 0.0.0.0:5080->80/tcp, retry the curl.

Issue 3 — curl still returns 000 after container is Up (SQL Server unreachable)

Symptom

docker ps shows the container as Up with correct port mapping, but curl still returns 000. docker logs www2 shows: Microsoft.Data.SqlClient.SqlException — A network-related or instance-specific error occurred while establishing a connection to SQL Server. Error 35 — An internal exception was caught.

Root cause: The connection string in appsettings.json uses localhost or . as the SQL Server name. Inside a Docker container, localhost refers to the container itself — not the Windows host where SQL Server lives. The app crashes on startup before it can serve any HTTP requests, so curl gets a connection refused (000).

Fix: Pass the corrected connection string as an environment variable using -e, replacing the server name with host.docker.internal. Docker Desktop maps this special hostname to the Windows host IP automatically.

PowerShell
docker stop www2 docker rm www2 docker run -d ` --name www2 ` -p 5080:80 ` --restart unless-stopped ` -e "ConnectionStrings__DefaultConnection=Server=host.docker.internal;Database=GuyStoreDb;Trusted_Connection=False;User Id=sa;Password=YOUR-PASSWORD;TrustServerCertificate=True;" ` guystore-www2

Replace GuyStoreDb, sa, and YOUR-PASSWORD with the values from your appsettings.json.

Issue 4 — SQL Server TCP/IP not enabled (Error 40)

Symptom

docker logs www2 shows: SqlException — Could not open a connection to SQL Server. Provider: TCP Provider, Error 40. Note: Error 40 is different from Error 35 in Issue 3 — Error 40 means TCP reached the host but SQL Server refused the connection.

Root cause: SQL Server on Windows defaults to named pipes only. Remote TCP connections (which is what Docker uses via host.docker.internal) are disabled by default and must be explicitly enabled.

Fix — three steps:

Step 1: Open SQL Server Configuration Manager → SQL Server Network Configuration → Protocols for MSSQLSERVER → right-click TCP/IPEnable → OK.

Step 2: Restart the SQL Server service:

PowerShell (Admin)
# Default instance Restart-Service -Name MSSQLSERVER -Force # Named instance (e.g. SQLEXPRESS) — use this instead if applicable Restart-Service -Name "MSSQL`$SQLEXPRESS" -Force

Step 3: Allow SQL Server port through Windows Firewall:

PowerShell (Admin)
New-NetFirewallRule -DisplayName "SQL Server 1433" ` -Direction Inbound ` -Protocol TCP ` -LocalPort 1433 ` -Action Allow

Then stop, remove, and rerun the container as shown in Issue 3.

Issue 5 — SSL certificate rejected by container (Error 35 after TCP enabled)

Symptom

docker logs www2 shows: A connection was successfully established with the server, but then an error occurred during the pre-login handshake. Provider: TCP Provider, Error 35 — An internal exception was caught. System.Security.Authentication.AuthenticationException: The remote certificate was rejected by the provided RemoteCertificateValidationCallback.

Root cause: After TCP/IP is enabled, the .NET SQL client attempts to validate SQL Server's SSL certificate. Local SQL Server installations use a self-signed certificate which the container's .NET runtime rejects by default. This is the modern Microsoft.Data.SqlClient behaviour — it enforces encryption and certificate validation strictly.

Fix: Add TrustServerCertificate=True to the connection string. This tells the SQL client to skip certificate validation, which is correct and safe for a local development database accessed from a container.

PowerShell
docker stop www2 docker rm www2 docker run -d ` --name www2 ` -p 5080:80 ` --restart unless-stopped ` -e "ConnectionStrings__DefaultConnection=Server=host.docker.internal;Database=GuyStoreDb;Trusted_Connection=False;User Id=sa;Password=YOUR-PASSWORD;TrustServerCertificate=True;" ` guystore-www2

This is the complete working connection string pattern — host.docker.internal as server name plus TrustServerCertificate=True are both required together.

Issue 6 — ingress validate fails in PowerShell ("Unexpected token --config")

Symptom

Running the cloudflared ingress validate command in PowerShell returns: Unexpected token 'config' in expression or statement. The '--' operator works only on variables or on properties.

Root cause: PowerShell interprets -- as its own operator (end of parameters marker) and also interprets (x86) in the path as a command execution expression. This was the same issue encountered in the original Cloudflare Tunnel runbook session.

Fix: Always run cloudflared commands in CMD (Command Prompt), not PowerShell. Open CMD as Administrator via Win+R → type cmd → press Ctrl+Shift+Enter. Then run:

CMD (Admin) — NOT PowerShell
"C:\Program Files (x86)\cloudflared\cloudflared.exe" --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel ingress validate

Expected output: all three hostnames (guydev.ca, www.guydev.ca, www2.guydev.ca) listed with OK.

General — Useful daily management commands

PowerShell
# View live logs (follow mode) docker logs www2 -f # View last 30 lines of logs docker logs www2 --tail 30 # Stop container docker stop www2 # Start container docker start www2 # Check all containers including stopped ones docker ps -a # Rebuild after code changes docker stop www2 docker rm www2 cd C:\Guy\Notes\Docker\www2 docker build -t guystore-www2 . docker run -d --name www2 -p 5080:80 --restart unless-stopped ` -e "ConnectionStrings__DefaultConnection=Server=host.docker.internal;Database=GuyStoreDb;Trusted_Connection=False;User Id=sa;Password=YOUR-PASSWORD;TrustServerCertificate=True;" ` guystore-www2 # Shell into running container (for debugging) docker exec -it www2 /bin/bash

General — www2.guydev.ca returns 502 Bad Gateway

Cloudflare reached cloudflared but cloudflared could not reach the container. Diagnose in this order:

PowerShell
# 1. Is the container running? docker ps # 2. Is port 5080 listening on Windows? netstat -ano | findstr :5080 # 3. Can Windows reach the container directly? curl.exe -s -o NUL -w "%{http_code}" http://localhost:5080/ # 4. Check app startup errors docker logs www2 --tail 30

General — www2.guydev.ca returns 404

Cloudflare reached cloudflared but no ingress rule matched the hostname. Verify the config.yml edit was saved and the service restarted. Run in CMD as Admin:

CMD (Admin)
"C:\Program Files (x86)\cloudflared\cloudflared.exe" --config "C:\Windows\System32\config\systemprofile\.cloudflared\config.yml" tunnel ingress validate