"Deploy FastAPI + React/Vite apps to Windows Server with NSSM services, reverse proxy (IIS or nginx), and git-pull workflow. Use when deploying any web app to a Windows prod server, setting up NSSM services, configuring IIS or nginx reverse proxy, making code environment-aware for dev/prod, or troubleshooting prod deployment issues."
Install
npx skillscat add saajunaid/caddis-plugin/windows-deployment Install via the SkillsCat registry.
Windows Server Deployment Skill
Deploy FastAPI + React/Vite web applications to Windows Server using NSSM services, a reverse proxy (IIS or nginx), and a git-pull workflow.
1. When to Apply This Skill
Trigger conditions:
- "Deploy this app to prod" / "Deploy to Windows Server"
- Setting up a new web application on a Windows prod server
- Adding a reverse proxy (IIS or nginx) for an app
- Registering NSSM services
- Configuring environment-aware dev/prod code (subpath, auth, static serving)
- Troubleshooting prod deployment (502, 404, service won't start)
- "Why can't the frontend reach the API?"
Prerequisites the user should have:
- A FastAPI + React/Vite app
- A Windows Server target machine
- NSSM installed on the target
- A git remote (Gitea, GitHub, etc.) accessible from both dev and prod
- Python and Node.js installed on the target
2. Architecture Overview
Two reverse proxy strategies are available. Choose based on what the server already runs.
Strategy A — IIS (Recommended When IIS Is Already Running)
Browser → IIS (:80/:443)
├─ Windows Auth + Active Directory (handled by IIS)
├─ URL Rewrite: /{subpath}/api/* → http://localhost:{api-port}/api/*
└─ URL Rewrite: /{subpath}/* → http://localhost:{api-port}/*
├─ /api/* → FastAPI routers
└─ /* → FastAPI serves dist/ static files
NSSM manages:
- <org>-{app}-api-prod (one per app)Key characteristics:
- IIS handles authentication (Windows Auth, AD groups) — app code has zero auth logic
- App is deployed under a subpath (e.g.
/my-app/) — IIS strips the prefix before forwarding - FastAPI serves both API routes and static frontend files in production
- No separate frontend service/port needed
Strategy B — nginx (Standalone Reverse Proxy)
Browser → nginx (:51xx) → serves static dist/ files
→ proxies /api/* → uvicorn (:81xx)
NSSM manages:
- <org>-nginx-prod (single instance, all apps)
- <org>-{app}-api-prod (one per app)Key characteristics:
- nginx handles static file serving and API proxying
- Each app gets its own port (e.g.
:5103,:5104) - No built-in auth — add separately if needed
- Clean separation: nginx = static + proxy, uvicorn = API only
Decision Matrix
| Factor | Choose IIS | Choose nginx |
|---|---|---|
| IIS already running on the server | ✅ | |
| Need Windows Authentication / AD | ✅ | |
| Apps must be on port 80/443 | ✅ | |
| Multiple apps under one domain with subpaths | ✅ | |
| No IIS on the server | ✅ | |
| Need lightweight, portable setup | ✅ | |
| Each app gets its own port | ✅ |
Port Convention
| Environment | API (uvicorn) | Frontend |
|---|---|---|
| Dev | 81x2 | 51x2 (Vite dev server) |
| Prod (IIS) | 81x3 | IIS on :80/:443 (no separate FE port) |
| Prod (nginx) | 81x3 | 51x3 (nginx) |
Where x is the app number. Prod API ports increment per app on the shared server (8103, 8104, 8105...).
3. Deployment Flow
First-Time Setup (Both Strategies)
1. Clone repo on prod via git
2. Set up Python venv + install deps
3. Build frontend (npm ci && npm run build:prod)
4. Create config/.env.api.prod
5. Register NSSM API service
6. Configure reverse proxy (IIS or nginx)
7. Open firewall ports
8. VerifySubsequent Deploys
1. Dev: git push origin main
2. Prod: git pull
3. pip install -e . (if Python deps changed)
4. cd frontend && npm ci && npm run build:prod (if FE changed)
5. nssm restart <org>-{app}-api-prod
6. Verify3.1 Remote Capability Handshake (Required Before Saying "Can't Access Prod")
When a user asks for remote prod actions, do not assume remote access is unavailable.
Run this handshake first:
$server = "{hostname}"
# 1) Reachability
Test-Connection -ComputerName $server -Count 1
# 2) WinRM availability
Test-WSMan -ComputerName $server
# 3) Actual command execution
Invoke-Command -ComputerName $server -ScriptBlock {
hostname
Get-Location
}Decision rule:
- If steps 1-3 succeed, proceed with remote deploy actions.
- If any step fails, report the exact failing step and provide fallback instructions.
Hard rule: Never claim "I can't remote into prod" until this probe fails in the current session.
4. Common Steps (Both Strategies)
4.1 Git Setup on Prod
cd G:\Projects
git clone http://{git-host}/{org}/{app-name}.git
cd {app-name}
# Credential storage (one-time)
git config credential.helper store
[System.Environment]::SetEnvironmentVariable("GCM_PROVIDER", "generic", "User")
git pull # enter credentials once, stored thereafterIf using a self-hosted git server, ensure the hosts file maps the hostname.
4.2 Python Environment
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -e .Verify Python version matches dev: python --version.
4.3 Frontend Build
cd frontend
npm ci
npm run build:prod # ← uses production Vite mode (see §7)
Get-ChildItem dist -Recurse | Measure-Object4.4 Environment Config
Create config/.env.api.prod:
APP_ENV=production
APP_PORT=81x3
# Add app-specific vars (DB connection, etc.)Never commit real .env files. Only .env.example with placeholder values.
4.5 Register API Service with NSSM
$projectRoot = "G:\Projects\{app-name}"
$pythonExe = "$projectRoot\.venv\Scripts\python.exe"
$svcName = "<org>-{short}-api-prod"
$port = 81x3 # Use actual port
nssm install $svcName $pythonExe "-m uvicorn src.api.main:app --host 0.0.0.0 --port $port"
nssm set $svcName AppDirectory $projectRoot
nssm set $svcName DisplayName "{App Name} API (PROD)"
nssm set $svcName Description "{App Name} FastAPI backend - prod environment"
nssm set $svcName AppEnvironmentExtra "APP_ENV=production"
# Logging
nssm set $svcName AppStdout "$projectRoot\logs\$svcName-stdout.log"
nssm set $svcName AppStderr "$projectRoot\logs\$svcName-stderr.log"
nssm set $svcName AppRotateFiles 1
nssm set $svcName AppRotateBytes 10485760 # 10 MB
# Restart on failure
nssm set $svcName AppRestartDelay 5000
nssm start $svcName5. Strategy A — IIS Reverse Proxy Setup
5.1 Prerequisites
Install IIS features (if not already present):
Install-WindowsFeature Web-Server, Web-Mgmt-ConsoleInstall these IIS modules:
- URL Rewrite — https://www.iis.net/downloads/microsoft/url-rewrite
- Application Request Routing (ARR) — https://www.iis.net/downloads/microsoft/application-request-routing
Enable ARR proxy functionality:
# In IIS Manager → Server level → Application Request Routing → Server Proxy Settings
# Check "Enable proxy" → Apply
# Or via appcmd:
& "$env:SystemRoot\system32\inetsrv\appcmd.exe" set config -section:system.webServer/proxy /enabled:true /commit:apphost5.2 IIS Rewrite Rules
Create rewrite rules under the Default Web Site (or a dedicated site). Two rules are needed — the API rule must come first:
Rule 1 — API proxy (matches first):
<rule name="{app-name}-api" stopProcessing="true">
<match url="^{subpath}/api/(.*)" />
<action type="Rewrite" url="http://localhost:{api-port}/api/{R:1}" />
</rule>Rule 2 — Frontend catch-all:
<rule name="{app-name}-frontend" stopProcessing="true">
<match url="^{subpath}/(.*)" />
<action type="Rewrite" url="http://localhost:{api-port}/{R:1}" />
</rule>How it works: IIS strips the subpath prefix before forwarding. FastAPI receives clean paths (/api/v1/... and /index.html).
5.3 Windows Authentication
Enable Windows Auth on the IIS site/app:
# Disable anonymous access, enable Windows Auth
Set-WebConfigurationProperty -Filter '/system.webServer/security/authentication/anonymousAuthentication' `
-PSPath 'IIS:\Sites\Default Web Site' -Name 'enabled' -Value 'false'
Set-WebConfigurationProperty -Filter '/system.webServer/security/authentication/windowsAuthentication' `
-PSPath 'IIS:\Sites\Default Web Site' -Name 'enabled' -Value 'true'Key insight: Auth is fully external to the application. FastAPI endpoints have no auth dependencies or middleware — IIS rejects unauthenticated requests before they reach uvicorn.
5.4 FastAPI Static File Serving (Required for IIS Strategy)
With IIS, FastAPI must serve the frontend dist/ files in production. Add this to main.py — gated on a settings flag so it only activates in production:
from pathlib import Path
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
_FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
# ... app setup, routers ...
# ── Static file serving (production only) ──
if not settings.debug and _FRONTEND_DIST.is_dir():
app.mount("/assets", StaticFiles(directory=_FRONTEND_DIST / "assets"), name="static")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str) -> FileResponse:
# Path traversal guard (OWASP A04)
file = (_FRONTEND_DIST / full_path).resolve()
if file.is_file() and str(file).startswith(str(_FRONTEND_DIST)):
return FileResponse(file)
return FileResponse(_FRONTEND_DIST / "index.html")Critical ordering rule: Mount this AFTER all API routers. The SPA catch-all must be the last route.
Dev behavior: settings.debug == True → static block is skipped → Vite dev server handles frontend.
Prod behavior: settings.debug == False → FastAPI serves dist/assets/* and falls back to index.html for SPA routing.
5.5 Firewall (IIS)
No per-app firewall rules needed — IIS listens on ports 80/443, which are typically already open. API ports (81xx) stay on localhost.
5.6 Verify (IIS)
# IIS site running?
Get-Website | Where-Object { $_.State -eq 'Started' }
# Frontend loads?
Invoke-WebRequest -Uri "http://localhost/{subpath}/" -UseBasicParsing -UseDefaultCredentials | Select-Object StatusCode
# API proxied?
(Invoke-WebRequest -Uri "http://localhost/{subpath}/api/v1/{endpoint}" -UseBasicParsing -UseDefaultCredentials).Content
# Health?
(Invoke-WebRequest -Uri "http://localhost/{subpath}/api/health" -UseBasicParsing -UseDefaultCredentials).Content6. Strategy B — nginx Reverse Proxy Setup
6.1 nginx as a Shared Service
nginx runs as a single NSSM service serving all apps. Each app gets its own server {} block on a different port.
# First-time nginx setup
nssm install <org>-nginx-prod "C:\nginx\nginx.exe"
nssm set <org>-nginx-prod AppDirectory "C:\nginx"
nssm set <org>-nginx-prod DisplayName "nginx Reverse Proxy (PROD)"
nssm set <org>-nginx-prod AppStdout "G:\Projects\logs\<org>-nginx-prod-stdout.log"
nssm set <org>-nginx-prod AppStderr "G:\Projects\logs\<org>-nginx-prod-stderr.log"
nssm set <org>-nginx-prod AppRotateFiles 1
nssm set <org>-nginx-prod AppRotateBytes 10485760
nssm set <org>-nginx-prod AppRestartDelay 5000
nssm start <org>-nginx-prod6.2 nginx Server Block Template
Add to C:\nginx\conf\nginx.conf:
server {
listen {51x3};
server_name {hostname} localhost;
location /api/ {
proxy_pass http://127.0.0.1:{81x3}/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
}
location /health {
proxy_pass http://127.0.0.1:{81x3}/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location / {
root G:/Projects/{app-name}/frontend/dist;
try_files $uri $uri/ /index.html;
expires 1h;
add_header Cache-Control "public, no-transform";
}
}Test and reload:
C:\nginx\nginx.exe -t -c C:\nginx\conf\nginx.conf
nssm restart <org>-nginx-prod6.3 Firewall (nginx)
# Only expose the FE port — API stays on localhost
New-NetFirewallRule -DisplayName "{App Name} ({51x3})" -Direction Inbound -LocalPort {51x3} -Protocol TCP -Action Allow6.4 CORS Configuration (nginx only)
When nginx serves on a different port than the API, CORS is needed:
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5102", # Vite dev
"http://localhost:51x3", # nginx prod
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)IIS note: CORS is generally not needed with IIS because the browser hits IIS on the same origin as the page — no cross-origin request occurs.
7. Environment-Aware Code — The One-Codebase Pattern
The single most important deployment principle: one codebase serves both dev and prod. Environment differences are handled by runtime/build-time detection, not separate code.
7.1 Vite Config — Conditional Base Path
When deployed under a subpath (IIS strategy), asset URLs must include the subpath prefix. Vite bakes this in at build time via the base config.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
const isProd = mode === "production";
const base = isProd ? "/{subpath}/" : "/";
return {
base,
plugins: [react()],
server: {
port: 5102,
proxy: isProd ? undefined : {
"/api": {
target: "http://localhost:8102",
changeOrigin: true,
},
},
},
};
});Dev: base: "/", proxy active → Vite dev server handles everything.
Prod build: base: "/{subpath}/" → all asset paths in dist/index.html prefixed correctly.
If not deploying under a subpath (nginx or root path), keep base: "/" for both modes and simplify.
7.2 Client-Side Router — basepath Configuration
This is the most common cause of "Not Found" on subpath deployments. The reverse proxy (IIS) strips the subpath before forwarding to FastAPI, but the browser URL still shows the subpath. Client-side routers need to know about this prefix.
TanStack Router
// frontend/src/main.tsx
import { createRouter } from "@tanstack/react-router";
const router = createRouter({
routeTree,
basepath: import.meta.env.BASE_URL, // dev: "/", prod: "/{subpath}/"
});React Router (v6+)
<BrowserRouter basename={import.meta.env.BASE_URL}>
<Routes>...</Routes>
</BrowserRouter>import.meta.env.BASE_URL is automatically set by Vite from the base config in vite.config.ts (§7.1). This is the single source of truth for the subpath prefix across the entire frontend.
Why this breaks: IIS rewrites /my-app/dashboard → /dashboard server-side. FastAPI serves index.html. But the browser URL bar still shows /my-app/dashboard. The client-side router sees that URL, and without basepath, tries to match /my-app/dashboard against your route tree — which only has /dashboard. No match → "Not Found".
7.3 Axios Client — Dynamic Base URL
// frontend/src/api/client.ts
import axios from "axios";
const apiClient = axios.create({
baseURL: `${import.meta.env.BASE_URL}api/v1`,
withCredentials: true, // Required if using Windows Auth (IIS)
});
export default apiClient;import.meta.env.BASE_URL is set by Vite automatically from the base config:
- Dev:
"/"→baseURL = "/api/v1"→ Vite proxy forwards to FastAPI - Prod (subpath):
"/{subpath}/"→baseURL = "/{subpath}/api/v1"→ IIS rewrites to FastAPI
withCredentials: true is required when the reverse proxy handles auth (e.g. IIS Windows Auth). With nginx or no auth, it's harmless.
7.4 Static Asset Fetches — The Hidden Subpath Trap
Router basepath (§7.2) and API baseURL (§7.3) are well-known subpath requirements. Runtime fetches of files from the public/ directory are easily missed because they work fine in dev (where BASE_URL is "/") and only break under a subpath.
Examples of static asset fetches that break under a subpath:
fetch("/config.json")— app configurationfetch("/ireland-counties.geojson")— map datafetch("/manifest.json")— PWA manifest- Any
fetch("/<file>")for files inpublic/
Fix: Create a toAppUrl() helper and use it everywhere:
// frontend/src/lib/utils.ts
export function toAppUrl(path: string): string {
const base = import.meta.env.BASE_URL ?? "/";
const stripped = path.startsWith("/") ? path.slice(1) : path;
return `${base}${stripped}`;
}Usage:
// ❌ BAD: Root-absolute — breaks under /my-app/ subpath
const GEOJSON_PATH = "/ireland-counties.geojson";
fetch("/config.json");
// ✅ GOOD: Subpath-aware
import { toAppUrl } from "@/lib/utils";
const GEOJSON_PATH = toAppUrl("ireland-counties.geojson");
fetch(toAppUrl("config.json"));Detection: Search for root-absolute fetch patterns that bypass BASE_URL:
grep -rn 'fetch("/' frontend/src/
grep -rn '= "/[a-zA-Z]' frontend/src/The three categories of subpath-sensitive URLs:
| Category | What | Where to fix | Reference |
|---|---|---|---|
| Router basepath | Route matching in browser | createRouter({ basepath }) |
§7.2 |
| API baseURL | Backend API calls | axios.create({ baseURL }) |
§7.3 |
| Static asset fetches | public/ files loaded at runtime |
toAppUrl() helper |
§7.4 |
All three must use import.meta.env.BASE_URL. Missing any one causes prod-only failures.
7.5 package.json Build Scripts
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:prod": "tsc -b && vite build --mode production"
}
}npm run build— default build (dev mode,base: "/")npm run build:prod— production build with subpath prefix
7.6 FastAPI Settings — Debug Flag
Use a settings property to distinguish dev from prod behavior:
# src/config/settings.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_env: str = "development"
app_port: int = 8102
@property
def debug(self) -> bool:
return self.app_env == "development"config/.env.api.dev → APP_ENV=development → debug=True → no static serving, docs enabledconfig/.env.api.prod → APP_ENV=production → debug=False → static serving active, docs disabled
7.7 What This Means for Development
| Action | Dev | Prod |
|---|---|---|
| Frontend served by | Vite dev server (:5102) |
FastAPI (IIS) or nginx |
| API requests proxied by | Vite proxy config |
IIS URL Rewrite or nginx |
| Auth | None | IIS Windows Auth (external) |
npm run build needed? |
No (Vite serves source) | Yes (build:prod) |
FastAPI serves dist/? |
No (debug=True) |
Yes (IIS strategy) or No (nginx strategy) |
All behavior switches automatically based on environment config. You never maintain separate codebases.
8. NSSM Script (Reusable)
Projects can include a reusable NSSM install script:
# Usage:
# .\nssm-install.ps1 -ProjectShort nps -Port 8103
param(
[Parameter(Mandatory)][string]$ProjectShort,
[Parameter(Mandatory)][int]$Port,
[string]$ProjectRoot = (Get-Location).Path
)
$svcName = "<org>-$ProjectShort-api-prod"
$pythonExe = "$ProjectRoot\.venv\Scripts\python.exe"
nssm install $svcName $pythonExe "-m uvicorn src.api.main:app --host 0.0.0.0 --port $Port"
nssm set $svcName AppDirectory $ProjectRoot
nssm set $svcName AppEnvironmentExtra "APP_ENV=production"
nssm set $svcName AppStdout "$ProjectRoot\logs\$svcName-stdout.log"
nssm set $svcName AppStderr "$ProjectRoot\logs\$svcName-stderr.log"
nssm set $svcName AppRotateFiles 1
nssm set $svcName AppRotateBytes 10485760
nssm set $svcName AppRestartDelay 5000
nssm start $svcName9. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "Unable to load periods" / API 404 | Frontend hitting static server, no proxy to API | Configure reverse proxy (IIS §5 or nginx §6) |
| Assets load but API calls fail on subpath | vite.config.ts not setting base for prod |
Use defineConfig(({ mode }) => ...) pattern (§7.1) |
Site works on prod but breaks after git pull |
Sysadmin made manual edits — overwritten by pull | Make code environment-aware (§7), never hand-edit on prod |
Next npm run build breaks prod |
Build uses wrong mode (no subpath prefix) | Use npm run build:prod with --mode production (§7.3) |
| 502 Bad Gateway (IIS or nginx) | uvicorn not running | Get-Service <org>-{short}-api-prod → restart |
| IIS returns 401 Unauthorized | Windows Auth blocking — user not in AD | Check IIS auth settings, verify user is in allowed AD group |
| IIS returns 404 on subpath routes | URL Rewrite rules missing or wrong order | API rule must come before frontend catch-all (§5.2) |
bind() failed on nginx start |
Port already in use | netstat -ano | findstr {port} → stop conflicting process |
Stale frontend after git pull |
dist/ not rebuilt |
cd frontend && npm ci && npm run build:prod |
ENOTEMPTY: rmdir dist/assets |
Process has dist/ files locked |
Stop service → build → restart |
| Service starts then stops immediately | Bad path or missing dependency | Check NSSM stderr log: Get-Content logs\<org>-{short}-api-prod-stderr.log -Tail 50 |
git pull fails with auth error |
Credentials expired or GCM_PROVIDER not set | git config credential.helper store + GCM_PROVIDER=generic as user env var |
| Unclear whether remote control works | Assumed lack of access without probing | Run Remote Capability Handshake (§3.1) before deciding |
| Frontend shows blank page | SPA routing broken (no catch-all) | IIS: check FastAPI SPA catch-all (§5.4). nginx: check try_files (§6.2) |
| Frontend shows blank page (assets 404) | Vite base flattened to "/" under subpath |
Restore base: mode === "production" ? "/{subpath}/" : "/" in vite.config.ts, rebuild, add deploy guard (§12) |
| Users see old version after deploy | Browser caching stale index.html |
Add Cache-Control: no-cache, no-store, must-revalidate on HTML responses (§13) |
| Frontend shows "Not Found" on subpath | Client-side router missing basepath |
Add basepath: import.meta.env.BASE_URL to router config (§7.2) |
Unexpected token '<' parsing JSON |
Static asset fetch uses root-absolute path (/file.json) under subpath |
Use toAppUrl() helper for all public/ file fetches (§7.4) |
withCredentials CORS error in browser |
Missing allow_credentials=True or wrong origin |
Add CORS config (§6.4) or verify same-origin (IIS usually doesn't need CORS) |
10. Deploy Checklists (Copy-Paste)
First-Time Deploy — IIS Strategy
- [ ] Git clone on prod + credential setup
- [ ] Python venv + pip install -e .
- [ ] config/.env.api.prod created (APP_ENV=production)
- [ ] Frontend: npm ci && npm run build:prod
- [ ] NSSM: API service registered and started
- [ ] IIS: URL Rewrite + ARR installed and proxy enabled
- [ ] IIS: Two rewrite rules added (API first, then frontend catch-all)
- [ ] IIS: Windows Auth enabled, anonymous disabled (if needed)
- [ ] FastAPI main.py has conditional static serving block (§5.4)
- [ ] vite.config.ts uses defineConfig(({ mode }) => ...) with conditional base (§7.1)
- [ ] client.ts uses import.meta.env.BASE_URL for baseURL (§7.3)
- [ ] Client-side router has basepath set to import.meta.env.BASE_URL (§7.2)
- [ ] All `fetch("/...")` calls for public/ assets use `toAppUrl()` helper (§7.4)
- [ ] Grep for root-absolute fetches: `grep -rn 'fetch("/' frontend/src/` returns no matches
- [ ] Deploy guard: post-build HTML check for `/{subpath}/assets/` prefix (§12.1)
- [ ] Cache-Control: `no-cache` on HTML, `immutable` on hashed assets (§13)
- [ ] Verify: frontend loads, API responds, auth worksFirst-Time Deploy — nginx Strategy
- [ ] Git clone on prod + credential setup
- [ ] Python venv + pip install -e .
- [ ] config/.env.api.prod created (APP_ENV=production)
- [ ] Frontend: npm ci && npm run build
- [ ] NSSM: API service registered and started
- [ ] nginx: server block added, tested, reloaded
- [ ] Firewall: FE port opened (API port NOT exposed)
- [ ] CORS: prod FE port added to FastAPI allow_origins
- [ ] Verify: static files, API proxy, health all return 200Subsequent Deploy (Both Strategies)
- [ ] Remote Capability Handshake run (ping + WSMan + Invoke-Command) (§3.1)
- [ ] git pull on prod
- [ ] pip install -e . (if deps changed)
- [ ] cd frontend && npm ci && npm run build:prod (if FE changed, IIS strategy)
OR npm run build (nginx strategy)
- [ ] nssm restart <org>-{short}-api-prod
- [ ] Verify app loads and API responds11. Lessons Learned
Hard-won lessons from real deployments:
One codebase, one repo. Dev and prod share identical code. Environment differences are handled by build-time mode (
vite --mode production) and runtime config (APP_ENV). Never maintain separate dev/prod codebases.Auth is infrastructure, not application code. When IIS handles Windows Auth, FastAPI endpoints have zero auth dependencies. IIS rejects unauthenticated requests before they reach uvicorn. Don't add auth middleware to match prod — it's not needed.
servecannot proxy API calls. Vercel'sserveis static-only./api/v1/...requests hit serve → 404. You need a reverse proxy (IIS or nginx) or FastAPI must serve static files. Never useservefor apps with API backends.Sysadmin hand-edits on prod cause
git pulldisasters. If a sysadmin manually editsvite.config.tsorclient.tson the prod server, the nextgit pullwill overwrite those changes and break the site. Make the code environment-aware in the repo sogit pullis always safe.Vite is a BUILD tool, not a runtime.
vite.config.tsis read atnpm run buildtime. Thebaseconfig gets baked into every asset URL indist/index.html. The file doesn't exist in prod at runtime. If thebaseis wrong at build time, the site breaks — even though the config "looks right" in the repo.Rebuild frontend after
git pull.git pullupdates source files, not the compileddist/folder. Always run the appropriate build command after pulling frontend changes.IIS subpath deployment eliminates the need for a separate FE port. With IIS serving on :80/:443 and rewriting to FastAPI, there is no port 5103 or nginx. One entry point, one port.
API ports stay on localhost. Only expose the entry-point ports (80/443 for IIS, 51xx for nginx). API ports (81xx) are proxied internally — no external access needed.
withCredentials: truein axios is needed for IIS Windows Auth. Without it, the browser won't send Negotiate/NTLM credentials in the API requests. It's harmless when no auth is used.Check the NSSM stderr log first when a service won't start:
Get-Content logs\<org>-{short}-api-prod-stderr.log -Tail 50.GCM_PROVIDER=genericis required on Windows Server for git credential storage to work with self-hosted git servers. Set as a persistent user env var.Port conflicts between apps. When multiple apps share a prod server, API backends must use different ports. Follow a consistent convention: 8103, 8104, 8105, etc.
Client-side router
basepathis mandatory for subpath deployments. When IIS serves an app under/my-app/, the browser URL retains that prefix. IIS strips it server-side before forwarding to FastAPI, so FastAPI never sees it. But the client-side router runs in the browser, where the URL still has/my-app/. Withoutbasepath(TanStack Router) orbasename(React Router), the router can't match any route → "Not Found". This is the #1 gotcha in subpath deployments.Path traversal guard on SPA catch-all. The
serve_spaendpoint resolves file paths from user-supplied URL segments. Always.resolve()the path and verify it starts with thedist/directory to prevent path traversal attacks (OWASP A04).Static asset fetches are the hidden third subpath trap. Router basepath and API baseURL are well-documented. Runtime
fetch("/config.json")orfetch("/data.geojson")frompublic/are not — they work in dev (base is/) and silently break in prod under a subpath. The symptom isUnexpected token '<'because the root-absolute URL hits the wrong app or fallback page, returning HTML instead of JSON. Prevention: create atoAppUrl()helper and audit withgrep -rn 'fetch("/' frontend/src/.Probe remote capability before declaring no access. In Windows environments, agents may be able to execute remote commands via WinRM (
Invoke-Command) even when interactive RDP control is not available. Always run the Remote Capability Handshake (§3.1) before stating remote access is unavailable.Never flatten
baseto"/"when the app is deployed under a subpath. The Vitebaseconfig controls every<script src>and<link href>in the builtindex.html. Changingbase: mode === "production" ? "/{subpath}/" : "/"→base: "/"causes all asset URLs to lose the subpath prefix. If the server has a catch-all rewrite rule (e.g. a Streamlit proxy at root), those asset requests hit the wrong backend → blank page. This is a silent, catastrophic break that is invisible in dev because dev always usesbase: "/".Deploy guards must validate built HTML before restarting services. A wrong
basepath produces a valid build (exit code 0) but broken output. The deploy script must check the builtindex.htmlfor the expected asset prefix before restarting services. If the check fails, the old working version stays live — the deploy aborts without downtime. See §12.Cache-Control headers prevent stale HTML after deploys. Vite hashes asset filenames (
index-Crzj2IZk.js) so old assets never collide with new ones. Butindex.htmlhas a fixed path — withoutno-cache, browsers serve the old HTML (with old asset hashes) even after a deploy. SetCache-Control: no-cache, no-store, must-revalidateon HTML andpublic, max-age=31536000, immutableon hashed assets. See §13.
12. Deploy Guards — Post-Build Validation
A deploy guard is a check that runs after the build but before the service restart. If the guard fails, the deploy aborts and the currently-running (working) version stays live. This prevents broken builds from causing downtime.
12.1 Base Path Guard (IIS Strategy — Required)
When deploying under a subpath, verify the built HTML references the correct asset prefix:
# In deploy.ps1 — after vite build, before nssm restart
$indexHtml = Join-Path $frontendDir "dist\index.html"
if (Test-Path $indexHtml) {
$html = Get-Content $indexHtml -Raw
if ($html -notmatch '/{subpath}/assets/') {
throw "DEPLOY GUARD FAILED: Built index.html does not reference /{subpath}/assets/. " +
"The Vite base path is wrong — aborting deploy before service restart."
}
Write-Host "Deploy guard passed: assets reference /{subpath}/"
} else {
throw "DEPLOY GUARD FAILED: $indexHtml not found after build"
}Why this matters: A wrong base in vite.config.ts produces a successful build (exit 0) but broken output. Without this guard, the deploy script would restart the service, serve the broken HTML, and take the app down. With the guard, the old working version stays live.
12.2 CI Build Validation (Belt-and-Suspenders)
The same check should run in CI so the broken build is caught before it reaches the deploy step:
# In CI workflow — after frontend build step
- name: Validate production base path
shell: powershell
working-directory: frontend
run: |
$html = Get-Content dist\index.html -Raw
if ($html -notmatch '/{subpath}/assets/') {
throw "DEPLOY GUARD: Built index.html does not reference /{subpath}/assets/. " +
"Check vite.config.ts base setting."
}
Write-Host 'Base-path guard passed'Key: Build with --mode production in CI (not just vite build), otherwise the mode-conditional base won't activate and the guard will always fail.
12.3 Additional Deploy Guards (Optional)
| Guard | What it checks | When to add |
|---|---|---|
| Health endpoint | API responds 200 after restart | Always |
| Base path | Asset prefix in built HTML | Subpath deployments |
| Dist size | dist/ is non-empty and reasonable size |
Prevent empty-build deploys |
| Git SHA convergence | prod HEAD matches expected commit | Prevent stale-code deploys |
13. Cache-Control Headers for SPA Serving
When FastAPI serves the built frontend (IIS strategy, §5.4), set appropriate cache headers to ensure browsers always get fresh HTML after deploys while caching hashed assets aggressively.
13.1 The Problem
Vite produces content-hashed asset filenames (index-Crzj2IZk.js), so old and new assets never collide. But index.html has a fixed path — without cache-control headers, browsers may serve a cached index.html with stale asset references after a deploy. The user sees a blank page or JS errors because the old hashed files no longer exist.
13.2 The Fix — Two Cache Policies
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
# Cache policies
_NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
_LONG_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"}
_FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
if not settings.debug and _FRONTEND_DIST.is_dir():
app.mount("/assets", StaticFiles(directory=_FRONTEND_DIST / "assets"), name="static")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str) -> FileResponse:
file = (_FRONTEND_DIST / full_path).resolve()
if file.is_file() and str(file).startswith(str(_FRONTEND_DIST)):
# Hashed assets → cache forever; everything else → no cache
headers = _LONG_CACHE if "/assets/" in full_path else _NO_CACHE
return FileResponse(file, headers=headers)
return FileResponse(_FRONTEND_DIST / "index.html", headers=_NO_CACHE)13.3 Cache Policy Summary
| Resource | Cache-Control | Why |
|---|---|---|
index.html (SPA shell) |
no-cache, no-store, must-revalidate |
Must always fetch latest — contains hashed asset references |
/assets/*.js, /assets/*.css |
public, max-age=31536000, immutable |
Content-hashed filenames — safe to cache forever |
Other static files (favicon.ico, etc.) |
no-cache, no-store, must-revalidate |
No content hash — may change between deploys |
13.4 nginx Equivalent
For the nginx strategy (§6.2), set the same headers in the server block:
location / {
root G:/Projects/{app-name}/frontend/dist;
try_files $uri $uri/ /index.html;
# SPA shell — never cache
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Hashed assets — cache forever
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}