~ / blog / 0x02-building-evilgeniuslabs-ca

Building EvilGeniusLabs.ca: from mockup to production in a week

Five days from domain purchase to a live site with blog, wiki, books, authenticated comments, container registry, and CI. The stack choices, the design mockup, the milestone-driven plan, and an honest accounting of how I used AI as a pair programmer to ship it.

It's 2026 and I finally built the personal site I've been putting off for a decade. The domain — evilgeniuslabs.ca — has been paid-for-but-parked since it replaced the old EvilGeniusCore brand earlier this year. Five days of focused work later, it's live, has TLS, pulls images from my own container registry, runs EF Core migrations automatically on boot, and has a blog post editor that renders Mermaid diagrams.

This post is about how that happened.

Why build this at all

The immediate trigger was prosaic: Publisher Verification for Microsoft Teams apps requires a claimed domain. I've been building Notify, a .NET CLI tool for posting to Teams channels via the Graph API, and I want it in the Teams Store. That's impossible without a verified publisher domain.

Once I committed to standing up a domain, the scope widened. A blog for technical writing. A wiki for reference material (OOP primers, long-form technical notes). Books for longer-form content that's structured into chapters and articles. Authenticated comments that aren't a dumpster fire. User profiles with Gravatar fallback. Admin moderation. Search. RSS, and all the other bells and whistles I could come up with.

Not because I want to ship a product. Because I wanted the tool I'd actually use.

Stack decisions

The default 2026 answer for "personal site" is Hugo or Astro pushing to Cloudflare Pages. I chose ASP.NET Core 10 Razor Pages running on a VPS behind nginx. Three reasons.

I write .NET daily. Tooling, debugger, deployment patterns — it's muscle memory. Writing a site in a framework I'd otherwise not touch is a recipe for "it works" followed by "I don't want to maintain this."

Content lives in Postgres. Static site generators are beautiful until you want to edit a post from your phone without opening a git client. A real database and a real editor is worth the complexity.

The VPS is 95% idle. I already run GitLab, Matrix/Synapse, Nextcloud, and a GitLab runner on it. One more .NET app alongside the existing stack costs anything.

So: Razor Pages app, EF Core 10 + Npgsql, Postgres 17, Markdig for markdown with a custom Mermaid extension, Docker on a VPS, nginx for TLS termination. Stable-boring stack underneath a cyberpunk skin.

Concern Choice
Framework ASP.NET Core 10 Razor Pages
ORM EF Core 10
Database PostgreSQL 17 (alpine)
Auth ASP.NET Core Identity + Microsoft OAuth (conditional)
Markdown Markdig + custom MermaidExtension + Prism client-side highlighting
CSS Bootstrap 5.3 as a utility layer, bespoke cyberpunk theme.css on top
Fonts VT323 + JetBrains Mono + Major Mono Display (Google Fonts)
Proxy nginx on a second VPS, TLS termination, vhost routing
Deployment Docker Compose on the app server; image built and pushed by GitLab CI

The skin started as a static mockup

Before writing a single Razor page I built a static HTML mockup. Matrix rain background, neon green on dark, glowing L-bracket panel corners, VT323 for display type, JetBrains Mono for body, Major Mono Display for eyebrow labels (all-caps, wide-tracked). Every screen — home, blog index, post, wiki page, book TOC — got painted as raw HTML/CSS first.

Decisions that got locked in at the mockup stage:

  • #39ff5f primary green, #ff4db8 secondary pink, #4df0ff cyan accent, #ffb84d amber
  • Dark-only theme (data-bs-theme="dark") — no light-mode toggle
  • Glow shadows via CSS custom properties so they can be tuned in one place
  • A tweaks panel fixed bottom-right with toggles for CRT flicker / Matrix rain / scanlines, persisted in localStorage

The mockup lives under Documentation/DesignReferences/ as a set of .html files. Later work constantly references them. "Does this look right?" gets answered by opening the mockup side-by-side with the live page.

A representative fragment of the bespoke CSS:

