Development with real infra, and production deploy
Development against real Postgres + Redis
Use this path when you want the full database-backed experience — real persistence, real job queue — but aren’t ready to connect a real Microsoft 365 tenant yet.
cp .env.example .env # set secrets, DATABASE_URL, REDIS_URL, DEMO_MODE=false
pnpm infra:dev:up # docker compose -f infra/docker-compose.dev.yml up -d
pnpm db:migrate
pnpm db:seed # loads the same demo tenants/devices/vulns fixtures
pnpm dev
See Requirements for what every
.env variable does before you fill it in.
Production (full self-hosted stack)
This is the real deployment: Docker Compose brings up every service behind a reverse proxy that provisions its own TLS certificate.
cp .env.example .env # set PP_DOMAIN, secrets, POSTGRES_PASSWORD, DEMO_MODE=false
docker compose -f infra/docker-compose.yml --env-file .env up -d --build
Services brought up: caddy (TLS via Let’s Encrypt) in front of web
(the compiled SPA served by nginx) and api (Fastify), plus worker,
postgres, redis, and ollama. Point your domain’s DNS at the host and
Caddy provisions the certificate automatically — no manual certificate
handling.
Once the stack is up, continue to Pairing to connect it to a real Microsoft 365 tenant.
Where to host for testing a real login
There are two fundamentally different ways to run PatchPilot, and only the second needs a public host at all:
- UI and onboarding only — stay in
DEMO_MODE=true(Demo Mode). Sign-in is bypassed, no Entra app, no hosting required. - Real sign-in with the On-Behalf-Of token exchange — needs a reachable
HTTPS redirect URI that exactly matches what’s registered in the Entra
app and written in
.env(AUTH_REDIRECT_URI/PUBLIC_URL/CORS_ORIGINS). The only exception:http://localhostis allowed for development.
A static host (including GitHub Pages) cannot run PatchPilot itself. Completing the OAuth code-for-token exchange happens server-side using a client secret that must never reach the browser. A static host can only serve the SPA shell — sign-in would be broken, since there’s no server there to hold the secret. You need a host that can run the Node/Docker stack.
| Option | Public URL? | Cost | When to use |
|---|---|---|---|
| Localhost | No (your browser only) | Free | Solo testing against your own MSP tenant. Register http://localhost:<port>/auth/callback. Can’t be reached by a separate customer tenant for consent. |
| Tunnel (Cloudflare Tunnel / ngrok) | Yes | Free | Run the stack locally, expose a public HTTPS URL. Lets you test the real multi-tenant GDAP consent flow with a separate test customer tenant. Prefer a stable hostname (Cloudflare Tunnel on a domain you own) — a free ngrok subdomain changes on every restart, forcing you to re-edit the app registration and .env each time. |
| VPS + domain | Yes | A few $/month | Closest to production. Point DNS at the host and run the Docker Compose command above; Caddy auto-provisions the TLS certificate. |
Recommendation: validate the UI in Demo Mode first, use localhost for single-tenant sign-in/OBO testing, and reach for a Cloudflare Tunnel only when you need to exercise the real customer-consent path without renting a server.
Keeping the worker/API alive on localhost
pnpm dev (via tsx watch) restarts automatically on a file save, but not
if the process crashes on its own — for the worker, that leaves every
remediation job stuck at queued until someone notices and restarts it by
hand. infra/docker-compose.yml already recovers from this
(restart: unless-stopped), but plain pnpm dev on localhost doesn’t. Run
pnpm --filter @patchpilot/worker dev:resilient (and the same for
@patchpilot/api) to run the same source under a small crash-restart
supervisor instead — no file-watch hot reload, but it comes back on its own
if it crashes.
AI features (optional)
Off by default (AI_FEATURES_ENABLED=false). The model runs entirely inside
your own Compose stack — the ollama container has no published port and
makes no calls out to the internet at inference time.
# 1. Set in .env: AI_FEATURES_ENABLED=true
# 2. Bring the stack up (or restart it) so the ollama container exists
docker compose -f infra/docker-compose.yml --env-file .env up -d
# 3. One-time model pull — needs host internet access for this step only,
# never again after. Weights persist in the ollama_data volume.
docker compose -f infra/docker-compose.yml --env-file .env exec ollama ollama pull llama3.1:8b
OLLAMA_MODEL defaults to llama3.1:8b (Meta, USA) — deliberately not a
PRC-origin model, since vendor provenance for the model weights matters
independently of network isolation when the box handles MSP client security
data. Minimum recommended: 8 vCPU / 16GB RAM. On beefier hardware, pull
llama3.1:70b instead and set OLLAMA_MODEL to match — no code change
needed, though a GPU is strongly recommended at that size.
Useful scripts
| Command | What it does |
|---|---|
pnpm dev |
Run web + api + worker with hot reload |
pnpm build |
Build all packages |
pnpm typecheck |
Type-check every package |
pnpm test |
Run the test suite |
pnpm infra:dev:up / pnpm infra:dev:down |
Start/stop dev Postgres + Redis |
pnpm db:generate |
Generate a Drizzle migration from the schema |
pnpm db:migrate |
Apply migrations |
pnpm db:seed |
Seed demo tenants/devices/vulnerabilities |
pnpm db:seedunconditionally wipes and reloads the database’s demo tables. Never run it against an instance holding real onboarded tenants.