Baseline: your first backend

You will build the smallest backend that works: a database, and a REST API that is generated automatically from it. Then you will find out where it is weak, because the weak spots are what the rest of the course fixes.

By the end of this lesson you will be able to

Before you start

You do not need any prior SQL or Docker knowledge. This lesson explains each idea the first time it appears.

1. The big picture

Every backend is, at its core, data behind an API. This variant builds exactly that and nothing more. It has two programs:

The traditional way to build an API is to write a program by hand that receives a request, runs some SQL, and formats the answer. PostgREST removes that program. It is only a translator between HTTP and SQL. A request for /candidates becomes a query on a table called candidates, and the rows that come back are sent as JSON.

sequenceDiagram autonumber participant C as Client (curl) participant R as PostgREST (apiserver) participant P as PostgreSQL (db) C->>R: GET /candidates R->>P: SELECT * FROM candidates P-->>R: 5 rows R-->>C: 200 OK, JSON list of 5 people
How one request travels in v-b0. Read it from top to bottom. (1) The client asks for /candidates. (2) PostgREST turns that into a SQL query. (3) Postgres returns the rows. (4) PostgREST sends them back as JSON. PostgREST keeps a small set of database connections open and reuses them, so it does not reconnect for every request. Those connections are made as the all-powerful superuser, which is one of the weaknesses we fix later.
New terms
Schema
The description of how a database is organised: its tables, columns and the types of data they hold. In PostgreSQL, a schema is also a named folder inside a database. The default one is called public.
Table, row, column
A table is a grid of data. Each row is one item (one candidate). Each column is one property of that item (their name, for example).
SQL
The language used to talk to a relational database such as PostgreSQL. SELECT * FROM candidates means "give me every column of every row in the candidates table".

2. Docker in five minutes

Installing a database directly on your computer is slow, and it leaves a mess behind. Docker avoids that. It lets you run a program in an isolated box that already contains everything the program needs. When you are done, you delete the box and your computer is as clean as before.

New terms
Image
A read-only package containing a program and everything it needs to run. Think of it as a recipe plus all the ingredients. postgres:18-alpine is an image. The part after the colon is the tag, which selects a version.
Container
A running copy of an image. You can start many containers from one image. Containers are disposable: deleting one does not harm the image.
Docker Compose
A tool that starts several containers together from one description file. The file is usually called compose.yml. You run it with docker compose.
Service
One entry in a Compose file. Each service becomes one container here. Our two services are db and apiserver.
Port mapping
A line like "3000:3000" which means "forward port 3000 on my computer to port 3000 inside the container". It is how you reach a program inside a container from your own browser or terminal. The left number is on your computer, and the right number is inside the container.
Environment variable
A named setting handed to a program when it starts, such as POSTGRES_PASSWORD. Many container images are configured this way, because it needs no editing of files inside the image.

Compose files use a format called YAML. In YAML, the indentation is the structure. Use spaces, never tabs, and keep each level indented by exactly two spaces. Most beginner errors in this course come from a misaligned line.

3. Part 1: Start the database

You will build the stack in small pieces and run it after each piece. That way you always know which change caused which result. We start with the database alone.

Create the project folder

Make a new folder named exactly v-b0 and open a terminal inside it.

terminal
$ mkdir v-b0
$ cd v-b0
Watch out: the folder name matters

Docker Compose builds names for your containers from the folder name. A folder called v-b0 gives containers called v-b0-db-1 and v-b0-apiserver-1. Every command in this lesson uses those names. If your folder is called something else, your containers will be named differently and the commands will not match.

Write the compose file

Create a file named compose.yml with this content:

compose.yml (version 1: database only)
services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_PASSWORD: devpassword
    ports:
      - "5432:5432"

Here is what each line does:

