This tutorial builds a small, read-only REST API over a brewery catalog — styles, breweries, beers, taprooms, and check-ins — using nothing but a PostgreSQL schema. You will create the database, boot a Bier instance against it two different ways, and make your first requests: listing, filtering, selecting/renaming columns, ordering, paginating, embedding a related resource, and calling a database function over HTTP.

Prerequisites

  • PostgreSQL running and reachable (createdb/psql on your PATH).
  • Elixir ~> 1.18 (Bier is developed against Elixir 1.20 / OTP 29, but any 1.18+ toolchain works for this tutorial).

Create the database

Bier never creates schema — it only introspects and serves whatever is already in PostgreSQL. brewery.sql is a complete, runnable script: three roles, an api schema with five tables and two functions, grants, and seed data. It ships inside the package, so it is at deps/bier/docs/tutorials/brewery.sql in a project that depends on Bier, and at docs/tutorials/brewery.sql in a git checkout.

createdb bier_tutorial
psql -d bier_tutorial -f docs/tutorials/brewery.sql

The script is safe to re-run against a freshly created database: its create role statements are guarded, and everything else lives in the database itself, so dropdb/createdb and loading again runs clean. To start completely over, roles included:

dropdb bier_tutorial
psql -d postgres -c "drop role if exists authenticator, web_anon, brewery_member"

The script creates three roles, mirroring PostgREST's own convention:

  • authenticator — the only role Bier ever connects to Postgres as. It is granted nothing of its own; it only switches into one of the roles below for the duration of a request. It is declared noinherit so that membership in those roles does not silently hand it their privileges outside of an explicit SET ROLE.
  • web_anon — what an unauthenticated request runs as. It can select from every table in api and execute both functions, but it cannot write.
  • brewery_member — an authenticated role that can additionally insert into api.check_ins. Using it requires a JWT, which is the subject of the Authentication tutorial — this one stays anonymous throughout.

Run Bier

Bier can run two ways: as a standalone server configured entirely from PGRST_* environment variables (no Elixir code of your own), or embedded as a supervised child of an Elixir application. Both connect to Postgres as authenticator, exactly like a real deployment — never as a superuser.

Quick path (Docker or a release)

The repository ships a Dockerfile that builds a release with BIER_STANDALONE=1 baked in, listening on port 3000 by default:

docker build -t bier .

docker run --rm -p 3000:3000 \
  -e PGRST_DB_URI="postgresql://authenticator:mysecretpassword@host.docker.internal:5432/bier_tutorial" \
  -e PGRST_DB_SCHEMAS="api" \
  -e PGRST_DB_ANON_ROLE="web_anon" \
  bier

(host.docker.internal reaches a Postgres running on your host machine from inside the container; point it at a linked db service instead if your Postgres is also containerized.)

Without Docker, build and run the same release directly:

MIX_ENV=prod mix release

BIER_STANDALONE=1 \
PGRST_DB_URI="postgresql://authenticator:mysecretpassword@localhost:5432/bier_tutorial" \
PGRST_DB_SCHEMAS="api" \
PGRST_DB_ANON_ROLE="web_anon" \
_build/prod/rel/bier/bin/bier start

Either way, PGRST_DB_URI carries the connection (including the authenticator credentials), PGRST_DB_SCHEMAS picks the schema to expose, and PGRST_DB_ANON_ROLE is the role unauthenticated requests run as. See the Configuration guide for every PGRST_* variable and the full standalone-boot story.

Elixir path

Embedding Bier means adding a {Bier, ...} child to a supervision tree, the same shape whether that is your own application or a throwaway iex session. The options below are the direct Elixir equivalent of the environment variables above:

children = [
  {Bier,
   name: MyApp.Bier,
   router: [port: 4040, scheme: :http],
   database: "bier_tutorial",
   username: "authenticator",
   password: "mysecretpassword",
   db_schemas: ["api"],
   db_anon_role: "web_anon"}
]

Supervisor.start_link(children, strategy: :one_for_one)

To try it without writing a project, start an iex session against this repository (mix deps.get first if you have not already) and call Bier.start_link/1 at the prompt:

iex -S mix
Bier.start_link(
  name: Tutorial,
  router: [port: 4040, scheme: :http],
  database: "bier_tutorial",
  username: "authenticator",
  password: "mysecretpassword",
  db_schemas: ["api"],
  db_anon_role: "web_anon"
)