:root {
  --green: #39ff5f;
  --pink:  #ff4db8;
  --cyan:  #4df0ff;
  --amber: #ffb84d;
  --bg:    #05070a;
  --text:  #c8f0d0;
  --glow-green: 0 0 12px rgba(57, 255, 95, 0.55);
  --glow-pink:  0 0 14px rgba(255, 77, 184, 0.55);
  --pixel: "VT323", monospace;
  --mono:  "JetBrains Mono", monospace;
  --label: "Major Mono Display", monospace;
}

.panel--brackets {
  position: relative;
  border: 1px solid var(--border);
}
.panel--brackets::before,
.panel--brackets::after {
  content: "";
  position: absolute;
  width: 18px; height: 18px;
  border: 2px solid var(--green);
  box-shadow: var(--glow-green);
}
.panel--brackets::before { top: -1px;    left: -1px;  border-right: 0; border-bottom: 0; }
.panel--brackets::after  { bottom: -1px; right: -1px; border-left:  0; border-top:    0; }

The .panel--brackets corners are everywhere. One class, applied to any panel, gets the glowing L-bracket treatment. It was worth the ten minutes to design properly once.

The plan was written before the code

The single most useful artifact in this project is Documentation/EvilGeniusLabs.ca_Plan.md. It's a numbered list of milestones — each one a concrete deliverable with scope notes and an explicit out-of-scope section. Not a product roadmap. A coding queue.

A sample of the actual milestones:

  • M1 — Bootstrap the Razor Pages project with EF Core + Postgres
  • M2ApplicationUser + UserProfile 1:1, seed roles + admin user at startup
  • M13 — Postgres full-text search with weighted tsvector columns
  • M17@handle on user profiles, back-filled for existing accounts at startup
  • M21 — Contact form + admin inbox with honeypot + rate-limit
  • M22/About page with operator-notes narrative

Each entry calls out schema changes, migration name, UI touch points, and explicit out-of-scope bits. Writing a milestone is five minutes. Reading it later while deciding "should this be in scope" is the unlock — because the answer is right there in the plan, not in my head.

flowchart LR
  plan["Plan.md milestone"] --> scope["Scope locked"]
  scope --> schema["Schema + migration"]
  schema --> code["Code + UI"]
  code --> commit["Commit"]
  commit --> ship["Tag + CI + deploy"]
  ship --> retro["Retro note back in Plan.md"]
  retro --> next["Mark shipped · next milestone"]

When a milestone ships, it moves from ### Remaining to the shipped list with a 2–10 line retrospective — what got built, any trade-offs, anything explicitly deferred. That retro is the closest thing to project documentation I maintain.

Systematic knock-down

The loop is boring on purpose:

  1. Read the milestone.
  2. Write the migration if the schema needs one. dotnet ef migrations add, inspect the generated SQL, sanity-check.
  3. Update the domain model + EF configuration.
  4. Write the service / business logic.
  5. Wire the page / UI.
  6. Smoke-test locally.
  7. Commit with a descriptive message.
  8. When a batch reaches a coherent shippable point, tag. CI builds and pushes the image. I pull on the app server and recreate the container.
  9. Update Plan.md — milestone moves into the shipped section with its retrospective.

Commit cadence is aggressive. When git status shows more than ~5 tracked-file modifications or the working tree spans more than one logical change, I stop and commit. That rule exists for a reason — see the next section.

Mistakes, friction, and smoke tests

The build wasn't clean. Things broke. Things got rebuilt. The discipline that kept the project moving wasn't "don't make mistakes" — it was iterate fast and smoke-test manually every time a thing deployed. Every milestone, every tag, every container recreation got a curl / browser / log-tail check before I moved on.

The expensive one: lost code to uncommitted work

On 2026-04-22, I lost roughly 344 lines of code across 6 files to a single sloppy command.

What happened: I was mid-refactor on a larger change and wanted to split the work into two commits. Several files were modified, none were staged. I ran git checkout HEAD -- <files> with a list of paths I thought I didn't want — except the list didn't include all the files I actually needed to preserve. The files outside the list were silently overwritten by their committed versions. No staging, no stash, no IDE history that went back that far, no shadow copy. The work was just gone.

