Chapter 01 — Prerequisites: every term you need, and where it will bite you
This chapter is a map, not a textbook. Every term below gets the same treatment: what it is in plain words, where it lives in FoxyInvoice, and — because this series made an honesty contract with you — the real incident that shows why you should care. You don't need to master any of this before starting. You need to recognize each drawer when you have to open it.
We'll cover six territories: Git, UI vs UX, databases, HTTP & APIs, Docker, and DNS & TLS. In roughly the order they'll hurt you.
1. Git: time travel for a team of one
What it is. Git stores your project's history as a chain of snapshots ("commits"). At any moment you can see what changed, who changed it, why (the commit message), and go back. GitHub is the cloud copy of that history — and, in our setup, the trigger for deployment.
Where it lives here. Everywhere. The rule that shapes all discipline
in this repo: pushing to main IS a production deploy. There is no
staging server, no release manager. git push → tests → security gates
→ eight minutes later it's live. That means the commit log is not
bureaucracy; it's the changelog and the audit trail of the product.
The vocabulary you need:
- repository (repo) — the project + its history. This app is one
repo containing backend (
src/), frontend (web/), deploy config (deploy/), and CI (.github/workflows/). - clone / pull — get the repo / get the latest.
- commit — one snapshot with a message. The convention here:
feat:,fix:,migration:,docs:prefixes, written like sentences to the future ("fix: Edit invoice loads data — route input bound after constructor"). - branch — a parallel line of work. In a team you'd branch per
feature and merge via pull request. Solo with auto-deploy, this repo
mostly commits straight to
main— but branches saved us during the QA-harness work running in parallel with shipping. - merge conflict — two lines changed the same line of text. Git refuses to guess; you pick. Scary the first time, routine by the fifth.
- tag — a named point in history (
blog-v1.0). We tag the blog's milestones so every published revision is recoverable. git revert— a commit that undoes a commit. Prefer it over hand-editing "the fix" back in: history stays true, and history is the story.
The incident that teaches it. During one shipping sprint, two lines
of work ended up on the same repo — one committing to a feature branch,
one shipping through temporary worktrees to main. The branches
diverged; the same file existed in two versions with different line
endings, and a push of "everything" nearly shipped stale dependency
files alongside new features. Recovery meant diffing trees
(git diff --ignore-cr-at-eol — line-ending noise is real) and
extracting only the true changes. The lesson: commit small, commit
often, one logical change per commit. When every commit does one
thing, you can cherry-pick, revert, and reason. When commits do five
things, you can't.
One more git habit that pays rent: a strict .gitignore plus an
automated secret scanner in CI. Sooner or later you will paste an API
key into a config file. The gate that catches it before the public repo
does is the cheapest insurance in this whole series.
2. UI vs UX: the screen is not the journey
What they are. UI (user interface) is what's on the screen: buttons, spacing, color, typography. UX (user experience) is whether the person succeeds: did they understand what to do, did it work, did they feel good afterward, did they come back? A beautiful UI with a broken journey fails. An ugly UI that gets someone paid wins.
Where it lives here. The UI system is Angular Material — a component library of pre-built, accessible buttons, tables, form fields (more on "why a component library" in Chapter 03). The UX is a thousand decisions, three of which are worth showing you because they're all real, and two of them were bugs reported by a real user:
The duplicate-invoice bug (pure UX). After saving an invoice, the page stayed open with its button re-enabled. Users double-clicked — "invoice saved" but now there are three identical invoices. The UI was fine; the journey was broken. The fix was UX surgery: disable the button during the save flight, then navigate away to the list with a confirmation. The user's report said it perfectly: "the message is displayed as invoice saved, but the page does not get closed automatically." Listen to users like that; they're doing your UX audit for free.
Progressive disclosure. The app has a "simple view" navigation — Clients, Invoices, Payments, Settings — with everything else folded behind an "Advanced tools" button. First-time freelancers see five options, not forty. That's a UX principle (don't show what the user doesn't need yet) expressed as a UI arrangement.
The frozen preview (technical bug, UX symptom). The invoice editor shows live totals as you type. One day the preview froze at its first value: tax and totals didn't update as line items changed. The UI displayed fine; the experience of the feature was dead. Root cause was a re-system subtlety (a computed value tracking something it couldn't see). Users don't report "your reactivity graph is wrong" — they report "the numbers are wrong." Diagnose UX reports down to the technical layer.
The rule to keep: UI is what they see; UX is what they achieve. You will spend roughly ten times more effort on the second one, and most of that after launch, guided by feedback (Chapter 10 is that entire loop).
3. Databases: one table, one truth
What it is. A relational database (we use PostgreSQL) stores
your application's facts as rows in tables with defined
columns. A primary key uniquely identifies each row (here, a
UUID); a foreign key says "this row belongs to that one" (every
invoice points at a client; every client points at a tenant/workspace).
Indexes make lookups fast and — just as important — can enforce
business rules: the export_profiles table has a unique index on
tenant_id, so "one tax profile per workspace" is guaranteed by the
database, not by hopeful application code.
The five-paragraph schema tour. tenants (workspaces) → users
(people, attached to tenants) → clients (your customers) → invoices
(header: number, dates, status) → invoice_line_items (qty × price
rows). Everything else — payments, reminders, feedback, audit — hangs
off that spine.
Lessons this repo paid full price for:
Money is
DECIMAL(18,2), never a float. Binary floats cannot represent0.10exactly — the error is tiny, but accountants add thousands of numbers and notice. This app stores every amount as a decimal pair(amount, currency)all the way down; the type system refuses to add dollars to euros (aMoney.Addacross currencies throws — which once surfaced as a cryptic 500 we'll dissect in Chapter 13).Migrations are code, and code that doesn't run doesn't exist. A database migration is a versioned script that evolves the schema (create table, add column). One was committed to the repo without its registration file — so the migration engine never saw it, the table never got created in production, and the feature returned 500s for every user. The fix taught rule 3.
Never change production schema by hand. Twice, a column was added directly in
psqlon the live database. Both times, the next deploy's proper migration collided with the hand-made change and crash-looped the API at startup — a billing app, down, loudly. The surviving rule: migrations must be idempotent where reality is messy (ADD COLUMN IF NOT EXISTS), and the hand-edit is a last resort you document immediately.An ORM is a lever with traps. An ORM (object-relational mapper — here, Entity Framework Core) lets application code speak in objects while it writes the SQL. Its best trick in this app: global query filters that automatically scope every query to the current tenant (Chapter 04's isolation story). Its worst trap, also real: a repository method loaded an invoice without its line items, so "edit line" returned 404 and "add line" silently recomputed totals against an empty set — data corruption wearing a boring bug's clothes.
Connection pools run out. Postgres defaults to 100 connections; two application pools (API + worker, twice over for two stacks) can exhaust that under burst. Ours is set to 200 with a comment in the docs explaining why. Infrastructure defaults are decisions someone else made for a generic workload; your job is to notice which ones were wrong for yours.
4. HTTP & APIs: the conversation format
What it is. Every interaction between the browser app and the
server is an HTTP request/response: the browser asks (a verb
plus a URL plus usually a JSON body), the server answers with a
status code plus JSON. That's the whole ceremony. "REST" is the
set of habits around making those URLs sensible:
GET /api/v1/invoices (list), POST /api/v1/invoices (create),
PUT /api/v1/invoices/{id} (update), DELETE for the obvious.
The status codes you'll actually meet — each with a real endpoint from this app:
| Code | Meaning | Real example |
|---|---|---|
| 200 | OK | GET /healthz — the smoke test |
| 201 | Created | POST /api/v1/invoices — a new invoice exists |
| 400 | Your request was malformed | posting an invalid email to /public/template-drafts |
| 401 | Not authenticated | calling /template-drafts/convert with no token |
| 404 | Not found | opening a shared invoice link with a bad token |
| 409 | Conflict | accepting a quote that was already handled — one-shot operations say "no" this way |
| 500 | Our fault | the crash-loop era; the server logs carry the stack trace |
Learning to want the right code is a skill: 404-vs-409 is the difference between "gone" and "already did that," and clients behave better when you're precise.
Authentication, in one paragraph. After login, the server hands the
browser a JWT (JSON Web Token): a signed ticket containing the
user's id, tenant, and permissions. The browser shows it with every
request (Authorization: Bearer …); the server verifies the signature
— no session table lookup needed. Long sessions use a second,
rotating refresh token. The vocabulary: claims (facts inside the
ticket), expiry (tickets are short-lived on purpose), scopes (what
the ticket allows — ours carry permission names like
invoice:create).
Two patterns worth stealing:
- Rate limiting — public endpoints (feedback, template capture) sit behind per-IP buckets. The internet will script your forms; make scripting expensive.
- The share token — for the public invoice view and quote acceptance, the URL contains a 32-byte unguessable token, and the token is the entire authorization. No login for the client, no data leak (it scopes to exactly one document), expires on a clock.
5. Docker: "works on my machine" dies here
What it is. A Docker image is a blueprint (built from a
Dockerfile: "start from this OS, install these dependencies, copy this
code, run this command"). A container is a running instance of that
image — isolated processes with their own filesystem. Docker
Compose describes a whole stack (database, API, worker, proxy) in one
YAML file so docker compose up reproduces it identically anywhere.
Where it lives here. One modest VPS runs, in containers: two
Postgres instances, two .NET APIs, two workers, and a Caddy reverse
proxy — the freemium stack and the enterprise stack side by side,
sharing nothing but the machine. They find each other by container
name on a shared Docker network (caddy proxies to
fox-api:8080 — that name is load-bearing). Database files live on a
volume (pgdata), which is why you can rebuild containers all day
and the data doesn't move.
The three lessons this stack paid for:
Environment files are credentials with a hat on. The freemium stack's compose command must include
-p fox --env-file .env.freemium. Run it without the env file once, and compose interpolates the other stack's database credentials into the container, which then crash-loops on auth failure. Config-by- environment ("12-factor") is great; ambiguous defaults are not.Secrets never go in layers. The API image restores private NuGet packages, which needs a token. The Dockerfile declares a BuildKit secret: the token is mounted during the build step and evaporates — never baked into the image or its history, where anyone with the image could read it.
A masked failure is worse than a failure. The deploy script once ran the image build as
build … || echo "WARNING: build failed (using cached image)"and then started containers withup -d --no-build. The build failed (missing token), the script shrugged, and the deploy "succeeded" — shipping eight-hour-old containers while the pipeline showed green. Failures must be allowed to fail. That one line of shell is the most expensive fourteen words in this chapter.
6. DNS & TLS: how a name becomes a page
The chain, in order. You type foxyinvoice.com → DNS (the
internet's phone book) resolves the name to the server's IP via an
A record → your browser connects on port 443 → TLS handshake:
the server presents a certificate (signed proof that this server is
entitled to this name) → encrypted HTTP flows inside. That certificate
is issued by Let's Encrypt, renewed automatically by Caddy — no cron
jobs, no expiry surprises. This is "auto-TLS," and it's why we run
Caddy at the edge.
Record types you'll touch: A (name → IP), MX (where mail for the domain goes — ours route through Cloudflare Email Routing, which is its own Chapter 06 saga), TXT (free-form text, used for ownership proofs like Search Console verification).
A real subtlety: for the TLS certificate to be issued, the certificate authority must reach our server directly — so the DNS records are "grey-clouded" (DNS-only) rather than proxied through Cloudflare's orange cloud. One toggle in a dashboard, decided by understanding what the handshake needs.
And one forward pointer: the edge is also where crawler hygiene
lives. Before our app executes a single line, Caddy has already decided
— per URL — whether the response carries X-Robots-Tag: noindex or is
allowed into search indexes. The public marketing pages are on an
explicit allowlist; everything else (your invoices, the admin console,
/upgrade) tells crawlers to stay away. Security and discoverability
both start at the front door.
The minimum setup
You can follow this entire series with:
- Git and a GitHub account (free).
- VS Code (or any editor) and a terminal you don't fear.
- Docker Desktop — even if you never write a Dockerfile, running the stack is one command.
- Node.js LTS and the .NET SDK — only if you'll modify code, not if you're reading.
- The willingness to read an error message top to bottom before panicking. Stack traces are biographies, not insults.
flowchart LR
subgraph Terms["What you now know"]
GIT["Git
history & deploys"]
UIUX["UI / UX
screen vs journey"]
DB["Databases
tables, money, migrations"]
HTTP["HTTP & APIs
verbs, codes, tokens"]
DOCKER["Docker
images, compose, secrets"]
DNS["DNS & TLS
names, certs, the edge"]
end
subgraph Bites["Where each one bites"]
DEPLOY["the deploy pipeline"]
USERS["real users & feedback"]
DATA["data integrity"]
EDGE["the front door"]
end
GIT --> DEPLOY
DOCKER --> DEPLOY
DNS --> EDGE
UIUX --> USERS
HTTP --> USERS
DB --> DATA
Every one of those arrows has a scar somewhere in this repo's history — and each scar gets its full story in the chapters ahead.
Next: Chapter 02 — Product thinking: pick a real problem, price it, position it