Call it at the prompt, not with iex -S mix run -e '...'. Bier is a supervisor, and start_link/1 links it to the calling process; the process that evaluates an -e expression exits as soon as the expression returns, which shuts the instance down again. The boot line still prints, but nothing is listening. At the prompt the shell process is the parent and stays alive for the whole session.

Either way — child spec or iex prompt — Bier prints a Bandit boot line once the schema has been introspected and the listener is up:

Running Tutorial.Router with Bandit 1.x.y at 0.0.0.0:4040 (http)

The rest of this tutorial assumes Bier is reachable at http://localhost:4040.

Your first requests

List beers

With no filter, select still limits the response to the columns you name:

curl "http://localhost:4040/beers?select=name,abv"
[
  {"name": "Trail Crest IPA", "abv": 6.80},
  {"name": "Fog Line", "abv": 6.20},
  {"name": "Table Pils", "abv": 4.80},
  {"name": "Export Stout", "abv": 7.50},
  {"name": "DIPA v12", "abv": 8.50},
  {"name": "Desert Saison", "abv": 5.90}
]

Order and limit: the three strongest beers

order=<col>.desc sorts descending; limit caps the row count:

curl "http://localhost:4040/beers?select=name,abv&order=abv.desc&limit=3"
[
  {"name": "DIPA v12", "abv": 8.50},
  {"name": "Export Stout", "abv": 7.50},
  {"name": "Trail Crest IPA", "abv": 6.80}
]

Filter: only the bitter ones

A horizontal filter is <column>=<operator>.<value>. gte is greater-than- or-equal; the full operator table is in the API reference.

curl "http://localhost:4040/beers?ibu=gte.40&select=name,ibu"
[
  {"name": "Trail Crest IPA", "ibu": 65},
  {"name": "Fog Line", "ibu": 40},
  {"name": "Export Stout", "ibu": 55},
  {"name": "DIPA v12", "ibu": 70}
]

Select and rename columns

select=<alias>:<col> renames a column's JSON key without changing what is queried:

curl "http://localhost:4040/beers?select=beer:name,abv&limit=2"
[
  {"beer": "Trail Crest IPA", "abv": 6.80},
  {"beer": "Fog Line", "abv": 6.20}
]

Embed the brewery

A foreign key lets you pull in the related row as nested JSON, with no join to write yourself — beers.brewery_id references breweries.id, so naming breweries(...) inside select embeds it:

curl "http://localhost:4040/beers?select=name,breweries(name,city)&limit=2"
[
  {"name": "Trail Crest IPA", "breweries": {"city": "Portland", "name": "Reunion Brewing"}},
  {"name": "Fog Line", "breweries": {"city": "Portland", "name": "Reunion Brewing"}}
]

Paginate: limit/offset

curl "http://localhost:4040/beers?limit=3&offset=3&select=name"
[
  {"name": "Export Stout"},
  {"name": "DIPA v12"},
  {"name": "Desert Saison"}
]

Paginate: Range and an exact count

The Range/Range-Unit headers are an alternative to limit/offset, and Prefer: count=exact asks Bier to compute the total row count and report it in Content-Range:

curl -i "http://localhost:4040/beers?select=name" \
  -H "Range-Unit: items" -H "Range: 0-2" -H "Prefer: count=exact"
HTTP/1.1 206 Partial Content
Content-Range: 0-2/6
[
  {"name": "Trail Crest IPA"},
  {"name": "Fog Line"},
  {"name": "Table Pils"}
]

0-2/6 reads as "rows 0 through 2 of 6 total" — the response is a 206 Partial Content because the requested window is strictly smaller than the full set.

Call a function: GET /rpc/search_beers

Every function in an exposed schema is callable at /rpc/<function>; scalar arguments become query parameters. api.search_beers(term text) does an ilike search over name and description:

curl "http://localhost:4040/rpc/search_beers?term=IPA&select=name"
[
  {"name": "Trail Crest IPA"},
  {"name": "DIPA v12"}
]

Recap and next steps

You loaded a real PostgreSQL schema, ran Bier against it two different ways — standalone via PGRST_* environment variables, and embedded via a {Bier, ...} child spec — and drove it with plain HTTP: no controllers, routes, or serializers were written for any of this.

From here:

  • API reference covers the full query grammar — every filter operator, aggregates, JSON paths, computed columns, mutations, and content negotiation — using this same brewery schema.
  • Authentication picks up where this tutorial left off: minting a JWT for brewery_member so a client can post check-ins, which web_anon cannot do.