The root cause was not the bad checkout command — it was that I'd let uncommitted work pile up across a dozen files while "almost done" with a bigger task. If I had committed every coherent chunk as I went, the worst case of any destructive command would have been "lose five minutes of work." Instead I lost an entire afternoon.

I rebuilt what I could from plan-faithful memory. The rebuilt code works, but it's not identical to what was there — and I'll never know the diff. That's a uniquely irritating class of loss.

The rule that came out of it, which is now in my global operator instructions:

Before any operation that touches the working tree destructively (git checkout HEAD -- ..., git reset --hard, git clean, git restore --worktree, rm on tracked files), the current state must be recoverable: either committed, stashed with git stash push -u, or fully backed up outside the repo. Never trust a "surgical" subset of files to be safe without verifying ALL modified content is preserved.

git stash -u is the cheap default when in doubt — it costs nothing and saves the whole working tree atomically.

Commits, stashes, and backups are free. Lost work is not. When reasoning about whether to commit/stash/backup, the answer is almost always yes.

If you're reading this thinking "yeah but I'm careful" — so was I. This rule exists because carefulness at 11pm on a Tuesday, after six hours of work, with one more thing to finish before bed, is not reliable. Muscle memory is. The only muscle memory worth having is commit.

Deployment stumbles that smoke tests caught

Past the first one, the rest were ordinary deployment friction — the kind of thing you only catch because you're actually hitting the system after each change.

nginx loading from the wrong directory. I put the new vhost in /etc/nginx/sites-enabled/ — where nginx conventionally looks — reloaded, and the site still returned the default redirect. nginx -t passed. I wasted twenty minutes before running grep -r "^\s*include " /home/clp/services/nginx/nginx.conf and realising CloudPanel's nginx loads from /home/clp/services/nginx/sites-enabled/. Systemd nginx was inactive on that box. I'd been editing files that were never being read.

Port binding that forgot about two hosts. The compose file initially bound the app to 127.0.0.1:8091 — fine for single-host setups. The nginx proxy is on a separate box, so loopback-only meant the proxy could never reach the backend. curl https://evilgeniuslabs.ca/ hung until timeout. The fix was a one-line change to bind on all interfaces and let the VPS firewall restrict access. Caught it in the first browser hit after deploy.

Docker healthcheck failing on Host header. My HEALTHCHECK ran wget http://127.0.0.1:8080/healthz inside the container. The container reported (unhealthy). Kestrel's logs showed 400 Bad Request - Invalid Hostname because appsettings.Production.json had AllowedHosts: "evilgeniuslabs.ca;www.evilgeniuslabs.ca". The healthcheck's default Host: 127.0.0.1:8080 wasn't on the list. Fix: pass --header="Host: evilgeniuslabs.ca" in the healthcheck command. Caught by looking at docker inspect --format '{{json .State.Health}}' the first time the container went unhealthy.

MapStaticAssets doesn't serve runtime uploads. .NET 9+ introduced app.MapStaticAssets() — endpoint-based static serving that's faster than the traditional middleware. It only serves files baked into a build-time manifest. User uploads to wwwroot/uploads/ at runtime aren't in the manifest, so the avatar I'd just uploaded returned 404 from Kestrel even though the file was on disk. Fix: add a scoped app.UseStaticFiles() rooted at the uploads directory. Caught the same minute I uploaded the first post image.

Docker volume created with wrong ownership. A named Docker volume mounted at /app/wwwroot/uploads was auto-created as root:root because that directory didn't exist in the image at build time. The app runs as non-root app user (UID 1000) and couldn't create the /uploads/content/2026/04/ subfolder. An UnauthorizedAccessException with a stack trace from ExceptionHandlerMiddleware made it instant. Fix: pre-create the uploads directory in the Dockerfile with app:app ownership so the volume inherits it on first mount.

Deploy token with the wrong scope. Set up a GitLab deploy token for the app server to pull images. Login succeeded, pull got access forbidden. The token had read_repository (git pulls) instead of read_registry (container pulls). They're adjacent in the UI and easy to mis-click. Caught it in the first docker compose pull after the token was set up.

The shape of the loop

