← MinBase Site Home

MinBase — Basic Tier Variant Specification

Teaching product spec for the MinBase incremental Docker Compose curriculum. This document defines the sequence, concepts, learning objectives, and per-variant verification steps. It does NOT contain Compose files — those are authored in a later step, one per variant, using this spec as the contract.

Canonical Decisions (locked)

Build status (all variants authored + validated)

All 10 folders (v-b0…v-b9) exist under H-Test with compose.yml, init.sql, README.md, and supporting files. All pass docker compose config. Three variants were exercised end-to-end with live HTTP:

Traefik note: the container that DEFINES middlewares must itself carry traefik.enable=true, or routers referencing those middlewares are disabled ("middleware X does not exist"). The Traefik service now sets it. Dashboard moved to 127.0.0.1:8090 (8080 was occupied on the build host).

Two-audience model

One curriculum, two depth tracks — same variants, same Compose files:

Do not build two courses. Build one with optional depth callouts.


The Sequence

Each variant introduces 1–2 new concepts. Baseline (v-b0) introduces zero — it is the starting point. Concept count is enforced: if a variant needs a third concept, it either splits or the concept moves.

v-b0 — Baseline (2 containers)

New concepts: none (this is the floor) Contents: Postgres + PostgREST only. Default network. Ephemeral storage. Superuser connection (deliberately — this is the "naive" starting point we will fix). You will learn: what the absolute minimum backend looks like — a database and an auto-generated REST API over it, nothing else. Verification:


v-b1 — Externalize Resources

