← The Upskill Initiative · ResourcesSupport labs
Support lab · tested 12 Oct 2026

Tenant Todo Lab

Build one multi-tenant to-do API, then run it, cache it, ship it to Kubernetes, watch it in Grafana, shard it with Vitess, add a Temporal workflow and stream events through Kafka. Every step lists the errors you will actually hit and how to fix them.

FastAPIPostgresRedisDockerKubernetesPrometheus + GrafanaLokiTempo + OpenTelemetryPgBouncerVitess MySQLTemporalKafka
8sessions (Day 4 is split in two)
~21.5 hin total
12tools
1app, grown daily

Before you start

  • What ~21.5 hours gets you. You will be able to build this stack, explain how each piece works, and fix the 6–10 most common failures in each tool. Being truly advanced takes months of real incidents. This plan gives you the mental model and the first incidents.
  • Memory is the hard limit. Everything was tested with Docker limited to 4 GB. Run one day's stack at a time, and stop the previous day's containers before starting the next. On Windows, Docker's memory is set in WSL (see Set up your computer).
  • Temporal and Kafka have their own days. Day 6 builds a real feature with Temporal: an admin bulk-assigns todos from a CSV, and the workflow survives crashes and retries without duplicates. Day 7 adds Kafka: an activity feed that survives consumers going down. Day 5 is Vitess only.
  • Works on macOS, Windows and Linux. Commands are written for a bash terminal: Terminal on macOS, your shell on Linux, and an Ubuntu terminal under WSL 2 on Windows. Where Windows PowerShell differs, a Windows note says so.
  • Day 4 has two parts. 4a covers metrics and database incidents (3 h). 4b covers logs, stack traces and traces (2 h).
  • Database skills are spread across the days. Each one appears where the app first needs it (table below). Adding them made each day run about 5–10 minutes over 3 hours.
  • Tested, not guessed. On 12 Oct 2026 every day of this lab was run end to end on macOS (Apple Silicon, Docker Desktop, 4 GB for Docker). Linux-only steps were checked in Ubuntu 24.04, and the PowerShell commands were checked with PowerShell 7.6. Green-dot boxes are real output from those runs; your ids, times and row counts will differ. Grey-dot boxes describe what to look for.

Database concepts, and where you practise them

ConceptWhat you actually doDay
Transactions, row-level securityIsolate tenants inside Postgres; commit vs rollback1
Indexes + EXPLAIN ANALYZE500k rows; take a query from ~19 ms to ~0.1 ms; find indexes Postgres can't use1
PaginationOFFSET vs keyset (cursor), measured1
Connection limits + poolingRun out of connections, hit the app pool timeout, add PgBouncer, see the tenant-setting leak2
Locks + deadlocksFind the session blocking everyone; lock_timeout; force a deadlock4
MVCC, dead rows, VACUUMWatch the table double in size from one UPDATE; reclaim it4
Slowest-query reportpg_stat_statements: which query costs the most in total4
ShardingShard key, routing, scatter queries, global ids, cross-shard writes5
Idempotent writesA unique key + ON CONFLICT DO NOTHING so retried batches never duplicate rows6, 7
Event streamingPartitions, consumer groups, offsets and lag, replay, dead-letter topic, the outbox pattern7

Set up your computer

You need Docker, Python 3.12 or newer, kind and kubectl. k9s is optional but recommended. Pick your system:

macOS

  1. Install Docker Desktop. In Settings → Resources, give it at least 4 GB of memory.
  2. Install Homebrew, then the tools:

Python 3 from python.org or Homebrew both work.

Windows (WSL 2)

  1. Open PowerShell as administrator, run the command below, then restart (Microsoft guide). It installs Ubuntu.
  1. Install Docker Desktop with the WSL 2 backend. In Settings → Resources → WSL Integration, turn it on for Ubuntu.
  2. Open the Ubuntu app and run the Linux commands in the next card there. From then on, run every command on this page in that Ubuntu terminal.

Keep your project in the Linux home folder (~/tenant-todo), not under /mnt/c. Docker recommends this for speed.