The common thread: every one of these was caught in the first manual smoke test after the change that introduced it. Not by CI. Not by unit tests (there aren't any yet — see the deferred list). By me, hitting the thing with curl or a browser, reading the logs, seeing the failure, fixing it, rolling a new tag.

That loop looks like this:

flowchart LR
  change["Make change"] --> commit["Commit + tag"]
  commit --> ci["CI builds image"]
  ci --> deploy["Pull + recreate container"]
  deploy --> smoke{"Manual smoke test:<br/>curl, browser, logs"}
  smoke -->|broken| diag["Diagnose from logs"]
  diag --> change
  smoke -->|working| next["Next milestone"]

Each cycle is five to fifteen minutes. Five shipped tags went out in the first day of the site being publicly reachable — v0.1.0 through v0.1.4 — each one triggered by something I saw while using the site or watching the logs. Every tag had a reason. Every reason came from actually using the thing. Smoke testing manually after each deploy isn't glamorous, but it's the only thing that matches the iteration speed of AI-assisted code generation. If you're shipping five commits an hour, you need to notice what breaks within five minutes of shipping it, or you'll carry regressions for days.

Tests would catch some of this earlier. Tests don't exist yet (I call that out my dumb ass for that in the workplan. I had great intentions but the need to build pushed themt o the side). Manual smoke testing is what fills that gap until they do.

Using AI in the loop

I'll be direct: I used Claude Code (Anthropic's CLI) as a pair programmer for a large chunk of the code in this repo. Not hidden, not apologetic. Here's what that actually looks like.

I'm on point for:

  • Architecture. What the tables are. How the auth flow works. Which content types get comments (posts yes, wiki pages yes, books yes, articles yes, chapters no — chapters are grouping containers, not reading surfaces). The milestone list itself.
  • Design decisions. The skin, the UX patterns, the trade-offs between "easy" and "correct."
  • Reviewing every diff. Claude writes code that compiles and passes smoke tests, but "compiles" isn't "does the right thing." Diffs get read line by line; over-engineering gets ripped out; unfamiliar patterns get questioned before they land.
  • Production operations. SSH sessions, nginx vhosts, Postgres CHECK constraints, the moment the deploy hangs at a 502.

Claude Code is excellent at:

  • DTOs and view models. Record types for revision entries, paged-list view models, admin table row projections — once the shape is clear, generating them plus the associated EF .Select(...) projections is minutes, not hours.
  • EF migrations. The weighted tsvector search column above, with HasComputedColumnSql + stored generation + GIN index — writing that from memory takes time. Writing it as "give me an EF Core fluent config that creates a stored computed tsvector weighting title A, excerpt B, body C, with a GIN index" takes one prompt.
  • Razor scaffolding. Copy-paste-adapt from one page to another while preserving consistent patterns for accessibility, error handling, and form validation. When six pages need the same kind of paged table with filter chips, automating the mechanical part is a win.
  • Infrastructure scripting. Bash one-liners, docker-compose edits, certbot invocations, SSH debugging. The container registry came online through a lot of "here's what I see, what do you think."

Claude Code is not good at:

  • Deciding whether a thing should exist. Scope has to be imposed from outside the model. Left to its own devices it will keep suggesting features.
  • Non-obvious naming. "History chip that's also the disclosure toggle for the comment's edit history" — a design decision I had to state first. AI will happily call that CommentHistoryButtonComponent forever.
  • Understanding full blast radius. A DB migration that alters a column used by three services still needs a human to trace the consequences. So does a port change in a compose file that's referenced in an nginx proxy_pass.

On the topics where it's genuinely useful, using it is a force multiplier. Pretending otherwise while shipping the output would be dishonest.

AI isn't bad. It's a multiplier — and multipliers work both ways

The reflexive "AI-assisted code is low quality" take is lazy. What's true is subtler: AI amplifies whoever's holding it. A seasoned developer with thirty years of scars knows when the generated code is subtly wrong — when a null check should be a guard clause, when a cascade delete is going to bite them in production, when a "simple refactor" is about to destroy an invariant the type system doesn't capture. In those hands, a good model produces excellent code faster than the developer could write it solo. Multiplier is positive. Output is better than what they'd ship alone, because the model catches things they'd skip and they catch things the model confidently generates wrong.