LineMeaning
services:Starts the list of programs to run.
db:The name we chose for this service. Other services will use this name as its network address.
image: postgres:18-alpineUse the official Postgres image, version 18, built on a very small Linux called Alpine. If you do not have it yet, Docker downloads it.
POSTGRES_DB: appOn first start, create a database called app.
POSTGRES_PASSWORD: devpasswordSet the password for the built-in administrator user, which is called postgres. This image refuses to start without a password.
"5432:5432"Make Postgres reachable on port 5432 of your computer. 5432 is the standard Postgres port.
Why it matters

A password written directly in compose.yml is fine for a throwaway lesson, but dangerous in a real project, because the file is usually committed to version control, and anyone who can read the repository then knows your password. This is the first of seven deliberate weaknesses in this variant. We fix it in v-b4.

Start it

Predict before you run: what will docker compose up -d print?

Think about what Docker needs to do. It needs a network for the containers, and a container for the db service. Will it also need to download something the first time?

Yes to all three. On the first run you will see the image being pulled, then a network and a container being created and started. The -d means "detached": the containers run in the background and give your terminal back.

terminal, inside v-b0
$ docker compose up -d
 Network v-b0_default  Created
 Container v-b0-db-1  Created
 Container v-b0-db-1  Started
$ docker compose ps

Your first run will also print some lines about pulling the image, with long ID codes. Those lines will not match anyone else's, which is normal.

Notice the names. Docker created a network called v-b0_default and a container called v-b0-db-1: the folder name, the service name, and an instance number. We did not ask for a network. Compose made one for us. We will come back to that in v-b1.

Why it matters

Automatic names are convenient, but they depend on the folder name. That is a problem when you run several copies of a service for scaling, because names such as -1 and -2 then appear. Production setups usually avoid hard-coding container names in scripts and refer to the service instead (docker compose exec db ...). In this course we use the generated names so you can see exactly how they are built.

4. Part 2: Look inside with psql

A database is invisible until you ask it questions. The standard tool for this is psql, which already exists inside the Postgres container.

New term: psql

psql is a text-based client for PostgreSQL. You type SQL, it sends your SQL to the server and prints the results. PostgreSQL is the database system. psql is only one of the ways of talking to it. Besides SQL, psql has its own shortcut commands that start with a backslash, called meta-commands, for example \dt to list tables.

Run psql inside the container, as the postgres user, connected to the app database:

terminal
$ docker exec -it v-b0-db-1 psql -U postgres -d app
psql (18.6)
Type "help" for help.

app=#

Here is what each piece of the command means. docker exec runs a command inside a running container. -it makes it interactive, so you can type. -U postgres says which database user to log in as, and -d app says which database to open. The prompt app=# tells you that you are connected to the app database, and the # means you are a superuser.

Now try the three meta-commands you will use all through this course:

CommandWhat it lists
\lThe databases on this server.
\duThe roles (users and groups) that exist.
\dtThe tables in the current schema.
Predict: what will \dt show right now?

We have created a database but never made a table. So it should show nothing. Postgres says so in words:

app=# \dt
Did not find any tables.

Try \l and \du as well. In \du you will see only postgres, with the attributes Superuser, Create role, Create DB, Replication and Bypass RLS. Those are all the powers a database user can have. Remember that list, because in v-b3 we take almost all of them away from the user the API uses.

Leave psql open for the next part. (To leave it any time, type \q.)

Key idea: look inside as well as outside

There are two ways to look at your backend. Calling the API from outside shows you what the API returns. Using psql shows you what the database actually holds. When something behaves strangely, always ask "what does the database say?" first. PostgREST is only a window onto Postgres, so if the two disagree, you already know where the problem is.

5. Part 3: Add the API (and meet an error)

The database is running, but it is still empty: no tables, no data. We will add PostgREST now anyway. Instead of showing you a finished file, we will build it up the way a working engineer would, and in this part and the next you will meet the two errors that nearly everyone hits the first time. Learning to read these errors is a core skill.

First attempt: the bare minimum

Add a second service to compose.yml, below db and indented at the same level:

compose.yml (version 2: add apiserver)
services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_PASSWORD: devpassword
    ports:
      - "5432:5432"

  apiserver:
    image: postgrest/postgrest:v13.0.7
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      PGRST_DB_URI: "postgres://postgres:devpassword@db:5432/app"
      PGRST_DB_SCHEMA: "public"
    depends_on:
      - db

The new lines mean the following.

LineMeaning
postgrest/postgrest:v13.0.7The official PostgREST image, pinned to an exact version so everyone sees the same behaviour.
"127.0.0.1:3000:3000"A port mapping, like before, but with an address in front. It means "only accept connections that come from this computer". Without 127.0.0.1, other machines on your network could reach the API too.
PGRST_DB_URIA connection string: how to reach the database. It reads postgres://user:password@host:port/database. The host is db, the name of our other service. Inside a Compose network, services find each other by service name.
PGRST_DB_SCHEMAWhich schema to turn into an API. Every table and function in public becomes an endpoint.
depends_on: - dbStart the database container first.
Why it matters: the superuser connection

The connection string logs in as postgres, the superuser. That means any request that reaches the API runs with the power to drop tables, read every row and create roles. Giving a web-facing program that much power is a well-known mistake. We replace it with a limited login in v-b3. Notice that you have the situation first, so the later fix will make sense.

Why it matters: what depends_on does not do

depends_on only waits until the database container has started. It does not wait until Postgres is ready to accept connections, and that takes a few more seconds. On a slow machine, PostgREST can start too early, fail to connect, and keep restarting. "The container is running" is not the same as "the service is ready". v-b2 fixes this with health checks.

Apply the change. Compose notices what changed and only starts the new service. Then call the API:

Predict: will curl http://localhost:3000/candidates work?

There is no candidates table yet, so you might expect a "not found" message. But PostgREST checks something else first: who is asking? We have not told it who an unauthenticated visitor should be. Make your guess, then compare it with the terminal output below.

terminal
$ docker compose up -d
$ curl -i http://localhost:3000/candidates
HTTP/1.1 401 Unauthorized
{"code":"PGRST302","details":null,"hint":null,"message":"Anonymous access is disabled"}

Error 1: "Anonymous access is disabled"

An HTTP response starts with a status code, a three-digit number that tells the client how the request went. Codes starting with 2 mean success, 4 mean the client's request was wrong or not allowed, and 5 mean the server failed. 401 Unauthorized means "I do not know who you are, so I will not help". The PGRST302 code is PostgREST's own label for this error, which you can search for in its documentation.

This is PostgREST being careful. It will not let anyone in unless you explicitly say so. To allow anonymous visitors, you give it the name of a database role to act as when no identity is supplied. That will be a role called anonymous, which we create in the next part. It can do only what that role is granted.

New term: the anonymous role

