ALICE · LEARN CHARLIEHUB

Mail club · harvest post the one with a working backend enclosed

Reference · /learn/

ISSUE 02

Backend, frontend, APIs

Four words that get used interchangeably and shouldn't. The clearest way to learn what a backend is turns out to be looking closely at a site that doesn't have one — this one.

Where this is going: Part A pins down the vocabulary against a concrete contrast case. Part B builds the smallest thing that needs all of it — a guestbook: a form, an API, a database table, and a list that comes back from the server.

FastAPI + Postgres 16 text-only by design no auth · personal port 8000
Stickers
  • data & content
  • writing code
  • documentation
  • the database
  • an API endpoint
  • worth noting
  • the split
  • a small build
  • wiring it up

PART A — THE WORDS

01Part A

What this site is right now: static

Every page you have ever loaded from this domain already existed as a file before you asked for it.

The whole serving side of this site is one command:

hello-web.servicethe entire server
$ python3 -m http.server 8000 --bind 0.0.0.0

That process does exactly one thing. A request arrives for /tokyo-field-card.html; it looks for a file at that path under ~/projects/hello-web/site/; if the file is there it sends the bytes, and if it isn't it sends a 404. It has no idea what a trip is, what a day is, or who you are. It is a very literal file cupboard.

Which means every interesting thing about this site happened earlier, when build.py ran on this machine and wrote those files to disk. By the time anyone visits, all the work is finished. Nothing is worked out per visitor.

STATIC — WHAT THIS SITE DOES NOW browser GET http.server no code runs file on disk already written same bytes for everyone DYNAMIC — WHAT A BACKEND ADDS browser POST FastAPI your code runs Postgres state that changes answer computed per request
The difference is not the browser and not the network. It is whether any code of yours runs at the moment the request arrives.
Why static is genuinely good

Static is not a lesser thing to be upgraded away from. It is astonishingly fast, it cannot be hacked through a login form it doesn't have, it cannot lose data it doesn't store, and it survives being copied to a USB stick. The trip cards should stay static forever. The question is never “should this be dynamic” in general — it is “does this specific feature need something a file can't do”.

Section Review Card

no. 01 of 07
Concept:
Nothing of mine runs at request time — the file already exists.
Key file:
~/.config/systemd/user/hello-web.service
One thing I actually understood today:
Rating:

02Part A

What a backend actually is

Code that runs at request time, on a machine you control, and can therefore give a different answer each time it is asked.

That is the entire definition, and everything else follows from it. A static file gives the same bytes to every visitor forever. A backend is a running program that receives the request and decides what to send back.

Having code run at that moment unlocks things a file categorically cannot do:

The one that catches people

The last two are not stylistic preferences. Anything the browser can see, a visitor can see and change. A length limit in JavaScript stops typos, not malice. Validation in the browser is a nicety; validation on the server is the real thing. The guestbook below does both, and only the second one counts.

Two things a backend is not. It is not “the hard part” — the guestbook backend is about sixty lines. And it is not a place in the world: “the backend” is just a process listening on a port, which on this site will be a process on this same container, on the same port 8000 the static files use today.

Section Review Card

no. 02 of 07
Concept:
Code that runs at the moment the request arrives, and can answer differently.
Key file:
the process holding port 8000
One thing I actually understood today:
Rating:

03Part A

What an API is

The published list of things you are allowed to ask the backend, and the exact shape of the answer.

If the backend is a program that answers questions, the API is the menu. It is a contract with three parts: which URLs exist, what you must send, and what comes back. The guestbook's API is two lines long:

Method & pathYou sendYou get back
GET /api/guestbook nothing a JSON list of entries, newest first
POST /api/guestbook JSON: {name, message} the created entry, with its id and posted_at

Two details in that table are the whole idea. First, the method matters as much as the path: same URL, different verb, completely different operation. GET means “give me” and changes nothing; POST means “here is something new”. Second, both sides are JSON — not HTML. The API returns data, not a page.

GET /api/guestbookthe response is data, not markup
[
  {
    "id": 3,
    "name": "Alice",
    "message": "First one that actually saved.",
    "posted_at": "2026-08-19T18:04:11.912+00:00"
  },
  { /* ... */ }
]

