Skip to content

IIS Worktree Branch Previews

Run a feature branch's portal + admin SPA + API + backend + login in local IIS side-by-side with the canonical local environment and with other branches, each under its own branch-specific hostnames, so engineers, product, and support can review a branch — including the full sign-in round-trip — without anyone having to swap their local IIS mapping.

Two scripts implement this:

  • scripts/dev/create-branch-preview.ps1 — provision a preview
  • scripts/dev/remove-branch-preview.ps1 — tear it down

Quick start

Run from an elevated PowerShell (IIS and the hosts file require admin):

# Stand up a preview for a branch (worktree is created automatically).
# -Org is the organisation the portal serves (default: gercek).
scripts\dev\create-branch-preview.ps1 -Ticket rak349 -Branch rakletai/rak-349-apply-form-validation-error-leak -Org gercek

# ... review at the URLs printed below ...

# Tear it down (keeps the worktree by default)
scripts\dev\remove-branch-preview.ps1 -Ticket rak349

# Tear down AND remove the worktree it created
scripts\dev\remove-branch-preview.ps1 -Ticket rak349 -RemoveWorktree

create-branch-preview.ps1 prints the preview URLs for ticket rak349:

Role URL Served from
Portal https://rak349-gercek.raklet.org Application (serves the -Org org; label is the org permalink)
Admin https://rak349-admin.raklet.org Raklet.Admin
API https://rak349-api.raklet.org Raklet.Api
Backend (admin assets) https://rak349-backend.raklet.org Raklet.Backend
Login https://rak349-login.raklet.org Raklet.Login

-Ticket is a short DNS-safe token (lowercase letters, digits, dashes). It names both the hostnames and the preview-<ticket>-* IIS sites/app pools.

What the create script does

  1. Reuses the branch's existing git worktree if it already has one; otherwise creates .claude/worktrees/<ticket> for the branch.
  2. Junctions packages/ to the main checkout (so transitive NuGet HintPaths resolve — packages/ is git-ignored and does not copy into new worktrees).
  3. Seeds the git-ignored *.local.config files (DB connection string + storage credentials) from the main checkout. Every preview shares the one local database, which is intended.
  4. Rewrites the admin/api/backend/login host references inside each worktree's web.config (and any appSettings.local.config) to the branch hosts. This covers the *-Path appSettings used for server-side redirects and server-rendered links and the credentialed CORS allow-lists (v2:origins / internal:origins). Rewriting login (incl. the login site's own Raklet-Admin-Path) is what keeps the whole sign-in round-trip on the preview. The portal hosts (www/v3), crm, CookieDomain, and name@raklet.org emails are left canonical — CookieDomain must stay raklet.org so the auth cookie is shared across the preview hosts.
  5. Sets Preview-Organisation-Permalink in the worktree's Application/web.config to -Org so the portal serves that organisation (see "How the portal preview works" below).
  6. Builds Application, Raklet.Api, Raklet.Admin, Raklet.Backend and rebuilds the admin assets with Grunt (build-local). Pass -SkipBuild to provision against an already-built worktree.
  7. Creates one app pool + one site per role, with http + https bindings on the branch hostnames, and assigns each site its pool.
  8. Adds 127.0.0.1 hosts-file entries for the five hostnames.
  9. Warms each site over HTTPS and prints the URLs. backend is warmed first because it is the only app that applies EF migrations (every other app sets Database.SetInitializer<RakletDb>(null)); if it can't start/migrate the script aborts rather than warm the schema-dependent hosts onto an un-migrated DB. See Database migrations and warmup order.

The script is idempotent: re-running it repoints paths, re-applies the host rewrite (the match is anchored so it never double-prefixes), tops up hosts entries, and leaves existing IIS objects in place. It only ever touches preview-<ticket>-* objects — never the canonical v3/admin/api/backend/ login sites.

Why it works (design rationale)

