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/psqlon yourPATH). - 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 declarednoinheritso that membership in those roles does not silently hand it their privileges outside of an explicitSET ROLE.web_anon— what an unauthenticated request runs as. It canselectfrom every table inapiandexecuteboth functions, but it cannot write.brewery_member— an authenticated role that can additionallyinsertintoapi.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 '...'.Bieris a supervisor, andstart_link/1links it to the calling process; the process that evaluates an-eexpression 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_memberso a client can post check-ins, whichweb_anoncannot do.