That distinction is what makes an API useful. Because the answer is data rather than a finished page, the same endpoint serves the form on this page, a curl command in the terminal, a Python script, or an app that doesn't exist yet. None of them need to know how the others display it.

Naming

The /api/ prefix is a convention, not a requirement. It earns its keep here for a very practical reason: it gives a clean rule for splitting traffic. Anything starting /api/ is answered by code; everything else is a file on disk. That rule is what makes the infrastructure change in Part B a small one.

Section Review Card

no. 03 of 07
Concept:
The published list of things I may ask, and the shape of the answer.
Key file:
GET + POST /api/guestbook
One thing I actually understood today:
Rating:

04Part A

What a database adds that a JSON file can't

This site already stores structured data in JSON files and it works fine. So what is Postgres for?

The honest answer is that data/*.json is the right tool for the trip cards and the wrong tool for a guestbook, and the reasons are specific:

concurrent writes

Two people submit at the same instant. The read-modify-write dance on a JSON file — load the list, append, write the whole file back — means one of them silently overwrites the other. A database handles simultaneous writers as its core job; that is what transactions are.

querying

With a file, “the twenty newest entries” means loading all of them into memory and sorting. With SQL it is ORDER BY posted_at DESC LIMIT 20, and the database does the work — without reading everything, once there is an index.

growing data

A JSON file is rewritten whole every time. That is fine at 30 KB and painful at 30 MB. A database appends a row.

enforced structure

The table declares that name is text and cannot be null, and id is unique. Those rules are enforced by the database itself, so a bug in the application cannot write a malformed row. JSON will cheerfully store anything.

crash safety

A process killed halfway through rewriting a JSON file leaves a truncated, unparseable file — all the data, gone. A database write either completed or it didn't.

Line them up and the pattern is clear. Every one of those is about data that arrives while the program is running. The trip JSON has none of those problems because it is edited by one person, in an editor, between builds — there is no concurrency, no growth, no crash mid-write.

The rule of thumb

Data you author before the build belongs in a file, in version control, where you can diff it. Data the world submits while the program runs belongs in a database. The guestbook is the second kind, which is exactly why it is the right first project.

The table is four columns:

psql — database: alice
CREATE TABLE IF NOT EXISTS guestbook (
  id         serial       PRIMARY KEY,
  name       text         NOT NULL,
  message    text         NOT NULL,
  posted_at  timestamptz  NOT NULL DEFAULT now()
);

Three of those four are things the database gives you rather than things the application sends. serial makes Postgres allocate the next unused id itself — no counter to keep, and no chance of two rows colliding. DEFAULT now() means the timestamp is the server's, not the browser's, so it cannot be wrong or faked. timestamptz stores the instant with its timezone rather than a naive wall-clock reading, which is the difference between a time that survives a trip to Japan and one that doesn't. The application only ever supplies name and message.

Section Review Card

no. 04 of 07
Concept:
Files for data I author. A database for data the world submits.
Key file:
table guestbook (database: alice)
One thing I actually understood today:
Rating:

05Part A

Frontend and backend as a real split

Not two job titles. Two machines, two languages, and a network between them that can fail.

The words sound like jargon until you notice they describe a genuine physical boundary:

FrontendBackend
Runs onthe visitor's laptopthis container
Written inHTML, CSS, JavaScriptanything — here, Python
Who can change itthe visitor, freelyonly you
Can seeonly what you sent itthe database, the filesystem, secrets
Fails bylosing the network, old browsercrashing, DB down
You trust itneveryes

The bottom row is the one worth internalising. The frontend runs on a computer belonging to someone else, who can edit it, disable it, or skip it entirely and talk to your API directly with curl. This is not paranoia about attackers — it is just true, and it decides where rules have to live.

The second thing the split forces is that the network in between can fail. In a single Python script, calling a function either returns or raises. Across a network there is a third outcome: no answer at all, indefinitely. Every frontend that talks to an API needs a visible state for “asked, still waiting” and another for “that didn't work”. The demo below has both, which is why it has a status pill in its corner.

Where this site sits

Right now this site is all frontend. The trip cards' clever bits — the live weather, the “now” marker, offline mode — run entirely in the browser against public APIs. There has never been a backend of yours in the picture. The guestbook is the first thing that puts one there.

Section Review Card

no. 05 of 07
Concept:
Two machines with a network between them. Never trust the front one.
Key file:
the “you trust it” row
One thing I actually understood today:
Rating:

PART B — THE BUILD

06Part B

The guestbook

Deliberately the smallest thing that still needs every piece: a form, an API, a table, and a list that came from the server.

Text only, no accounts, no editing, no deleting. Every one of those omissions is on purpose — the goal is to see the full loop close once, cleanly, without a single distraction. Once submitting a name and watching it come back from Postgres feels ordinary, adding to it is easy. Building it all at once is how you end up debugging four things simultaneously and learning none of them.

Guestbook, from scratch

serves one · about sixty lines · text only

Ingredients

  • 1 FastAPI app — ~/projects/guestbook/app.py
  • 1 Postgres 16 table, four columns
  • 2 endpoints — GET and POST /api/guestbook
  • 1 Pydantic model, for the rules
  • 1 systemd user unit, on port 8000
  • the old hello-web.service, kept — that is the way back

Method

  1. Create the table. Four columns, three of which Postgres fills in itself — serial, DEFAULT now() and timestamptz mean the app only ever supplies a name and a message.
  2. Declare the contract as a Pydantic model: name 1–60, message 1–500. Anything that doesn't fit is refused with a 422 before your handler runs.
  3. Write the two endpoints. GET selects newest-first; POST inserts and returns the finished row, id and timestamp included.
  4. Pass the values as parameters, never glued into the SQL string. This is the step that costs nothing and prevents SQL injection.
  5. Mount StaticFiles at / last, so /api/* matches first and every other path falls through to the files already on disk.
  6. Prove it on 127.0.0.1:8011 with curl first — post an entry, get the list, check a 600-character message is rejected, confirm a trip card still serves.
  7. Only then take the port: disable hello-web.service, enable guestbook.service. The rollback is the same two commands the other way round.

keep this card — it is the whole build on one page

The full round trip

  1. You type and press Submit

    JavaScript on this page intercepts the form, so the browser doesn't do its default full-page reload.

  2. The browser sends JSON

    fetch('/api/guestbook', {method:'POST', body: JSON.stringify({name, message})}). That is the frontend's entire half of the conversation.

  3. FastAPI receives it and checks it

    A Pydantic model declares the rules — name 1–60 characters, message 1–500. If the JSON doesn't fit, FastAPI rejects it with a 422 before a line of your code runs.

  4. One INSERT

    INSERT INTO guestbook (name, message) VALUES (%s, %s) RETURNING ... — the values passed separately from the SQL text, never glued into the string. Postgres fills in the id and posted_at and hands the finished row back.

  5. The new row comes back as JSON

    Which is why the page can show it immediately, with the real server timestamp rather than a guess.

  6. The list re-renders

    The frontend puts it at the top of the list. No page reload happened at any point.

The one security habit that matters here

Step 4 passes the values as parameters, not by building a SQL string with + or an f-string. Doing it the other way is SQL injection, and it is the single most common way small projects get wrecked. Postgres receives the query shape and the data down separate channels, so a message containing '); DROP TABLE guestbook;-- is stored as those exact harmless characters. Get this habit right once and it costs nothing forever.

The whole backend

Sixty-odd lines, and about a third of it is the connection helper:

~/projects/guestbook/app.pyabridged — the shape
from fastapi import FastAPI
from pydantic import BaseModel, Field
import psycopg2, psycopg2.extras

app = FastAPI()

class NewEntry(BaseModel):
    # the contract. FastAPI enforces this before the handler runs.
    name: str = Field(min_length=1, max_length=60)
    message: str = Field(min_length=1, max_length=500)

@app.get("/api/guestbook")
def list_entries():
    with db() as cur:
        cur.execute("SELECT id, name, message, posted_at"
                    "  FROM guestbook ORDER BY id DESC LIMIT 200")
        return cur.fetchall()

@app.post("/api/guestbook", status_code=201)
def add_entry(entry: NewEntry):
    with db() as cur:
        cur.execute(
            "INSERT INTO guestbook (name, message) VALUES (%s, %s)"
            " RETURNING id, name, message, posted_at",
            (entry.name.strip(), entry.message.strip()),   # <-- separate
        )
        return cur.fetchone()

# mounted LAST so /api/* is matched first, and everything else
# falls through to the files build.py already wrote:
app.mount("/", StaticFiles(directory=SITE, html=True), name="site")

Two things are worth pointing at. The NewEntry class is the input half of the API contract — it is not documentation of the rules, it is the rules, checked on every request. And the last line is what lets one process serve both the API and every static page this site already has, which is the subject of the next section.

Section Review Card

no. 06 of 07
Concept:
The smallest thing that still needs every piece.
Key file:
~/projects/guestbook/app.py
One thing I actually understood today:
Rating:

07Part B

What has to change on port 8000

This is the part that touches the live site rather than adding to it, so it is worth understanding before it happens.

Port 8000 is currently held by hello-web.service, running python3 -m http.server. That program cannot be extended. It has no concept of running code for a request — a POST to it can only ever return an error. There is no version of “add an API to http.server”; something else has to hold the port.

Since the port is also what serves every trip card, whatever takes it over has to keep serving those too. Two ways:

OptionHowCost
FastAPI serves both
chosen
One process on 8000. API routes declared first; StaticFiles mounted at / last, catching everything else. One process, one unit file, nothing new installed.
Reverse proxy nginx or Caddy on 8000, routing /api/* to FastAPI on a private port and the rest to the files directly. Neither is installed. Adds a config file and a second service to keep running.

Option one, for now. The proxy is the right answer at the point where there is real traffic or more than one backend service, and swapping to it later changes nothing about the application code — it is a deployment decision, not an architectural one.

One real behaviour change

http.server generates a clickable directory listing for any folder with no index.html. StaticFiles returns a 404 instead. Every page anyone actually visits has an index.html, so nothing linked breaks — but a bare directory URL that used to show a file list will stop doing so. That is a loss of an accidental feature, not of any content.

The swap, and how to undo it

the change
$ systemctl --user disable --now hello-web.service
$ systemctl --user enable --now guestbook.service
the rollbackunconditional, always available
$ systemctl --user disable --now guestbook.service
$ systemctl --user enable --now hello-web.service

The old unit file is not deleted, so the way back is always exactly one command. That is worth doing on purpose with anything that takes over a working service: the point at which you discover you need the rollback is never the point at which you want to be reconstructing it.

Test before switching

The new app can be run on 127.0.0.1:8011 first and exercised with curl — POST an entry, GET the list, check a 600-character message is rejected, confirm a trip card still serves. The live site is untouched the entire time. Only once all of that passes is there any reason to take port 8000 away from the thing currently doing its job perfectly well.

Section Review Card

no. 07 of 07
Concept:
One process serves the API and the files. The rollback is one command.
Key file:
~/.config/systemd/user/guestbook.service
One thing I actually understood today:
Rating:

08Part B

The live thing

Not a mockup. This form talks to the real API, and the entries below come out of Postgres.

Guestbook

checking…
0 / 500

Entries

    Loading…

    If the status pill reads API not running, the backend hasn't taken over port 8000 yet — the page is being served by http.server, which has no /api/guestbook to answer with. Everything above still reads correctly; only this box needs the service.

    Worth doing once

    Open the browser's dev tools, go to the Network tab, and submit an entry. You will see exactly one request go out, with your JSON in the body, and one JSON response come back — no page reload. That request is the frontend/backend boundary, and watching it happen makes the split concrete in a way reading about it doesn't.

    09Part B

    Next step: photos

    Noted here so it isn't forgotten. Not built.

    Once the text guestbook works end to end, the natural next move is swapping the message for a photo. It is the same skeleton — same table, same two endpoints, same form — with a file field instead of a text one. What changes:

    That last point is the reason for doing text first. File uploads are where a small project acquires its first real security surface, and it is much easier to think about when the rest of the loop is already working and understood.