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 four 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 worker holds: pool, keys, token issuer |
| 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 worker
app.prepare { start in try await start.state(StripeClient.self).warmUp() }
app.every(3600, onWorker: 0) { _ in … } // one worker'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-worker 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 worker. Features reach it with
State<Services>rather than each holding a pool of its own -- a pool per feature is connections multiplied by workers 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, --workers, --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 |
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 worker |
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 worker, as it starts, from
app.prepare. 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. Two workers, old and new, serve at the same time through a reload.
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 worker 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 worker, and each is one thread. What follows from that, and what this application does about it:
- State is built after the fork.
app.stateruns in each worker, so the pool, the keys and the issuer exist once per process. Nothing here is shared between workers, and nothing needs a lock. - Connections multiply.
DATABASE_POOL_SIZEis per worker: 4 workers × 8 is 32 connections. Size it against PostgreSQL'smax_connections, not against one number in your head. - Start-up work goes in
app.prepare. It may await, it runs before the worker accepts anything, and a throw stops that worker rather than serving half-ready. Migrating and hashing the timing password happen there. - Shutdown goes in
app.state'sshutdown:, which closes the pool when the worker drains. - What every worker must see lives outside the process: the database, and
Topicfor anything to be heard across workers (the chat example). - Work on a timer is
app.every. This application clears ended refresh tokens hourly withonWorker: 0, so four workers do not each run the delete. Across machines, take a lock in the database instead; EXAMPLES.md shows how. - Work repeated in every worker is work done N times. A cache warmed in
prepareis warmed per worker; a scheduled job started in every worker runs N times. Usestart.index == 0to do something once, and remember that worker 0 is replaced on a reload.
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.1-noble compiles, ubuntu:noble runs with
the Swift runtime libraries copied in, as a non-root user, with a health check
on /health. --workers 0 is one worker per CPU; under a CPU limit, set it to
that limit. 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 worker serves, so a failed migration fails the unit instead of the first request.systemctl reloadreplaces the workers one at a time without dropping a connection: that is how a new binary goes out.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. With
--acme-domain, add aReadWritePathsfor the cache.
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 or --acme-domain. INSTALLATION.md covers
both, and CONFIG.md every flag.
/healthanswers while the process runs and touches no database: a supervisor should not restart a worker for a database outage it cannot fix./readytakes a connection from the pool: a load balancer should stop sending traffic to a worker that cannot reach the database.--metrics-port 9090serves Prometheus metrics, per route since this release. 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
STARTER_DATABASE_URL='postgres://garuda:[email protected]:5432/starter_test?sslmode=disable' swift testThe 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
STARTER_DATABASE_URL the database tests are skipped, so the suite still runs
anywhere.