The anonymous role is the database role PostgREST uses for requests that carry no proof of identity. You choose it with the setting PGRST_DB_ANON_ROLE (in PostgREST's own documentation it is spelled db-anon-role). The idea behind PostgREST's design is that the database decides what each role may do, and PostgREST simply switches to the right role for each request.

Add one line to the environment: section of apiserver:

compose.yml (apiserver, with the new line)
    environment:
      PGRST_DB_URI: "postgres://postgres:devpassword@db:5432/app"
      PGRST_DB_SCHEMA: "public"
      PGRST_DB_ANON_ROLE: "anonymous"

Changed settings only take effect when the container is created again. Running docker compose up -d again does this, because Compose notices that the service changed and replaces just that container.

terminal
$ docker compose up -d
$ curl -i http://localhost:3000/candidates
HTTP/1.1 404 Not Found
{"code":"PGRST205","details":null,"hint":null,"message":"Could not find the table 'public.candidates' in the schema cache"}

This time the answer is different, and it is exactly what we should expect. PostgREST now knows who the visitor is (the anonymous role), so it goes looking for a table called candidates. There is no such table yet, so it reports 404 Not Found. The code PGRST205 means "table not found in the schema cache". We will explain the words "schema cache" shortly. For now, the next job is clear: create the table.

6. Part 4: Create a table (and meet a second error)

An empty database is not much use. Save the following as init.sql in your v-b0 folder. It is a SQL script: a list of commands that run one after another.

init.sql (version 1)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE ROLE anonymous NOLOGIN;

CREATE TABLE IF NOT EXISTS candidates (
    candidate_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name         VARCHAR(100) NOT NULL,
    created_at   TIMESTAMPTZ DEFAULT now(),
    updated_at   TIMESTAMPTZ DEFAULT now()
);

GRANT USAGE ON SCHEMA public TO anonymous;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO anonymous;

INSERT INTO candidates (name) VALUES
    ('Harry'), ('Hermione'), ('Luna'), ('Ron'), ('Neville');

This script has several new ideas. Take them one block at a time.

New terms for init.sql
Extension
An optional add-on for Postgres. uuid-ossp adds functions that make UUIDs. CREATE EXTENSION IF NOT EXISTS installs it, and does nothing if it is already there.
UUID
A "universally unique identifier": a long random-looking code such as 3f19c2ab-.... Because they are practically never repeated, they make safe ID numbers for rows, and you can create one without asking the database for the "next number". uuid_generate_v4() makes one.
Primary key
The column that uniquely identifies each row. No two rows may share a value, and it may not be empty.
Role
PostgreSQL's single idea for "who is acting". A role can be a user, a group, or both. Every permission in the database is granted to a role. NOLOGIN means that nobody can connect as this role directly. It can only be taken on by another role, which is exactly what PostgREST will do.
GRANT
The command that gives a role permission to do something. GRANT USAGE ON SCHEMA lets the role look inside the public schema. GRANT ALL PRIVILEGES ON ALL TABLES lets it read, add, change and delete in every table that exists right now.
TIMESTAMPTZ
A date and time with time zone. It is stored in a way that always means one exact moment, no matter where you are. DEFAULT now() fills it in with the current time when a row is added.
Watch out: this grant is far too generous

GRANT ALL lets any anonymous visitor read, add, change and delete everything. We do it here only to make the first demonstration work. In v-b3 you will replace it with exactly the permissions each role needs. This idea is called least privilege.

Run the script

The script is on your computer, but psql runs inside the container, so first copy the file in. Leave psql with \q, or open a second terminal, and run:

terminal, inside v-b0
$ docker cp init.sql v-b0-db-1:tmp/init.sql
$ docker exec -it v-b0-db-1 psql -U postgres -d app

At the app=# prompt, use the \i meta-command ("include") to run the file:

psql
app=# \i /tmp/init.sql
CREATE EXTENSION
CREATE ROLE
CREATE TABLE
GRANT
GRANT
INSERT 0 5

Each line is Postgres confirming one command. INSERT 0 5 means five rows were added. Now check your work with the commands you already know. The recording below shows what you should see.

Real recording. It lists the tables (\dt) and the roles (\du), then selects the five names. You can pause it, and you can select and copy the text.

Why is it still a 404?

Now call the API again, as you did before. You created the table and the data a moment ago, so you would expect five rows:

terminal
$ curl -i http://localhost:3000/candidates
HTTP/1.1 404 Not Found
{"code":"PGRST205","details":null,"hint":null,"message":"Could not find the table 'public.candidates' in the schema cache"}

Nothing has changed in the answer, even though psql shows that the table exists. This is a classic moment of confusion, so slow down and ask: what does PostgREST know that I do not?

New term: schema cache

To answer requests quickly, PostgREST does not ask the database "which tables exist?" on every request. It asks once, when it starts, and keeps the answer in memory. That saved answer is the schema cache. Think of it as a photograph of your database taken at startup. If you change the database afterwards, the photograph is out of date until someone takes a new one.

PostgREST started when the database was empty, so its photograph shows no tables. The general lesson is: whenever the structure of the database changes, PostgREST must be told to refresh its cache. In real projects this happens right after every change to the database structure.

PostgreSQL has a built-in messaging feature. A program can listen on a named channel, and anything can notify that channel. PostgREST listens on a channel called pgrst. Sending it the message reload schema makes it rebuild the cache. At the psql prompt:

psql
app=# NOTIFY pgrst, 'reload schema';
NOTIFY
terminal
$ curl -i http://localhost:3000/candidates
HTTP/1.1 200 OK
Server: postgrest/13.0.7
Content-Range: 0-4/*
[{"candidate_id":"...","name":"Harry",...}, ...]

It works. 200 OK means success, and the body is a JSON list of your five rows. The Content-Range: 0-4/* header means "rows 0 to 4 are included".

Key idea: the schema is the API

You did not write a single line of API code. You created a table, and the API appeared. Add a table and a new endpoint appears. Rename a column and the JSON changes. That is why the database design matters so much in this stack.

PostgREST also describes itself. Fetch the root address, http://localhost:3000/, and you get an OpenAPI document: a machine-readable list of every endpoint it generated. Tools can read it to build documentation or client code for you.

7. Part 5: Make the setup automatic

Typing \i and NOTIFY by hand is fine for learning, but you would never rebuild a system this way. We want docker compose up to produce a working backend by itself, every time.

The Postgres image has a feature for exactly this. On its first start, it runs every .sql file it finds in a folder called /docker-entrypoint-initdb.d/. We will place our script there by mounting it: making a file from your computer appear inside the container.

Add the last line of this final init.sql. It sends the notification automatically:

init.sql (final version: one line added at the end)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE ROLE anonymous NOLOGIN;

CREATE TABLE IF NOT EXISTS candidates (
    candidate_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name         VARCHAR(100) NOT NULL,
    created_at   TIMESTAMPTZ DEFAULT now(),
    updated_at   TIMESTAMPTZ DEFAULT now()
);

GRANT USAGE ON SCHEMA public TO anonymous;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO anonymous;

INSERT INTO candidates (name) VALUES
    ('Harry'), ('Hermione'), ('Luna'), ('Ron'), ('Neville');

NOTIFY pgrst, 'reload schema';

Now replace your compose.yml with the final version. The only new part is the volumes entry under db:

compose.yml (final version for v-b0)
services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_PASSWORD: devpassword
    ports:
      - "5432:5432"
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro

  apiserver:
    image: postgrest/postgrest:v13.0.7
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      PGRST_DB_URI: "postgres://postgres:devpassword@db:5432/app"
      PGRST_DB_SCHEMA: "public"
      PGRST_DB_ANON_ROLE: "anonymous"
      PGRST_OPENAPI_SERVER_PROXY_URI: "http://127.0.0.1:3000"
    depends_on:
      - db

The mount line has three parts separated by colons: the file on your computer (./init.sql), where it appears in the container, and ro, which means read-only, so the container cannot change your file. PGRST_OPENAPI_SERVER_PROXY_URI only tells PostgREST what web address to print in its self-description.

Watch out: init scripts run only once

The folder is only read when the database starts for the first time, with an empty data directory. If a database already exists, Postgres skips the folder entirely, and editing init.sql changes nothing. This is why we must throw away the old database before starting the final version. In this lesson the command is docker compose down -v. The -v deletes the container's volumes, meaning its stored data. Only use -v when you really want to erase the data.

Start fresh and watch it come up. Press play. The recording is slowed down so you can follow each line; in real life the whole thing takes about a second once the images are downloaded.

terminal
$ docker compose down -v
$ docker compose up -d
Real recording of docker compose up -d followed by docker compose ps. Compose creates the network, creates both containers, then starts db before apiserver, which is what depends_on asked for.

Now check that it works, with no manual steps:

Real recording. The ID and time values (candidate_id, created_at) will be different on your machine. Everything else should match: five rows in the same order, and HTTP/1.1 200 OK.
Try it: checkpoint

Before you continue, confirm all three of these on your own machine.

  1. docker compose ps shows two containers, both running.
  2. curl http://localhost:3000/candidates returns five names.
  3. In psql, SELECT name FROM candidates; returns the same five names.

If any of them fail, jump to Troubleshooting before moving on.

8. Part 6: Break it on purpose

Now the most important part of this variant. You will add a record, shut the stack down the way people normally do, and watch your record vanish. We do this deliberately, because once you have felt the loss, the fix in v-b1 will make sense.

First, two ways to stop the stack

Compose has two common commands for stopping, and they do very different things:

CommandWhat happensYour data
docker compose stopStops the containers but keeps them. docker compose start brings the very same containers back.Safe
docker compose downStops the containers and removes them, along with the network.Lost in this variant
Predict: add a person named Ginny, run down then up. Will Ginny still be there?

Make your prediction first, and write it down. Then watch the recording below. A hint: think about where Postgres stores its files, and what happens to a container that is removed.

Real recording. (1) A POST request adds Ginny, and the server answers 201 Created. (2) The list now shows six names. (3) docker compose down removes the containers. (4) up -d creates new ones. (5) The list shows only the original five. Ginny is gone.

Why did this happen? PostgreSQL keeps its files in a data directory inside the container. When Compose starts a container from an image that declares a data folder but is not told where to keep it, Docker quietly attaches a fresh, unnamed storage area to that container. docker compose down removes the container, and with it the link to that storage. The next up creates a brand new container with brand new, empty storage, so init.sql runs again and you get only the original five rows.

Why it matters: ephemeral storage

Ephemeral means short-lived. Storage that is tied to a container's life is fine for a demo, and disastrous for production, where a redeploy, a restart or a server reboot could silently wipe every user's data. (Technically, the old data is still sitting in an orphaned unnamed volume that nothing will reconnect to. You can see these leftovers with docker volume ls. They waste disk space, and you cannot practically get the data back.) The fix is a named volume: storage that you name, that Docker manages outside any container, and that survives down. v-b1 adds it.

Watch out: stale data is the opposite trap

You may also meet the mirror image of this problem. If you change init.sql and run docker compose up -d without down, Compose can reuse the old storage, and your changes to init.sql are ignored because the database is no longer empty. If your database "does not match your file", run docker compose down -v and start again.

9. The seven weaknesses

None of these is an accident. The course method is break it, then fix it: you first build the naive version that most people really write, so that when you meet the fix, you understand why it exists. You have already met every item in this list. Here they are in one place, with the lesson that fixes each.

#WeaknessWhat could go wrongFixed in
1PostgREST connects as the superuserA single bad request could destroy or leak everything.v-b3
2The password is written in compose.ymlAnyone who can read the repository can read the password.v-b4
3Storage is ephemeralAll data is lost on docker compose down.v-b1
4The default network is created and deleted by ComposeThe network disappears with every down, and you cannot share it with other stacks.v-b1
5depends_on waits for start, not readinessThe API can start before the database is ready and fail.v-b2
6GRANT ALL gives anonymous users too many rightsAny visitor can modify or delete data.v-b3
7The database port 5432 is published on all network interfaces, but the API is limited to 127.0.0.1On a shared network, other machines can try to log in to your database directly.Later variants (v-b7 puts a single reverse proxy at the entrance)

Weakness 7 is easy to miss because the two ports lines look similar. Compare "5432:5432" with "127.0.0.1:3000:3000" in your file. Only the second has an address restriction.

10. Troubleshooting

Most problems in this lesson fall into a short list. Find your symptom in the left column.

SymptomLikely causeWhat to do
port is already allocated or address already in useAnother program (often a local Postgres) already uses port 5432 or 3000.Stop that program, or change the left number in the mapping, for example "5433:5432".
the input device is not a TTY when using docker exec -itGit Bash on Windows cannot give Docker an interactive terminal.Run the command from PowerShell, or prefix it with winpty.
You see ^M at the end of lines in an editor, or the file behaves oddlyThe file was saved on Windows with CRLF line endings, a hidden extra character at the end of each line.Convert to LF. In VS Code, click CRLF in the status bar and choose LF. In a Linux shell: sed -i 's/\r//g' compose.yml
The API says PGRST302 (anonymous access disabled)PGRST_DB_ANON_ROLE is missing or misspelled.Check the line in compose.yml, then run docker compose up -d again.
The API says PGRST205 (table not found in schema cache)The cache is stale, or the table does not exist.Check with \dt in psql, then run NOTIFY pgrst, 'reload schema';
The table is empty or missing after you edited init.sqlInit scripts only run on an empty database.docker compose down -v, then docker compose up -d
YAML error such as mapping values are not allowed hereWrong indentation, or a tab character.Use two spaces per level, and compare your file with the final version above.
API returns connection refused shortly after startingPostgres is not ready yet (weakness 5).Wait a few seconds and try again. Check docker compose logs db.
Try it: two commands to remember

docker compose logs apiserver shows what PostgREST is saying. docker compose logs db shows what Postgres is saying. When you are stuck, the logs usually explain why. Read them before changing anything.

Deeper: why does PostgREST use a role switch instead of its own permissions?

Most API frameworks keep permission checks in application code: "if the user is an admin, allow this". PostgREST does not. It lets PostgreSQL's own role system decide, which has two advantages. The rules live next to the data, so they cannot be bypassed by a different program that connects to the same database. And PostgreSQL has had decades of testing on exactly this job.

The way it works is that PostgREST logs in with one role, then switches to the role appropriate for each request with the SQL command SET ROLE. In this variant the "login role" is the superuser, so that switch is meaningless. That is exactly why v-b3 introduces a limited login role. You will see the mechanism in detail then.

11. Challenge: add a second table

You have followed instructions for a while. Now do something with less help. This is the point of the exercise: it should be slightly hard, and that is where the learning happens.

Challenge

Add a second table called houses to your stack. It must satisfy all of these:

  1. It has a UUID primary key column house_id (generated automatically), and a required text column name.
  2. It contains four rows: Gryffindor, Hufflepuff, Ravenclaw, Slytherin.
  3. The API serves it: curl http://localhost:3000/houses returns those four rows.
  4. It must appear automatically after docker compose down -v then docker compose up -d. No manual psql steps.

How to check your own work. After a clean restart, this command should print exactly 4:

terminal
$ curl -s http://localhost:3000/houses | grep -o '"name"' | wc -l
4

In a later release of this course, a command named minbase check v-b0 will run checks like this one automatically on your live stack and report which requirements pass. For now, you are the grader.

Hint 1: where does the new SQL go?

Everything that must exist at first start belongs in init.sql. Add your new table in that file, using candidates as a pattern.

Hint 2: it returns 404 or 401. Why?

Think about what the anonymous role is allowed to touch, and about when GRANT ... ON ALL TABLES takes effect. It applies to the tables that exist at the moment the command runs. Where in the file does your new table appear, relative to the GRANT line?

Solution (try on your own first)
-- Add this BEFORE the two GRANT lines:
CREATE TABLE IF NOT EXISTS houses (
    house_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name     VARCHAR(100) NOT NULL
);

-- Add this with the other INSERTs:
INSERT INTO houses (name) VALUES
    ('Gryffindor'), ('Hufflepuff'), ('Ravenclaw'), ('Slytherin');

The table must be created before GRANT ... ON ALL TABLES, or the anonymous role will not have access to it. The NOTIFY line stays last, so PostgREST learns about everything. If you instead added the table in a separate script that runs afterwards, you would need its own GRANT.

12. Check what you learned

These questions test understanding, not memory. After each answer you will see an explanation, whether you were right or wrong. Read it either way.

Recap

Further reading

When a term is unclear, go to the source that owns it. These are the primary references for this lesson.

The recordings in this lesson are real captures of the stack described here, made with tools/rec.py. Versions used: PostgreSQL 18.6, PostgREST 13.0.7.