The local app is six IIS sites sharing one *.raklet.org wildcard certificate and one local database. A few properties make branch hostnames cheap and correct:

  • Single-label wildcard cert. CN=*.raklet.org covers exactly one label, so a one-label host like rak349-admin.raklet.org is valid, while a two-label host like admin.rak349.raklet.org is not. That is why the branch token is a prefix on the service label (<branch>-admin), not a parent domain.
  • Non-SNI shared cert binding. The existing HTTPS bindings share the 0.0.0.0:443 certificate, so a new *:443:<host> binding inherits the wildcard cert automatically — no netsh, no per-host certificate work.
  • Local wildcard DNS. The dev VM resolves *.raklet.org to 127.0.0.1 (via Acrylic DNS Proxy), so branch hostnames resolve without DNS-provider changes. The hosts-file entries the script adds are a portable fallback for machines without the wildcard resolver.
  • Host-derived roots. The admin SPA computes ApiRoot/AdminRoot/ BackendRoot/LoginRoot from the hostname that served the page (Raklet.Backend/Content/scripts/core/app.module.js, local branch). The same admin bundle served from rak349-admin.raklet.org therefore calls rak349-api.raklet.org and signs in via rak349-login.raklet.org; the canonical admin.raklet.org still uses api.raklet.org/login.raklet.org. Build once, works for every branch host. PathService.getLoginUrl reads the derived LoginRoot.
  • Server-side host rewrite. The MVC admin shell redirects and the server-rendered links read host values from web.config appSettings, which are rewritten per worktree (step 4) so they stay on the preview host instead of bouncing to the canonical sites.

Login round-trip. The admin SPA sends an unauthenticated user to the host-derived rak349-login.raklet.org. That login site is the Raklet.Login project with its own rewritten web.config, so after authentication its PathService.GetAdminPathNG() (reading the rewritten Raklet-Admin-Path) redirects back to rak349-admin.raklet.org — the visitor never leaves the preview. The auth cookie is scoped to raklet.org (CookieDomain, left canonical), so the session is shared across every *.raklet.org preview host and the canonical local env. (Social/OAuth sign-in still uses provider redirect URIs registered for login.raklet.org, so use email/password on a branch login host.)

How the portal preview works

The member-facing portal (Application) is tenant-keyed by hostname: Application/Infrastructure/SubdomainRoute.cs normally reads the subdomain as an organisation permalink (demo.raklet.org → org demo), so a branch host like rak349-gercek.raklet.org would resolve to a non-existent org rak349-gercek and 404.

The route therefore honours a Preview-Organisation-Permalink appSetting: when set, it serves that organisation regardless of the host and skips the subdomain/custom-domain resolution. The key ships empty in the canonical/staging/prod Application/web.config (a no-op there); the create script sets it to -Org in the worktree copy. The portal host's label is the org permalink (<ticket>-<org>) purely so it's self-documenting and consistent with the other services — routing is driven entirely by the appSetting, not by parsing the host (org permalinks can contain dashes, so the host isn't parsed). Portal self-links built via PathService.GetV3Path already resolve to the current request host, so a preview visitor stays on rak349-gercek.raklet.org while browsing.

Two branches can preview the same org in parallel (e.g. both -Org gercek) at their own hostnames, sharing the org's data in the one local database.

Scope and limitations

  • Shared local database. All previews (and the canonical local env) use the same database. Do not run two branches in parallel when they have diverging EF migrations — one branch's Update-Database will break the other. The portal org's data is shared too, so concurrent mutating tests on the same -Org can interfere; use different orgs per branch when that matters.
  • Migrations run via backend only. A preview's schema is current only after its backend site has started and migrated. If a write 500s with EF's "An error occurred while executing the command definition" (inner: Invalid column name), the DB is behind the model — warm/fix backend first. See Database migrations and warmup order.
  • Login is per-branch, session is shared. Each preview gets its own <ticket>-login.raklet.org so the sign-in round-trip stays on the preview, but the auth cookie is scoped to raklet.org, so one sign-in is shared across all previews and the canonical local env. Social/OAuth providers only allow login.raklet.org redirect URIs, so use email/password against a branch login host.
  • Cleanup. Remove previews you are done with so IIS sites, app pools, and hosts entries do not accumulate. Use -RemoveWorktree once the branch work is finished (the script refuses to remove a worktree with uncommitted changes and tells you the --force command if you really mean it).