Memory: with WSL 2, Docker's memory limit comes from %UserProfile%\.wslconfig (default: half your RAM), not from Docker Desktop's settings. To set 6 GB, put the lines below in that file, then run wsl --shutdown and reopen Ubuntu (Microsoft guide).

Linux (and Ubuntu on WSL)

  1. On Linux, install Docker Engine and run sudo usermod -aG docker $USER, then log out and in again (Docker guide). On WSL, Docker Desktop provides Docker, so skip this step.
  2. Python's venv module is a separate package on Ubuntu. Without it, python3 -m venv fails with "ensurepip is not available".

On an ARM machine, use the arm64 files instead; see the kind, kubectl and k9s guides.

Using PowerShell instead of WSL? Equivalent commands

These were checked with the PowerShell 7.6 parser, not run on a Windows machine. Use PowerShell 7: Windows PowerShell 5.1 can strip the quotes inside JSON arguments. The Linux-only parts of Days 4b–6 (the Docker socket for Alloy, bash loops and heredocs) are easiest in WSL. Install kind with winget install Kubernetes.kind and k9s with scoop install k9s or choco install k9s.

On this page (bash)PowerShell 7Note
source .venv/bin/activate.venv\Scripts\Activate.ps1If scripts are blocked: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser (Python docs)
docker exec -i pg psql -U postgres -d todo < sql/01-schema.sqlGet-Content sql/01-schema.sql | docker exec -i pg psql -U postgres -d todoPowerShell has no < input redirection
a line ending in \end the line with a backtick `Line continuation
curl -s ...curl.exe -s ...Plain curl can be an alias for Invoke-WebRequest
lsof -i :5432Get-NetTCPConnection -LocalPort 5432 -State ListenWho is using a port
for i in $(seq 1 105); do ...; done1..105 | ForEach-Object { ... }Loops
-o /dev/null-o NULDiscard output (curl.exe)

What you are building

Pick a day to see what it adds
Kubernetes (Day 3) scrapes /metrics Day 5 swap 1. cache? 2. query You (curl)X-Tenant-ID: 1 FastAPI/todos · /health Rediscache + rate limit Postgresrow-level security Prometheusstores metrics Grafanadashboards Vitess · vtgatehash(tenant_id) → shard -80 | shard 80- TemporalCSV import Kafkatodo-events events Loki + Tempologs · traces (4b) logs + spans
Press “Send a request” to follow one GET /todos call.
Day 1 · FastAPI + Postgres

Two companies, one database, zero leaks

By tonight: an API where tenant 1 and tenant 2 share one table and the database itself stops them seeing each other's rows.

Idea 15
Setup 20
Schema 25
RLS 20
API 50
Indexes 35
Break it 25

What "multi-tenant" means

One app serves many customer companies (tenants). Their data must never mix.
15 min
ApproachHowUsed by
Shared table + tenant_idEvery row has a tenant_id. Cheapest. One missed filter leaks data.Most SaaS, Supabase (with RLS). We use this.
Schema per tenantSame tables copied into one schema per tenant.Mid-size B2B apps
Database per tenantStrong isolation, painful at 10,000 tenants.Regulated customers
Shard by tenantShared tables, but each tenant's rows live on one shard.Vitess / PlanetScale (Day 5)

Row-level security (RLS) is a Postgres rule that filters rows for you. It is the main thing that keeps Supabase customers apart, so it is worth knowing well.

Project folder, Python env, Postgres container

20 min
What to look for
pg   postgres:16   Up 5 seconds   0.0.0.0:5432->5432/tcp
... LOG:  database system is ready to accept connections

Port already in use? If docker run says Bind for 0.0.0.0:5432 failed: port is already allocated, another Postgres is running on your computer (it happened on our test Mac). Find it with lsof -i :5432 (Windows PowerShell: Get-NetTCPConnection -LocalPort 5432 -State Listen), or use -p 5433:5432 and port 5433 in every URL.

Tables: tenants and todos

Save as a file so Day 2 and Day 3 can reuse it.
25 min

Windows PowerShell: < doesn't work there. Use Get-Content sql/01-schema.sql | docker exec -i pg psql -U postgres -d todo. The same applies to every < file on this page.

Inside psql, try \dt (list tables), \d todos (describe), SELECT * FROM tenants;, then \q to quit.

Row-level security: the database guards each tenant

20 min
  • USING filters what a query can see. WITH CHECK blocks writing a row for another tenant.
  • app.tenant_id is a setting the API puts on the connection for each request.
  • The true in current_setting(..., true) means "return nothing if unset" instead of raising an error. Nothing set means no rows.

The API

Type it yourself. Read the two comments, they hold the key ideas.
50 min
app/main.py (Day 1 version)

The queries have no WHERE tenant_id = .... Row-level security adds that filter for you. Many teams keep the WHERE as well, as a second safety net.

Real output · test run 12 Oct 2026 (status codes and notes added)
{"id":1,"title":"buy milk","done":false}            201
{"id":2,"title":"globex secret plan","done":false}  201
[{"id":1,"title":"buy milk","done":false}]          200   ← tenant 1 sees only its row
[{"id":2,"title":"globex secret plan","done":false}] 200  ← tenant 2 sees only its row
{"detail":"todo not found"}                         404   ← tenant 2 can't touch tenant 1's todo
{"detail":[{"type":"missing","loc":["header","x-tenant-id"],"msg":"Field required"...}]}  422

Also open localhost:8000/docs. FastAPI builds a clickable test page for free.

Indexes: make "my latest todos" fast at 500k rows

An index is a sorted copy of some columns, so Postgres can jump to rows instead of reading the whole table.
35 min

Round 1. The question every todo app asks: "tenant 7's newest 20". Run it with the single-column index from 01-schema.sql:

Real output (trimmed)
Limit  (actual time=18.817..18.820 rows=20)
  ->  Sort  Sort Key: created_at DESC          ← sorts all 10,000 of tenant 7's rows
        Sort Method: top-N heapsort  Memory: 26kB
        ->  Bitmap Heap Scan on todos (actual rows=10000)
              ->  Bitmap Index Scan on todos_tenant_idx
Execution Time: 18.941 ms

Round 2. Drop that index (DROP INDEX todos_tenant_idx;) and run it again: Parallel Seq Scan, 17.7 ms in our test. Almost the same. The index found the rows, but the sort still did most of the work.

Round 3. Build an index that matches both the filter and the order:

Real output
Limit  (actual time=0.058..0.073 rows=20)
  ->  Index Scan using todos_tenant_created_idx on todos
        Index Cond: (tenant_id = 7)          ← no Sort step: rows come out already ordered
Execution Time: 0.109 ms                     ← ~170× faster on our machine

Now replace the index line in sql/01-schema.sql with this one, so Days 2–4 get it automatically.

How to read a plan, in 4 checks
  • Scan type: Seq Scan = read every row. Index Scan / Bitmap = jumped to rows. Index Only Scan = didn't even touch the table.
  • rows= estimate vs actual: far apart means stale statistics. Run ANALYZE.
  • Rows Removed by Filter: work thrown away. Big numbers mean a missing index.
  • Sort / Hash steps with a large "Memory" or "Disk": an index in the right order can remove them.
Three ways an index gets ignored (all tested)

Two of these need an index on title, so create it first: CREATE INDEX todos_title_idx ON todos (title); then ANALYZE todos;

QueryPlanFix
WHERE created_at > now() - interval '1 hour'Seq Scan, 41 ms. The index starts with tenant_id, like a phone book sorted by surname that you search by first name.Put the filtered column first, or add a separate index
WHERE title LIKE '%4242'Seq Scan, 27 ms (with a title index!). A B-tree can't search from the middle of a word.For "contains": CREATE EXTENSION pg_trgm; + CREATE INDEX ON todos USING gin (title gin_trgm_ops); → 0.8 ms (tested). Even LIKE 'task%' skips a plain index under the default en_US.utf8 collation; it needs (title text_pattern_ops) → 0.4 ms (tested).
WHERE lower(title) = '...'Seq Scan, 47 ms. The index holds title, not lower(title).CREATE INDEX ON todos (lower(title)); → 0.1 ms (tested)
Pagination: OFFSET vs keyset
Real output
OFFSET 9000:  Sort (quicksort, 1088kB) over 10000 rows    Execution Time: 39.456 ms
keyset:       Index Scan using todos_tenant_created_idx    Execution Time: 0.071 ms

Your cursor value will differ: use a created_at from your own data. OFFSET gets slower with every page; keyset stays flat.

Indexes cost something: every INSERT/UPDATE must update each one, and they use disk. Run \di+ todos*. In our test the title index alone was 19 MB on a 33 MB table. Only add indexes that real queries need.

Break it on purpose

Guess what will happen before each one, then check.
25 min
Log in as the superuser

Restart with DATABASE_URL=postgresql+psycopg://postgres:dev@localhost:5432/todo uvicorn app.main:app. Tenant 2 now sees tenant 1's todos, because superusers skip RLS. Supabase's service_role key works the same way.

Forget to set the tenant

Comment out the set_config line. Lists come back empty, and POST fails with new row violates row-level security policy. Read the full error in terminal 1.

Kill the database

docker stop pg, call /health (500, "connection refused"), then docker start pg. It recovers by itself because pool_pre_ping tests connections before using them.

An index Postgres skips on purpose

CREATE INDEX ON todos (done); then EXPLAIN SELECT id FROM todos WHERE done = false;. Two thirds of rows match, so reading the whole table is cheaper. Indexes only pay off for selective filters. Drop it afterwards.

Issues you will hit on Day 1

SymptomCauseFix
password authentication failed for user "postgres"POSTGRES_PASSWORD is only used the first time the volume is created. You changed it later.docker rm -f pg && docker volume rm pgdata, then run it again
port is already allocated / bind: address already in useSomething else is on 5432 (a Postgres installed on your computer, an old container)lsof -i :5432 (PowerShell: Get-NetTCPConnection -LocalPort 5432 -State Listen); stop it or use -p 5433:5432 and change the URL
connection refusedContainer not running, or still startingdocker ps -a, docker logs pg
ModuleNotFoundError: psycopg2URL starts with postgresql://. SQLAlchemy 2.0 then looks for the old psycopg2 driver (2.1 and later default to psycopg 3, but Day 4b pins 2.0)Use postgresql+psycopg://
The virtual environment was not created successfully because ensurepip is not availableUbuntu/WSL ships venv separatelysudo apt install -y python3-venv
ModuleNotFoundError: fastapivenv not active in this terminalsource .venv/bin/activate (PowerShell: .venv\Scripts\Activate.ps1)
422 Unprocessable ContentMissing header or bad JSON bodyRead the detail field. It names the missing field.
permission denied for sequence todos_id_seqThe app role can insert rows but can't take the next idGRANT USAGE ON SEQUENCE todos_id_seq TO app;
LEAKRLS is on but everyone sees everythingConnected as the superuser or the table owner, and both skip RLSConnect as app, or run ALTER TABLE todos FORCE ROW LEVEL SECURITY
Lists are empty even though rows existThe tenant setting was set on a different connection or transaction (connection pool)Set it inside the same transaction as the query, as tenant_tx does
INSERT "worked" but the row is goneUsed engine.connect() without commit(), so it rolled backUse engine.begin() for writes
Index exists but the query is still slowIndex doesn't match the ORDER BY, or the query wraps the column in a function or a leading %EXPLAIN ANALYZE; build the index to match the query's filter + order
Plan estimates rows=1 but actual rows=200000Statistics are stale after a big loadANALYZE todos;
Page 500 of a list takes secondsOFFSET reads and discards every earlier rowKeyset pagination
Exit check: can you answer these?

1. Why can't the app connect as postgres? Superusers bypass RLS.

2. Why set_config(..., true) and not SET app.tenant_id? A plain SET stays on the pooled connection and leaks into the next request for a different tenant.

3. What does WITH CHECK stop that USING doesn't? Writing a row that belongs to another tenant.

4. Why (tenant_id, created_at) and not (created_at, tenant_id)? The query filters on tenant_id with equality, then reads in created_at order. Equality columns go first.