Accounts and notes on PostgreSQL: configuration read from the environment, migrations, JWT access tokens with rotating refresh tokens, cursor paging, OpenAPI, health and readiness, tests, and a deployment recipe.
It is meant to be copied. The other examples each show one thing; this one shows the shape of a whole application, so the structure is the point as much as the code.
cd Examples
createdb starter
export DATABASE_URL='postgres://garuda:[email protected]:5432/starter?sslmode=disable'
swift run starter env # what the environment says
swift run starter migrate # apply migrations and exit
swift run starter serve -- --port 8080 # servecurl -s localhost:8080/auth/signup -d '{"email":"[email protected]","password":"correct horse"}'
TOKENS=$(curl -s localhost:8080/auth/login -d '{"email":"[email protected]","password":"correct horse"}')
ACCESS=$(printf '%s' "$TOKENS" | sed 's/.*"access_token":"\([^"]*\)".*/\1/')
REFRESH=$(printf '%s' "$TOKENS" | sed 's/.*"refresh_token":"\([^"]*\)".*/\1/')
curl -s localhost:8080/notes -H "authorization: Bearer $ACCESS" -d '{"title":"First","body":"hello"}'
curl -s "localhost:8080/notes?limit=20" -H "authorization: Bearer $ACCESS"
curl -s localhost:8080/auth/refresh -d "{\"refresh_token\":\"$REFRESH\"}"
curl -s localhost:8080/auth/logout -d "{\"refresh_token\":\"$REFRESH\"}" -o /dev/null -w '%{http_code}\n'Open http://localhost:8080/docs for Swagger UI over the generated document.
| Method | Path | Needs | Answers |
|---|---|---|---|
POST |
/auth/signup |
201 the account, 409 taken, 422 invalid, 403 closed | |
POST |
/auth/login |
200 a token pair, 401 | |
POST |
/auth/refresh |
200 a new pair, 400 invalid_grant |
|
POST |
/auth/logout |
204, and safe to repeat | |
POST |
/auth/logout-all |
access token | 204 |
GET |
/auth/me |
access token | 200 the account, its role included |
GET |
/notes?limit=&before= |
access token | 200 a page, newest first |
POST |
/notes |
access token | 201 the note, 422 |
GET |
/notes/:id |
access token | 200, 404 |
PATCH |
/notes/:id |
access token | 200, 404, 422 |
DELETE |
/notes/:id |
access token | 204, 404 |
GET |
/admin/accounts?limit=&before= |
an administrator | 200 a page, 401, 403 |
PUT |
/admin/accounts/:id/role |
an administrator | 200 the account, 401, 403, 404, 409, 422 |
GET |
/health |
200 always, while the process runs | |
GET |
/ready |
200, or 503 when the database does not answer | |
GET |
/docs, /docs/openapi.json |
the API, documented |
| File | What lives there |
|---|---|
| Configuration.swift | every environment variable, read and checked once |
| Services.swift | what a shard holds: pool, keys, token issuer; withServices for work outside the server |
| Schema.swift | migrations, append-only |
| Accounts.swift | sign-up, login, refresh, logout |
| Notes.swift | what an account owns |
| Admin.swift | the routes only an administrator reaches |
| StarterApp.swift | the application: state, start-up, middleware, routes |
| starter/main.swift | serve, migrate and env |
| StarterTests.swift | the whole thing through app.test |
starterApp(configuration) returns the Application, and main.swift only
chooses what to do with it. That is what makes the tests exercise the same
routes the executable serves.
Two features fit in two files. Twenty need a rule, and this is the one to copy: a feature is a function, and what it contributes is registered in one place.
// Notes.swift: routes only, so the feature is a value.
func noteRoutes() -> Router {
let app = Router()
app.get("") { … }
return app
}
// StarterApp.swift
app.nest("/notes", noteRoutes())A feature that needs more than routes takes the application and registers it all together -- there is nothing else to declare it to:
// Billing.swift
func addBilling(_ app: Application, _ configuration: StarterConfiguration) {
app.state { _ in try StripeClient(configuration.stripeKey) } // per shard
app.onShardStart { start in try await start.state(StripeClient.self).warmUp() }
app.every(.seconds(3600), onShard: 0) { _ in … } // one shard's job
app.nest("/billing", billingRoutes())
}- Routes as a
Routerwhen the feature only serves requests: it can be mounted under any prefix, mounted twice, and tested on an application of its own.Notes.swiftis this shape. - A function on
Applicationwhen the feature also needs per-shard state, start-up work, a timer or middleware, because those belong to the application.Accounts.swiftis this shape, since it needs the configuration. - Migrations stay in one list (Schema.swift), not one per feature: the order they ran in is what the version counts, and two lists cannot agree on an order. A feature's tables go at the end of the one list.
- Shared services in one value (Services.swift),
built once per shard. Features reach it with
State<Services>rather than each holding a pool of its own -- a pool per feature is connections multiplied by shards multiplied by features. - Tests per feature, through
app.teston the whole application, so what is tested is what is served. ARouterfeature can also be tested alone:let app = Application(); app.merge(noteRoutes()).
Nothing here is a framework mechanism to learn: a module is a function, and
Router is the value it can hand back. There is no container to register with
and no scan at start-up, so what an application is made of is what its one
file says it is.
Garuda's own concerns stay on the command line, because the server parses them
and CONFIG.md documents them: --port, --shards, --tls-*,
--rate-limit, --access-log, --max-body. The application's concerns are
environment variables, because a deployment sets them as secrets:
| Variable | Required | Default |
|---|---|---|
APP_ENV |
no | development; production refuses guesses |
DATABASE_URL |
in production | postgres://garuda:[email protected]:5432/starter?sslmode=disable |
JWT_PRIVATE_KEY |
in production | a key made up for the run, and shared with every shard through the environment |
JWT_PRIVATE_KEY_FILE |
no | read in place of JWT_PRIVATE_KEY, for a mounted secret |
ACCESS_TOKEN_SECONDS |
no | 900 |
REFRESH_TOKEN_DAYS |
no | 14 |
SESSION_DAYS |
no | 90 |
SIGNUPS_OPEN |
no | true |
ADMIN_EMAILS |
no | none; [email protected],[email protected] are made administrators at start-up |
DATABASE_POOL_SIZE |
no | 8, per shard |
DOCS_PATH |
no | /docs; off serves neither |
AppEnvironment (Garuda) does the reading and collects the problems;
Configuration.swift says what the
variables are, what they default to, and which combinations make no sense.
CONFIG.md has the readers.
Three things this pattern is built around:
- Everything is read before anything is served.
fromEnvironmentreturns a value or throws with every problem listed, so a missing secret is one restart, not five. - Production is refused defaults. No database URL, no signing key, and no
sslmode=disable— each is an error rather than something guessed. - The checks are about sense, not just types. An access token that outlives its refresh token, or a refresh token that outlives its session, is refused.
Make a signing key with:
openssl ecparam -genkey -name prime256v1 -noout -out jwt.pem
export JWT_PRIVATE_KEY_FILE=$PWD/jwt.pemKeep it. It signs every access token: a new key signs out everyone.
Schema.swift is a list that only grows. Each entry is the statements of one migration, applied together in one transaction, and the database records how many have run.
public let starterMigrations: [[String]] = [
["create table users (...)"],
["create table notes (...)", "create index notes_user_id on notes (user_id, id desc)"],
PostgresRefreshTokenStore.schema(),
]- Append; never edit. A migration that has run will not run again, so an edit reaches nobody's database but a new one's.
- Two ways to run them, and both are safe:
swift run starter migrate— for a deployment that migrates before it rolls out new code.- Every shard, as it starts, from
app.onShardStart. The first to take PostgreSQL's advisory lock migrates; the rest wait on that lock and find nothing to do.
- A database ahead of the binary is an error, not a migration backwards. A rolled-back deployment does not drop columns.
- Write migrations that suit a rolling deployment: add a column before the code that writes it, stop writing a column before dropping it. Old and new code serve at the same time while a deployment rolls out across machines.
An account is a member or an admin, and
Admin.swift is guarded in two lines:
app.authenticate(jwt: AccessClaims.self) // whose request it is
app.authorize(jwt: AccessClaims.self, .admin) // whether they mayNo handler there checks anything about who is asking. A member's token is
refused 403 {"error":"this route needs an administrator"}, a request with no
token is 401 with the challenge, and both appear in the OpenAPI document for
every route in the group without either route saying so. The rule itself is a
value, named once and testable without a server:
extension Policy where Value == AccessClaims {
static let admin = Policy(needs: "an administrator") { $0.role == "admin" }
}The role is in the access token, so guarding a route costs no lookup. The
price is staleness, and it is bounded twice: a token lives
ACCESS_TOKEN_SECONDS (15 minutes by default), and a change of role also ends
that account's sessions, so its next refresh is refused and signing in again
mints a token that tells the truth. Put the role in the token when a route may
act on a 15-minute-old answer; look it up per request when it may not.
The first administrator comes from the deployment. A new database has none,
and a route that promotes whoever asks is not a route, so ADMIN_EMAILS names
them and every shard applies it at start-up:
That list only ever promotes. A list that also demoted would undo an administrator's work at the next restart, so it is a floor rather than the whole truth — and an account in it cannot be demoted through the API, which answers 409 saying to take it out of the list first.
The last administrator cannot be demoted. That is the one rule a policy
cannot hold: whether this demotion leaves any is a question for the database.
It is asked inside the transaction, with the row locked (select … for update), so two administrators demoting each other at the same moment cannot
both pass, and it answers 409 rather than 403 — nothing about who is asking is
wrong.
Garuda runs a process per shard, and each shard serves on several threads of its own, Vega's work-stealing workers. What follows from that, and what this application does about it:
- State is built as each shard starts.
app.stateruns in each shard, so the pool, the keys and the issuer exist once per process. Nothing here is shared between shards. Within a shard every thread reads it, so it isSendable, and nothing in it changes once built, so it needs no lock. - Connections multiply.
DATABASE_POOL_SIZEis per shard: 4 shards × 8 is 32 connections. Size it against PostgreSQL'smax_connections, not against one number in your head. - State that takes work to build is built in
app.state, which may await. The hash a login for an unknown email is checked against is made there. - Start-up work goes in
app.onShardStart. It may await, it runs before the shard accepts anything, and a throw stops that shard rather than serving half-ready. Migrating and naming the administrators happen there. - Shutdown goes in
app.state'sshutdown:, which closes the pool when the shard has drained. - What every shard must see lives outside the process: the database, and
Topicfor anything to be heard across shards (the chat example). The signing key is the same in every shard becausestarter servemakes it before the shards start, and puts it in the environment. - Work on a timer is
app.every. This application clears ended refresh tokens hourly withonShard: 0, so four shards do not each run the delete. Across machines, take a lock in the database instead; EXAMPLES.md shows how. - Work repeated in every shard is work done N times. A cache warmed in
onShardStartis warmed per shard; a scheduled job started in every shard runs N times. Usestart.shard.index == 0to do something once, and remember that shard 0 is replaced on a reload. - Work outside the server --
starter migrate, and the tests' set-up -- goes throughwithServices, which builds the sameServiceson a Vega runtime of its own and closes them after.
swift build -c release --product starterThe binary needs the Swift runtime libraries and libssl, libz and libstdc++ (INSTALLATION.md lists them and how to ship them).
deploy/Dockerfile builds from the repository root, because
Examples/ depends on the Garuda checkout beside it:
docker build -f Examples/deploy/Dockerfile -t starter .
docker run --rm -p 8080:8080 \
-e APP_ENV=production \
-e DATABASE_URL='postgres://starter:secret@db:5432/starter?sslmode=require' \
-e JWT_PRIVATE_KEY="$(cat jwt.pem)" \
starterIt is a two-stage build: swift:6.3-noble compiles, ubuntu:noble runs with
the Swift runtime libraries copied in, as a non-root user, with a health check
on /health. --shards auto lets Vega choose the shard processes from the
machine's CPUs; under a CPU limit, give --shards and --threads that fit
it. docker run ... starter migrate applies the schema without serving.
deploy/starter.service and deploy/env.example:
install -m 755 .build/release/starter /usr/local/bin/starter
install -m 644 Examples/deploy/starter.service /etc/systemd/system/
install -m 640 -o root -g starter Examples/deploy/env.example /etc/starter/env
systemctl daemon-reload && systemctl enable --now starterExecStartPre=starter migratemigrates before any shard serves, so a failed migration fails the unit instead of the first request.systemctl restartputs out a new binary: the health check fails for--drain-delay, then the shards finish what they are serving.systemctl reloadreplaces the shards one at a time without dropping a connection, but they are forks of the running process, so it runs the code already running.TimeoutStopSecis above--drain-delayplus--graceful-timeout.- The unit takes away what the application does not need: no new privileges, a read-only system, no home, restricted address families.
Bind to loopback behind a reverse proxy, and trust its headers with
--forwarded-allow-ips, or serve TLS directly with --tls-cert and
--tls-key, and --tls-reload to pick up a renewed certificate. INSTALLATION.md covers
both, and CONFIG.md every flag.
/healthanswers while the process runs and touches no database: a supervisor should not restart a shard for a database outage it cannot fix. The deployment serves it with--health-check-path /health, which answers before routing and fails during--drain-delay./readytakes a connection from the pool: a load balancer should stop sending traffic to a shard that cannot reach the database.--metrics-port 9090serves Prometheus metrics. Keep that port off the internet.--access-logwrites one line per request, and--request-idadds an id to carry into your own logs.
This is a starter, not a finished service. What it deliberately leaves out:
- Email: confirming an address, and resetting a password.
- Rate limits per account.
--rate-limitis per address, which is the floor. - Roles, and anything an administrator does.
- Deleting an account, and exporting what it holds.
- Backups, and a tested restore.
cd Examples
GARUDA_POSTGRES=127.0.0.1:46432:vega:vega-secret:vega swift testGARUDA_POSTGRES is the server Garuda's own database tests use, as
host:port:user:password:database (vega-db's scripts/postgres-test-server.sh
starts one). The tests make a database of their own on it,
garuda_starter_example, and drop and make tables there.
The tests drive the real engine through app.test, against a real PostgreSQL:
sign-up and its refusals, login timing, the token rotation and reuse
detection, ownership (another account gets 404, not 403), paging, health and
the generated document. The configuration tests need no database. Without
GARUDA_POSTGRES the database tests are skipped, so the suite still runs
anywhere.