Put the same model in the hands of someone who doesn't have the reference frame — someone learning, or someone faking seniority — and the multiplier flips sign. The model produces code that looks right: idiomatic names, sensible structure, passing tests. It's also, sometimes, wrong in ways that take years of experience to see. The user can't tell. They ship it. The bug surfaces six months later under load, or during a migration, or in a security audit. The model didn't introduce the bug — the user's inability to evaluate what the model produced did. But the effect is the same: bad code, shipped with confidence.

The takeaway isn't "don't use AI." It's know what you're evaluating and evaluate it. If you can't read a generated migration and tell whether the cascade behaviour is what you want, don't generate migrations yet — learn EF first. If you can't look at a generated Razor form and spot the missing anti-forgery token or the XSS vector in the @Html.Raw, don't use AI for form scaffolding yet — learn the framework first. The tool is neutral. The operator is not.

I've been writing code since the Commodore Vic-20 in the early eighties. I know what bad C# looks like. That's why this project shipped in five days instead of five weeks, and why I'm comfortable attaching my name to every line regardless of who typed it first.

The search milestone (M13) is a good example of "small in code, large in effect." Blog posts, wiki pages, and book articles all want full-text search that weights titles heavier than body content. Postgres has exactly this feature via tsvector columns, weighted to_tsvector fragments, and GIN indexes. EF Core lets you declare it as a generated column that Postgres maintains automatically — no service code needs to keep it in sync.

The EF configuration is the whole milestone's core decision:

builder.Entity<Post>(b =>
{
    b.Property(p => p.SearchVector)
     .HasColumnType("tsvector")
     .HasComputedColumnSql(
         "setweight(to_tsvector('english', coalesce(\"Title\",        '')), 'A') || " +
         "setweight(to_tsvector('english', coalesce(\"Excerpt\",      '')), 'B') || " +
         "setweight(to_tsvector('english', coalesce(\"BodyMarkdown\", '')), 'C')",
         stored: true);

    b.HasIndex(p => p.SearchVector).HasMethod("gin");
});

Three things are happening:

  1. setweight(..., 'A' | 'B' | 'C') — Postgres' tsvector ranking supports four weight buckets. Title matches score highest, excerpt matches medium, body matches lowest. ts_rank_cd uses those weights when we rank results.
  2. stored: true — the generated value is persisted on the row, not computed per-query. Writes do the tokenisation work once; reads are cheap.
  3. GIN indexHasMethod("gin") translates to CREATE INDEX ... USING gin (...), which is the right index type for tsvector columns. B-trees won't work for this.

Querying is a one-liner with EF.Functions.WebSearchToTsQuery, which accepts Google-style input: "cyberpunk -bootstrap" (require "cyberpunk", exclude "bootstrap") or "css OR theme" works without writing custom parsers. Wiki pages and book articles have the same treatment — one search endpoint, three content types, weighted by where the match landed.

