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=Production plus missing HTTPS forwarding — the X-Forwarded-Proto line in nginx fixes it.
  • Times look shifted? Set Clinic:TimeZone on 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.