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.
authenticator role are introduced early
(v-b3). The Postgres superuser is NEVER used in
PGRST_DB_URI in any shipped variant.*_FILE convention
(POSTGRES_PASSWORD_FILE). PostgREST does not honor
PGRST_*_FILE (silently ignored: "could not look up
local user ID 1000" for the DB URI, "Server lacks JWT secret" / PGRST300
for JWT), but it does read a value from a file when the value starts
with @:
PGRST_DB_URI: "@/run/secrets/pgrst_db_uri",
PGRST_JWT_SECRET: "@/run/secrets/...". The image being
distroless is irrelevant (PostgREST opens the file itself). An earlier
version of this spec said PostgREST "ignores file-based secrets" and fed
it from .env.secret; that was wrong, and v-b4 was broken by
it. The lesson stands, in a sharper form: file-secret support is
per-image and per-syntax, so verify it with a request, not by
reading the compose file. (v-b4, v-i4 and v-i5 use @/path;
the remaining variants still interpolate from .env.secret
until their conversion ticket lands.)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:
/
(200); /api/candidates same-origin (200); deep-link
/dashboard → index.html fallback (200); security headers
present; Tenant A via Traefik /api (StripPrefix + JWT +
RLS) → Harry/Hermione/Ron.\.dx
shows plpython3u/pgcrypto/pgjwt;
POST /api/rpc/count_words → 4;
GET /api/rpc/summarize_notes → anonymous
{"note_count":0,...}, org-A token
{"note_count":2,"total_words":5}; the API surface is an
allow-list (only those two /rpc endpoints); in-DB-minted
JWT (minting role, not HTTP) accepted by PostgREST with RLS per-tenant
isolation; raising function → HTTP 500 (errors surface); SPA still
served at / with security headers. v-b8 compose corrected
to the PG18 volume contract.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).
One curriculum, two depth tracks — same variants, same Compose files:
Do not build two courses. Build one with optional depth callouts.
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.
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:
docker compose up, then
curl http://localhost:3000/ returns the OpenAPI root.curl http://localhost:3000/<table> returns seeded
rows as JSON.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:
docker volume ls shows the named volume;
docker network ls shows the external net.docker compose down && up with volume intact →
init.sql does NOT re-run.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:
docker inspect --format '{{.State.Health.Status}}'
shows healthy for Postgres.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:
\du in psql shows the role hierarchy with correct
NOLOGIN/LOGIN flags; app_dbowner
is not a superuser and owns the table.web_anon.authenticator
in psql and confirm it CANNOT DROP TABLE or read tables it
wasn't granted.SELECT rolpassword ... shows a SCRAM verifier,
not md5.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:
docker inspect <container> does NOT reveal the DB
password or JWT secret in env..env.development → .env.production;
confirm the stack picks up prod values.git status / .gitignore check: no secret
file is tracked.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:
401.role: web_user (documented helper),
send as Authorization: Bearer → 200 with
rows.401; send a token with a bogus
role → PostgREST error, not a data leak.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:
403 — they simply don't exist in the result
set).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:
curl -H "Host: api.local" http://localhost/ routes to
PostgREST via the label rule.X-Frame-Options, X-Content-Type-Options).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:
curl http://localhost/ serves index.html
from Nginx.curl http://localhost/api/<table> routes to
PostgREST (same origin, no CORS)./dashboard)
returns index.html via try_files fallback, not
a 404.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):
./install.sh brings the stack up with the custom image;
\dx shows plpython3u, pgcrypto,
pgjwt.POST /api/rpc/count_words {"input_text":"hello world foo bar"}
→ 4.GET /api/rpc/summarize_notes (invoker, RLS-scoped) →
anonymous {"note_count":0,...}; with an org-A token
{"note_count":2, "total_words":5}.select mint_jwt('web_user','<org>')
(plpython → pgjwt sign()) mints a token accepted by
PostgREST; the signing secret is read server-side from
app_secret.signing_secret and is never an argument. org-A
token → org-A rows only, org-B → org-B only, anonymous → []
(RLS); then select verify_jwt('<token>') →
{"valid": true, ...}. Over HTTP, /rpc/mint_jwt
and /rpc/verify_jwt are refused (401/403/404), with or
without a body-supplied secret.GET /api/ lists
only /rpc/count_words and
/rpc/summarize_notes. EXECUTE is opt-in
(ALTER DEFAULT PRIVILEGES … REVOKE EXECUTE … FROM PUBLIC,
then a named GRANT after each function);
pgcrypto/pgjwt live in schema ext
and the RLS helper in app_private, neither exposed./ (200); security headers present.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.
These are named here so students know they're coming and why they're NOT in Basic:
max_connections): introduced as a "Why"
sidebar in v-b2/v-b3 (the #1 production footgun), but PgBouncer and
tuning are Intermediate. Basic just makes students aware of the
pool ↔︎ max_connections relation.pg_dump
awareness note belongs near v-b1 (persistence), but automated backups to
S3 are Advanced (v-a5). No student should finish Basic thinking
"production-ready" means "no backup" — call it out, defer the
automation.| 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.