None of this code is novel — tsvector + GIN + websearch_to_tsquery is the canonical Postgres search recipe. What the milestone bought was deciding on the weights, writing the HasComputedColumnSql fragment once, and confirming with the admin search bar that queries actually ranked sensibly. About 40 minutes total. Most of that was picking whether "excerpt" deserved B-weight or C-weight. (It's B.)

Deployment pipeline

Pushing a git tag triggers a GitLab CI build-image job that builds the Dockerfile, pushes to my own registry, and tags both :v0.x.y and :latest. The app server pulls :latest when I say so; migrations auto-apply on container start under a Postgres advisory lock so concurrent starts don't race.

flowchart TB
  subgraph dev["Dev"]
    tag["git tag v0.x.y<br/>git push --tags"]
  end
  subgraph ci["GitLab CI"]
    build["docker build"]
    push["docker push"]
  end
  subgraph reg["gitlab"]
    image["image:latest + :vX.Y.Z"]
  end
  subgraph app["App server"]
    pull["docker compose pull"]
    compose["docker compose up -d"]
    migrate["EF migrations auto-apply<br/>(advisory lock)"]
    health["HEALTHCHECK /healthz"]
  end
  subgraph proxy["nginx proxy"]
    tls["TLS termination<br/>Let's Encrypt"]
  end

  tag --> build --> push --> image
  image --> pull --> compose --> migrate --> health
  users((Browser)) --> tls --> app

Every piece of this chain is a single-commit-worth of config. The Dockerfile was one commit. The compose file was another. The nginx vhost for evilgeniuslabs.ca took two iterations to get right — the first attempt put the file in /etc/nginx/sites-enabled/ but the live nginx loads from /home/clp/services/nginx/sites-enabled/ (a CloudPanel quirk). Cost me twenty minutes of "why isn't this vhost loading." The fix was obvious once I ran grep -r "^\s*include " /home/clp/services/nginx/nginx.conf and realized I'd been editing the wrong path.

Each shipped tag is documented in the shipped-milestones section of the plan:

  • v0.1.0 — initial pipeline (CI infra fail: registry wasn't up yet)
  • v0.1.1 — drop redundant adduser (base image already has UID 1000 app)
  • v0.1.2 — healthcheck Host header + all-interfaces port binding
  • v0.1.3 — pre-create /app/wwwroot/uploads for correct volume ownership
  • v0.1.4UseStaticFiles for /uploads/* (MapStaticAssets doesn't cover runtime files)

Five bug-fix patches in the first 24 hours of having the site reachable from the public internet. Each one is a tiny commit with a clear reason. None of them blocked anything — the site kept serving while I iterated.

What's next

The ### Remaining section of the plan is the most honest roadmap. Currently sitting there, in the order they're likely to land:

  • M23 — Comment mentions + notifications. Right now a comment goes into the database and nobody hears about it. This milestone covers @handle mention parsing, notifications on reply, and email dispatch for thread participants. Blocked on the email gateway being wired to a real SMTP relay — the logging-only fallback isn't useful for a live site.
  • M24 — Backup + notify on the VPS. In progress. rsync-based nightly backup of the Postgres dumps and the uploads volume to off-host storage, with a Teams-channel ping via Notify on success/failure. The backend exists; wiring the schedule is the remaining work.
  • M25 — Write some tests, you lazy bastard. Literally the milestone title. I've been winging it on smoke tests and careful migrations; that scales to exactly the point where it doesn't. xUnit + Testcontainers for Postgres is the probable shape — real database, ephemeral per test run, no mock-the-universe regret later.
  • M26 — Polymorphic comments on wiki pages, books, and book articles. Today, comments only hang off blog posts. Extending the Comment table with nullable WikiPageId / BookId / ArticleId FKs guarded by a num_nonnulls() = 1 CHECK constraint, plus a shared _CommentsSection.cshtml partial that every host page can include. This milestone is sketched in enough detail in the plan that it's ready to pick up; the CommentReport and CommentRevision tables already key off CommentId alone so they need no changes.

Further out, mostly on the deferred list:

  • RSS feed + sitemap + OpenGraph metadata. Low cost, expected on a dev blog.
  • Dedicated Tag management UI — rename, merge, color reassignment. Right now tags are created inline from the post editor.
  • R2 / B2 media storage swap-in behind IAvatarStorage / IMediaStorage — the interfaces already exist.
  • Pandoc export for books — .epub / .pdf straight from the content model.
  • EvilGeniusLabs rebrand cleanup across other repos that still reference the old EvilGeniusCore name.

Future blog posts will be one per milestone as they ship — how the email gateway was wired, what the backup cadence looks like, what broke when I tried to write the first real integration test. That's the shape of this blog: build, then write.

The meta-point

Five days is a short time to ship from nothing to a site with blog, wiki, books, comments, moderation, auth, and a container registry. It happened because:

  1. The scope was ruthlessly planned. Each milestone had a clear deliverable and explicit out-of-scope bits.
  2. Commits stayed tight. No ten-file working trees; no "let me clean this up later."
  3. I used AI honestly. For boilerplate, scaffolding, and recall of syntax I don't need to carry in my head.
  4. I retained architectural judgement. Every diff got reviewed. Every migration got sanity-checked. Every deploy was watched.

If you're building something similar: write the plan first. Keep commits small. Be honest about your tools. Ship.

— EG_

// comments

0 ENTRIES
// sign in to comment