Previously in Part 11, we turned the AI on its own app and closed what it found. ClinicLive now works — on localhost. This part moves it to a real Linux VPS with nginx in front and systemd keeping it alive, and meets the three lines of configuration that separate "works locally" from "works in production".
The AI writes the runbook
Twelve parts in, the pattern is muscle memory: describe the outcome precisely, name the known traps, demand explanations, review everything. Deployment is where that discipline pays most, because deployment mistakes surface at 2am. Here is the real prompt, verbatim from the commit:
Write the production deployment for a Linux VPS: a systemd unit, an
nginx site config, and a DEPLOY.md runbook a beginner can follow —
one-time setup, ship-an-update, and a 2am troubleshooting checklist.
Use placeholder IPs/domains only (203.0.113.10 / cliniclive.example.com).
Call out loudly the nginx lines SignalR needs — that's the #1 'works
locally, dies in prod' failure for Blazor Server. Add a GitHub Actions
workflow that builds and runs the full test suite, Testcontainers
included.
Notice what the prompt does. It asks for a runbook a beginner can follow — which forces the AI to explain each step, which lets us verify each step. It demands placeholders, pre-empting a leak. And it names the SignalR trap up front, so the AI has to address it loudly instead of burying it in a comment. You get better output by telling the AI what failure looks like.
Model pick: Sonnet, medium effort. nginx and systemd are the best-documented territory on the internet — the model has seen ten thousand of these configs. Save the expensive thinking for problems that are yours alone; deployment is everyone's problem, solved a million times.
One service, kept alive by systemd
The unit file is short enough to understand completely — which is the standard everything in production should meet:
# /etc/systemd/system/cliniclive.service
[Unit]
Description=ClinicLive (Blazor Server)
After=network.target postgresql.service
[Service]
WorkingDirectory=/var/www/cliniclive
ExecStart=/usr/bin/dotnet /var/www/cliniclive/ClinicLive.dll
Restart=always
RestartSec=5
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://localhost:5100
[Install]
WantedBy=multi-user.target
The lines that earn their keep: Restart=always with RestartSec=5
means a crash puts the app back up in five seconds without a human awake.
After=postgresql.service orders startup so the database is there when the app
boots. It runs as www-data, not root. And it listens on
localhost:5100 only — nothing reaches this process except through nginx, which
is the next piece.
nginx, and the three lines everything depends on
This config is the centerpiece of the whole part. Read the comments:
# /etc/nginx/sites-available/cliniclive.example.com
server {
listen 80;
server_name cliniclive.example.com;
location / {
proxy_pass http://localhost:5100;
proxy_http_version 1.1; # SIGNALR: WebSockets need HTTP/1.1
proxy_set_header Upgrade $http_upgrade; # SIGNALR: pass the upgrade through
proxy_set_header Connection $http_connection; # SIGNALR: keep the tunnel open
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache off;
proxy_read_timeout 100s; # circuits are long-lived; default 60s drops them
}
}
Here is why those three SIGNALR lines matter more than everything else in this
post. Blazor Server is a WebSocket application: every interactive page holds a
circuit — a live SignalR connection — and our queue board and kiosk hold one more. A
WebSocket starts life as an HTTP request that asks to be upgraded to a persistent
two-way tunnel. A reverse proxy that doesn't forward that handshake — the
Upgrade and Connection headers, over HTTP/1.1 — quietly refuses
the upgrade, and SignalR falls back to long-polling or drops the circuit entirely.
And this is the cruel part: nothing errors. The app loads. The pages
render. But every interaction is sluggish, the waiting-room board flickers between live and
"reconnecting…", and forms lose state mid-typing. It works, but it feels broken — and
because there's no exception to paste into a search box, this is the number one
works-locally-dies-in-production failure for Blazor Server. The fourth line matters too:
circuits sit quiet between interactions, and proxy_read_timeout 100s gives them
comfortable headroom over SignalR's keep-alive pings where nginx's 60-second default cuts
the tunnel out from under an idle page.
Tip: once DNS points at the server, TLS is one command:
certbot --nginx -d cliniclive.example.com. Certbot edits this same config
in place and handles renewal. There is no excuse for port 80 in production anymore.
What the AI got wrong: nothing that broke — its closest miss was a line
that works but reads like a riddle:
sudo cp cliniclive.service /etc/nginx/../systemd/system/, a path that
resolves to /etc/systemd/system/ the long way around. Technically correct,
humanly baffling; the runbook keeps a comment translating it. On its home turf the AI's
failure mode isn't wrongness, it's weirdness — plus one risk we prevented rather than
fixed: it will cheerfully write your real IP and hostname into a public README unless
you demand placeholders. Which is exactly why the prompt did.
The checklist that saves you at 2am
The best thing in the AI-written DEPLOY.md is this. Not prose — symptom → cause pairs, because at 2am nobody reads paragraphs:
sudo journalctl -u cliniclive -f— the app's logs, live. Start here, always.- Board stuck on "reconnecting…"? You forgot the SIGNALR headers in nginx.
502 Bad Gateway? The service isn't listening on 5100 — check journalctl.-
Login loops?
ASPNETCORE_ENVIRONMENT=Productionplus missing HTTPS forwarding — theX-Forwarded-Protoline in nginx fixes it. -
Times look shifted? Set
Clinic:TimeZoneon the server — Part 10 says hello.
Every one of those is a real failure with this exact stack, and three of the five produce no exception anywhere. A checklist like this is the difference between a five-minute fix and a lost night — make the AI write yours while the context is fresh, not after the incident.
Secrets stay on the server
Production settings exist in exactly one place — on the VPS, readable by one user, and never in git:
# /var/www/cliniclive/appsettings.Production.json (chmod 600)
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=cliniclive;Username=cliniclive;Password=change-me"
},
"Clinic": { "TimeZone": "Australia/Sydney" }
}
And note what's in the public repo instead: 203.0.113.10 is a
documentation-reserved IP (RFC 5737 — it can never route to a real machine), and
cliniclive.example.com is a reserved domain. Public repos get scraped the hour
they're pushed. Documentation teaches patterns; samples must never contain real
infrastructure — the same privacy rule that gave our seeder its .test email
addresses back in Part 5.
Ship an update
dotnet publish src/ClinicLive/ClinicLive.csproj -c Release -o bin/deploy-linux
tar -czf cliniclive.tar.gz -C bin/deploy-linux . # tar, not zip: Windows
# zips break paths on Linux
scp cliniclive.tar.gz youruser@203.0.113.10:~
ssh youruser@203.0.113.10 "sudo systemctl stop cliniclive \
&& sudo tar -xzf ~/cliniclive.tar.gz -C /var/www/cliniclive \
&& sudo chown -R www-data:www-data /var/www/cliniclive \
&& sudo systemctl start cliniclive"
Migrations apply automatically at startup — the seeder calls MigrateAsync()
before seeding. Let's be honest about that choice, because it's a real trade-off. For a
small, single-instance app it's the pragmatic shortcut: deploy the binaries and the schema
catches up by itself. The costs: if you ever run two instances, they race to migrate; and a
failed migration takes the app down at startup, instead of failing loudly at
deploy time while you're still watching the terminal. Teams and zero-downtime
setups run dotnet ef database update (or a migration bundle) as an explicit
deploy step instead. Either answer is defensible — what's not defensible is not knowing
which one you chose.
CI: every push proves the app
The last piece of the prompt, delivered as one small workflow file:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest # Docker is preinstalled — Testcontainers just works
steps:
- uses: actions/checkout@v4
- name: Set up .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Build
run: dotnet build --configuration Release
- name: Test (spins a real PostgreSQL via Testcontainers)
run: dotnet test --configuration Release --no-build --logger "trx"
The quiet magic is ubuntu-latest: GitHub's runners ship with Docker
preinstalled, so the full Part 9 suite —
Testcontainers and all — runs unchanged. Every push now spins up a real
postgres:18, runs every booking, queue and timezone test against it, and
blocks the merge if anything regresses. This is the compounding payoff of the testing
investment: the tests we wrote to catch the AI's mistakes now catch ours, forever, for
free.
The meter: the whole production kit — unit, nginx config, runbook, workflow — cost about $0.60 of API spend, bringing the entire ClinicLive build to ≈ $6.20. That's the final number; the retro does the honest math on it.
Checkpoint: ClinicLive answers on a public domain behind nginx, systemd
brings it back if it falls, the board holds a real WebSocket instead of limping on
long-polling, secrets live only on the server, and CI proves every push against a real
PostgreSQL. Everything in this part is tag part-12 in
the repo.
The app is built, tested, hardened and shipped. Two parts remain — and they're about you, not the app. Next: the power tools that make AI development genuinely faster — parallel agents, custom commands, MCP — and the more valuable skill of knowing when to say no. That's Part 13: Power Moves.