New concepts (2): external Docker network; named volume for persistence. Also introduced: init.sql to seed schema + data on first cold start (treated as part of the persistence theme, not a third concept — it's how the volume gets its initial state). You will learn: resource isolation and data durability — why you don't let Compose own your network and data lifecycle implicitly. "Why" sidebar (junior): what actually happens on a named-volume first-run vs subsequent runs; why init.sql runs only when the data directory is empty. Verification:


v-b2 — Reliability

New concepts (2): healthchecks; restart policies + depends_on: condition: service_healthy. You will learn: service reliability and startup ordering — PostgREST must not start until Postgres is actually accepting connections (not just "container started"). "Why" sidebar (junior): the difference between "container running" and "service ready"; why naive depends_on without a condition is a lie. Verification:


v-b3 — Least Privilege (roles + authenticator) ← moved up from old v-b4

New concepts (2): dedicated Postgres roles (app_dbowner, authenticator, web_anon); PostgREST connecting as authenticator (NOT superuser). Also here: the object owner is split from the superuser. POSTGRES_USER stays postgres (the bootstrap superuser, used by init.sql and operators only); app_dbowner is a NOLOGIN, NOSUPERUSER, NOBYPASSRLS role that owns the objects (ownership is handed over at the end of init.sql). Renaming the superuser is not least privilege; separating the owner from the superuser is. You will learn: the principle of least privilege, and PostgREST's core auth model — authenticator is a login role with almost no rights that can only SET ROLE into web_anon (anonymous) or authenticated roles. "Why" sidebar (junior): SCRAM vs md5 password auth (set password_encryption = scram-sha-256); why superuser-in-PGRST_DB_URI is the #1 PostgREST footgun; grant tightening to specific objects only. Verification:


v-b4 — Config & Secrets Management

New concepts (2): .env files with dev/prod separation (.env.development / .env.production); secrets moved out of Compose into a secrets file / Docker secrets. You will learn: configuration management and environment separation — the same Compose file drives local and cloud by swapping the env layer, and no credential is ever committed. "Why" sidebar (junior): why secrets in environment: leak via docker inspect; the _FILE convention (Postgres) and PostgREST's @/path value syntax for reading secrets from a mounted file; how JWT secret gets staged here (used in v-b5). Verification:


v-b5 — API Authentication (JWT) ← surfaced as its own variant

New concepts (2): JWT verification in PostgREST (PGRST_JWT_SECRET, role claim → Postgres role); the db-anon-role vs authenticated-role distinction in practice. You will learn: how PostgREST turns a signed JWT into a Postgres role at request time, so authorization is enforced by the database, not application code. "Why" sidebar (junior): anatomy of the JWT (role, exp claims); HS256 shared secret vs asymmetric; why the secret lives in the secrets layer from v-b4; the "Server lacks JWT secret" failure and how to diagnose it. Verification:


v-b6 — Multi-Tenancy (Row-Level Security) ← restored from older plan

New concepts (2): RLS policies on tenant tables; org_id propagated from the JWT claim into the query via current_setting. You will learn: true tenant isolation enforced in the database — one table, many tenants, and a policy that makes it impossible to read another tenant's rows even with a valid token. "Why" sidebar (junior): why org_id is a UUID, not an integer (non-enumerable, merge-safe across shards, no sequential-ID leakage); FORCE ROW LEVEL SECURITY; how the JWT org_id claim reaches the policy via PostgREST's request.jwt.claims setting. Verification:


v-b7 — Reverse Proxy (Traefik) ← Traefik locked as canonical

New concepts (2): Traefik as edge router via Docker labels (service discovery, routing rules, entrypoints); security headers middleware. You will learn: request routing and a first hardening pass — Traefik discovers PostgREST by container labels (no hand-written upstream config), and adds security headers at the edge. "Why" sidebar (junior): label-based discovery vs static config; why the API is no longer published directly on :3000; where TLS will terminate later (Traefik, in the Intermediate tier); middleware chaining order. Verification:


v-b8 — Static / SPA Web Server (Nginx) ← in-depth, its own variant

New concepts (2): Nginx container serving a compiled SPA + static assets; full-stack topology where frontend and API are distinct services behind one Traefik edge. You will learn: how a real full-stack deployment is shaped — a static web server for the SPA, a REST API for data, both routed by the same proxy under one origin, so the browser sees no CORS boundary. "Why" sidebar (junior): why the SPA web server and the reverse proxy are separate containers (separation of concerns: Nginx serves bytes, Traefik routes/terminates TLS; independent scaling and restart; Nginx try_files SPA-fallback for client-side routing); path-based routing (/ → Nginx, /api → PostgREST) vs host-based; caching headers for static assets. Verification:


v-b9 — Python Stored Functions as REST Endpoints ← the critical missing piece

New concepts (2): a custom Postgres image that adds Python + PGJWT to the Alpine base; PL/Python (plpython3u) stored functions exposed by PostgREST at /rpc/<name> — how MinBase writes non-DB-related REST API endpoints with NO second app server (FastAPI/Flask) added to the stack. You will learn: that an endpoint doesn't have to be a table — a stored function written in Python (a language most devs know better than PL/pgSQL) is just another /rpc route on the same PostgREST, same Traefik edge, no new moving parts. This is the earliest signal in the course that "non-DB logic" belongs next to your data, not in a separate service. (This variant was the "critical piece missing" from the original brief — added after v-b8 so students meet PostgREST and the full Basic topology first.) "Why" sidebar (junior): why the stock postgres:alpine image is ALREADY compiled --with-python (official Dockerfile configures it; plpython3.so ships in contrib) but strips the Python runtime at prune-time — so the custom image adds back only apk add python3 (1 line, no rebuild, no -dev headers; the old root-draft's postgresql16-dev + make install was wrong-version and unnecessary); why a plpython function that runs plpy queries over RLS'd tables runs as the caller (so RLS scopes it; SECURITY DEFINER would leak across tenants) — and why that's sharp: elevate deliberately + SET search_path + tight GRANT EXECUTE; the dict → json trap (plpython does NOT auto-serialize a dict to RETURNS json — you must json.dumps()); why PGJWT installs by COPY (pure SQL/PLpgSQL on pgcrypto, zero C — no compiler); why a JWT minted inside Postgres (plpython → pgjwt sign()) is accepted by PostgREST (same HS256 secret, same claims — v-b5's model, now in-DB); why PostgREST returns scalar text JSON-quoted (strip quotes when feeding a Bearer header); and pinning the apk python version for reproducible image builds. Also in this variant (cross-cutting fix): the PG18 volume contract — postgres:18 images store data at /var/lib/postgresql/18/docker and expect the volume at the PARENT /var/lib/postgresql, not /var/lib/postgresql/data as earlier variants assumed. v-b1…v-b8 are corrected to match in this variant's commit (all persistent-volume variants must adopt it). Verification (all validated end-to-end in the 2026-09-25 build):


End-of-Basic-Tier state

After v-b8 the student has a stack that is safe to deploy to a cloud server: least privilege enforced, secrets externalized, JWT auth, RLS tenant isolation, edge routing with security headers, and a real SPA-plus-API topology. Attack surface minimized; nothing published that shouldn't be. After v-b9 they also have non-table REST endpoints written in Python with no added services — the last capability the Basic tier was missing. The Intermediate tier hardens and deploys this exact shape.

Deferred to later tiers (explicitly out of Basic scope)

These are named here so students know they're coming and why they're NOT in Basic:

Concept-count audit (self-check)

Variant New concepts Count OK
v-b0 baseline 0 ✅
v-b1 external network, named volume (+init.sql as persistence detail) 2 ✅
v-b2 healthchecks, restart+depends_on 2 ✅
v-b3 roles, authenticator (least privilege) 2 ✅
v-b4 .env dev/prod split, secrets file 2 ✅
v-b5 JWT verification, anon-vs-auth role 2 ✅
v-b6 RLS policies, org_id-from-claim 2 ✅
v-b7 Traefik label routing, security headers 2 ✅
v-b8 Nginx static/SPA server, full-stack topology 2 ✅
v-b9 custom image (Python+PGJWT); PL/Python functions as /rpc endpoints 2 ✅

Every variant is a clean 1–2 concept step. Least privilege is taught before any superuser habit forms. RLS and JWT — the actual differentiators — are first-class.

Back to top · Home