Server Guide
How to run and operate a Lazypock server. The repo is a monorepo with two
parts: the core backend (Rust, axum, PostgreSQL) and the Studio admin UI (SvelteKit, built into the binary and served at /_/). Your app talks to the
server through one of the SDKs — no backend code required.
Everything is one binary: the API, auth, realtime, the TypeScript hooks
runtime, migrations and the CLI. Postgres is the source of truth and the
message bus (data, row-level security, jobs, realtime via LISTEN/NOTIFY).
Prerequisites
- Rust 1.85+ (only for running from source)
- PostgreSQL 15+ — a direct connection, not a pooler
- Docker (only for the container route) — no other system package is required
- Bun (only for the Studio dev server)
There is no ImageMagick, no external queue and no other sidecar: thumbnails and scaled images are produced in-process, or by a service you point the server at — see Image processing.
Quick start — Docker
No Rust toolchain needed. The repository root has a Dockerfile and a compose.yml that runs the server
next to Postgres, so the shortest path is:
git clone https://github.com/gnuzd/lazypock.git
cd lazypock
# set LAZYPOCK_DATABASE_URL and LAZYPOCK_SECRET_KEY in compose.yml first
docker compose up --build
# Studio: http://localhost:4000/_/ A tagged release also publishes a multi-arch image
(ghcr.io/gnuzd/lazypock), so a
deployment can pull a built artifact instead of building one:
services:
lazypock:
image: ghcr.io/gnuzd/lazypock:latest
ports: ["4000:4000"]
environment:
LAZYPOCK_DATABASE_URL: postgres://lazypock:change-me@db:5432/lazypock
# openssl rand -hex 32
LAZYPOCK_SECRET_KEY: change-me
LAZYPOCK_ENV: production
volumes: ["lazypock-data:/data"]
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: lazypock
POSTGRES_PASSWORD: change-me
POSTGRES_DB: lazypock
volumes: ["db-data:/var/lib/postgresql/data"]
volumes:
lazypock-data:
db-data: - Server + Studio admin UI:
http://localhost:4000(login at/_/) - REST API:
http://localhost:4000/api/... - The image runs as a non-root user and writes only to
/data, which the compose file mounts as a volume. Migrations run automatically on boot. - Pin a version rather than
latestin production.
To reset everything (including the database):
docker compose down -v Prefer to run the binary directly? See Run from source below.
Ready to connect an app? Follow the TypeScript SDK quick start.
Run from source
Requires Rust 1.85+ and a reachable PostgreSQL 15+ instance (Docker is fine). The CLI is the same binary you deploy.
git clone https://github.com/gnuzd/lazypock.git
cd lazypock
cp .env.example .env # then point LAZYPOCK_DATABASE_URL at your Postgres
cargo run -- migrate # create the system tables
cargo run -- config # show the resolved config (credentials redacted)
cargo run -- serve # start the API on http://localhost:4000
curl http://localhost:4000/api/health serve also applies pending
migrations on boot and loads the schema before accepting requests, so a
first run against an empty database is the whole installation step. Run lazypock migrate --status to see
what is applied.
Studio (admin UI)
A plain cargo build embeds no
Studio. To run the SvelteKit dev server against a local API:
cd studio
bun install
bun run dev # Vite dev server on http://localhost:5173 The SvelteKit dev server proxies /api requests to the Rust backend on port 4000, and serves the Studio at http://localhost:5173/_/. To
build the bundle into the binary:
cd studio && bun run build
cd ..
LAZYPOCK_STUDIO_DIR=studio/build/studio cargo run -- serve Then connect an app with one of the SDKs — see the TypeScript SDK install.
First-time setup
There is no default superuser and no default password. Any one of these three creates the first account:
- From the Studio. Open
/_/; with no superuser yet you are offered Setup instead of login. Setup only works while no superuser exists, so it cannot be replayed later. - From the CLI.
lazypock superuser create --email you@example.com --password …(or setLAZYPOCK_SUPERUSER_EMAIL/LAZYPOCK_SUPERUSER_PASSWORDto keep both out of shell history — there is no interactive prompt, and a missing value is an error). This applies migrations first, so it is safe on a brand-new database. - From the environment at boot, for a deployment with no
browser and nothing to
execinto yet: set bothLAZYPOCK_SUPERUSER_EMAILandLAZYPOCK_SUPERUSER_PASSWORDand start the server. The account is created only while none exists, so a redeploy never resets a password or makes a second one; setting only one of the two is a boot error rather than a guess. The same advisory lock as the Setup page is taken, so the two cannot race on a fresh installation.
Then log in and start creating collections — every collection you create in Studio gets an instant REST API + realtime channel + rules, ready to call from any SDK.
OAuth2 sign-in
Auth collections can sign in through an external provider. Providers are configured once, as an application setting, so no rebuild is needed:
curl -X PATCH https://your-host/api/settings -H "Authorization: Bearer <superuser token>" -H "Content-Type: application/json" -d '{"oauth2":{"providers":[{
"name": "google",
"config": {
"client_id": "...",
"client_secret": "...",
"redirect_uri": "https://your-host/api/oauth2-redirect"
}
}]}}' google and github have built-in presets (the
three provider URLs and the scopes). Any other provider names authorize_url, token_url and user_url explicitly. A provider
missing one of them is refused at use, not silently dropped.
# what the client asks for
GET /api/collections/users/auth-methods
# -> oauth2.providers: [{ name, authURL, state, codeVerifier }, ...]
# open authURL in a popup; the provider returns to
GET /api/oauth2-redirect?code=...&state=...
# exchange the code (the popup bridge posts it back to the app origin)
POST /api/collections/users/auth-with-oauth2
{ "provider": "google", "code": "...", "codeVerifier": "...",
"redirectUrl": "https://your-host/api/oauth2-redirect", "createData": {} } The first sign-in creates an account in the collection (the email comes from
the provider and is marked verified) and links it in _external_auths; later sign-ins
reuse it. createData may fill
ordinary fields, but never the email, password or verified. The PKCE flow uses S256,
the pending flow is single-use and expires after 10 minutes, and the popup
result is posted only to the origin that started it.
Outbound email — verification, password reset, sign-in codes — goes through
SMTP configured as the mail setting:
PATCH /api/settings
{
"app_name": "My App",
"mail": {
"host": "smtp.example",
"port": 587,
"encryption": "starttls",
"username": "...",
"password": "...",
"sender_name": "My App",
"sender_address": "noreply@example.com"
}
} encryption is none, starttls or tls, and the port defaults from
it (25 / 587 / 465). The configuration is read at send time,
so a change applies to the next email without a restart. Sends are queued and
retried, never made inside a request.
Templates
The subject, HTML body and plain-text alternative are rendered with MiniJinja. Built-in templates ship for otp, verification, password_reset and email_change. A file named for
the part — otp.subject, otp.html, otp.text — in LAZYPOCK_MAIL_TEMPLATES_DIR replaces it, so changing an email does not need a rebuild. A template that
uses a variable the server did not supply fails loudly rather than sending
an empty code.
The same directory can add new names for your own email: receipt.subject, receipt.html and receipt.text make a receipt template. All three parts
are required, and a name missing one is refused at boot rather than sending a
half-empty email.
Sending your own email
POST /api/mail queues one message
through the configured mailer. It accepts a superuser token or an API key — so an application server does not need a superuser
password — and it is never public: an unauthenticated caller cannot use the
deployment as a relay.
# a template (the built-in auth emails, or one your directory adds)
curl -X POST https://your-host/api/mail -H "Authorization: Bearer <superuser token or API key>" -H "Content-Type: application/json" -d '{"to":"ada@example.com","template":"receipt","assigns":{"order":"A-1042"}}'
# or a fully rendered message
curl -X POST https://your-host/api/mail -H "Authorization: Bearer <superuser token or API key>" -H "Content-Type: application/json" -d '{"to":"ada@example.com","subject":"Your order shipped","html":"<p>On the way</p>"}' The answer is { "queued": true, "id": … } — queued, not delivered. Sending happens in the job queue, which retries with
backoff; a message that cannot be delivered is recorded in the logs and the job
is retired with its reason. With a raw message text is optional: when it is omitted a
plain-text alternative is derived from the HTML. All of it needs the mail setting — an unconfigured server
refuses the request instead of queueing a job that can only fail. Studio →
Settings → Mail has a Send test email button that uses this
endpoint.
Email verification, password reset and email change
Six endpoints on an auth collection, all backed by one-time tokens in _otps. The request half
queues an email through the mailer; the confirm half applies the
change:
POST /api/collections/users/request-verification { "email": "..." }
POST /api/collections/users/confirm-verification { "token": "..." }
POST /api/collections/users/request-password-reset { "email": "..." }
POST /api/collections/users/confirm-password-reset { "token", "password", "passwordConfirm" }
# these two need a signed-in account
POST /api/collections/users/request-email-change { "newEmail": "..." }
POST /api/collections/users/confirm-email-change { "token", "password" } The request half always answers 204, whether or not the address
has an account, so it cannot be used to find out which addresses exist. A
token is 32 random bytes, valid for 24 hours and single-use. Confirming a password reset also revokes the
account's sessions, and an email change additionally requires the current
password and is applied to the address the token was mailed to, not to any
the caller supplies.
Sessions and tokens
A sign-in returns an access token and a refresh token. The access token lives 15 minutes; the refresh token lives 30 days and is rotated on every use, in a family: replaying an already-rotated refresh token is treated as theft and revokes the whole family.
POST /api/collections/users/auth-with-password
{ "identity": "ada@example.com", "password": "…" }
200 { "token": "<access, 15 min>", "refreshToken": "<30 days>", "expiresIn": 900, "record": { … } }
# exchange it — the refresh token goes in the body or as the bearer,
# and the collection must be the session's own ("_superusers" for a superuser)
POST /api/collections/users/auth-refresh
{ "refreshToken": "<refresh token>" } The TypeScript SDK and the Studio do this for you: they refresh shortly before
the access token lapses, retry a request once after a 401, and keep the session
across a reload. If you build requests by hand, read expiresIn rather than assuming a
lifetime, and send the refresh token to auth-refresh (the access token is the
wrong bearer there).
Both lifetimes are fixed constants today (`crates/auth/src/token.rs` and `crates/auth/src/sessions.rs`); there is no environment variable for them.
Filters
Every list endpoint accepts a PocketBase-compatible ?filter= expression (combined with
the collection's listRule). Clauses are field op value, joined with && (AND) and || (OR); use ! and parentheses to negate / group.
GET /api/posts?filter=(title~'hello' && published=true) || author='USER_ID' | Operator | Meaning |
|---|---|
= != | Equal / not equal |
> >= < <= | Greater / less than (or equal) |
~ !~ | Like / not like (case-insensitive, auto-wrapped in %…%) |
?= ?!= | Any element equal / not equal |
?~ ?!~ | Any element like / not like |
?> ?>= ?< ?<= | Any element compares |
Array fields (multi-select, multiple relation, multiple file)
apply a match-all constraint by default. Prefix the operator with ? for an any / at-least-one-of match:
GET /api/posts?filter=tags ?= 'news' # has the 'news' tag
GET /api/posts?filter=tags ?= 'news' && published=true
GET /api/posts?filter=tags ?~ 'new' # any tag contains 'new' Relation fields can be filtered through a dot-path — author.email = 'ada@example.com' — including multi-level paths and multi-relations. Null checks use field = null / field != null.
GET /api/posts?filter=author.email = 'ada@example.com'
GET /api/posts?filter=author.manager.name ~ 'Ada'
GET /api/posts?filter=deleted_at = null Filters work the same in collection rules and through the typed SDK
(getList(1, 20, { filter: "tags ?= 'news'" })).
View collections
A view is a read-only collection over a Postgres VIEW. Give it SQL — or the no-code
builder spec, which is rendered to SQL — and its columns are the query's:
POST /api/collections
{
"name": "published_posts",
"type": "view",
"viewQuery": "SELECT id, title, created FROM posts WHERE status = 'published'",
"rules": { "listRule": "" }
}
# the same thing, from the builder
POST /api/collections
{
"name": "titles",
"type": "view",
"viewBuilder": { "source": "posts", "fields": [{ "source": "posts", "name": "title" }] }
} The query must be one SELECT and must expose an id column, because the records API
addresses records by id. A ; is refused
rather than executed, so a definition cannot smuggle in a second statement.
Views are created WITH (security_invoker = true), so the
source tables' row-level security still applies. A view cannot carry Postgres
policies itself, so its own rules are
enforced by the query layer: an absent rule is superuser-only, "" is public, and a filter is applied to
every read. A superuser bypasses it, as it bypasses a table's policies.
What a view does not have: indexes (it uses its sources'), realtime events (a view
fires none, so a subscription is refused), expand (there is no relation metadata), and keyset paging unless the query exposes created — otherwise use page-based paging.
Writing to a view is refused: a view collection is read-only.
Connect an existing PostgreSQL database
LazyPock is designed to run on top of a database you already have —
point it at an existing Postgres (15+) instance and its tables show up
in the Studio automatically. Just set LAZYPOCK_DATABASE_URL to your
database:
export LAZYPOCK_DATABASE_URL="postgres://user:password@db-host:5432/my_existing_db"
cargo run -- serve On boot LazyPock does three things to the database:
- System migrations create its internal
_-prefixed tables (_collections,_fields,_superusers,_settings,_logs, …) plus its migration bookkeeping. These are namespaced and never touch your tables. - Adoption (reflection): every table it did not create
becomes a collection, and every column becomes a field — so your existing
tables appear in the Studio sidebar and are served through the dynamic
/api/collections/{collection}/recordsroutes immediately. This is reflection, not migration: nothing alters the existing tables, and a column lazypock has no field type for becomes text with the original type kept in its options. - Default-deny. An adopted collection starts with no rules, which means superuser-only. Adopting a table must not silently expose somebody else’s data; open it up deliberately in the Studio. Tables and schemas that are internal, or already a collection, are left alone.
Adoption is controlled by LAZYPOCK_REFLECT_EXISTING (on by
default). Row-level security needs the restricted lazypock_anon / lazypock_authenticated roles to
exist and be reachable by the connecting role; if they are not, the server refuses to start rather than serving without them, because a
request that cannot switch role fails anyway (every anonymous and
authenticated call would answer 500 while the Studio kept working). A
deployment that runs on superuser tokens alone can set LAZYPOCK_REQUIRE_RLS=off to keep
the warning instead. See README.md for the CLI
(serve, migrate, superuser, config, healthcheck).
Production deployment
Lazypock is one binary: the API and the Studio together. The repository
root has a Dockerfile that
builds it, and a compose.yml that runs it next to Postgres:
docker compose up --build
# Studio: http://localhost:4000/_/ Two variables have to be set before the stack is reachable from anywhere:
LAZYPOCK_DATABASE_URL— a direct connection. A transaction-mode pooler such as PgBouncer, and the pooled endpoint managed providers offer, are not supported: realtime needsLISTEN/NOTIFYon the direct endpoint, and a prepared statement has to survive a pool checkout.LAZYPOCK_SECRET_KEY— 32 bytes as hex (openssl rand -hex 32). It encrypts the secrets stored in the database, so losing it means those can no longer be decrypted.
Setting LAZYPOCK_SIGNING_KEY is optional, and it keeps the token signing key out of the database — and
therefore out of every dump and replica. Left unset, it is generated on
first run and kept there, encrypted with LAZYPOCK_SECRET_KEY when that
is set (a plaintext key from an older version is rewritten encrypted the
next time the server starts). A key that cannot be decrypted stops the
boot and names the variable, rather than quietly signing everybody out
with a new one.
Behind a reverse proxy, set LAZYPOCK_TRUSTED_PROXIES to the
proxy’s addresses. Without it no forwarding header is read at all, so
rate limits and request logs would key on the proxy rather than the client.
The image runs as a non-root user and writes only to /data, which compose mounts as
a volume. It needs no ImageMagick: thumbnails and
scaled images are produced in-process. A health check is built in
(lazypock healthcheck), and GET /api/health reports the
running build's version in data.version — so a probe can
answer which version is actually deployed — and data.unavailable appears only
when a subsystem (the schema watcher or the realtime listener) has failed
locally. docker run … superuser create works for first-time setup.
On Coolify, point a resource at this repository and let it build the Dockerfile, or deploy the published image. Coolify terminates TLS in front, and schedules database and volume backups.
Image processing
Resizing runs in the API process by default, using image
codecs compiled into the binary. There is no ImageMagick and no sidecar:
that is what keeps the binary self-contained. The named presets, upload
thumbnails and on-demand GET /api/files/:id/scale/:preset requests all go through the same code path.
Moving resizing out of the process
A large image decodes into peak memory in the request worker. Set LAZYPOCK_IMAGE_SERVICE_URL to
move that work — and its memory — to a service you run:
# a service that accepts the source bytes and the resize parameters
export LAZYPOCK_IMAGE_SERVICE_URL="https://images.example.com/resize"
# optional bearer token
export LAZYPOCK_IMAGE_SERVICE_TOKEN="$(openssl rand -hex 32)"
# a thin binary with no codecs of its own: the service does all resizing
cargo build --release --no-default-features The request is POST <url>?width=…&height=…&fit=…&format=…&quality=… with the source bytes as the body and the encoded image as the response. fit is one of cover|contain|fill|inside|outside, format one of webp|png|jpeg|avif. A failure is never silently fallen back to the local codecs: a
deployment that pointed at a service did so to keep the CPU elsewhere. The
full contract is in crates/files/src/image_service.rs.
Which variants exist, their sizes, and whether they are generated during
upload (eager) or on first request (lazy) is configured with _image_presets in the Studio —
see File storage & images.
Environment variables
Only these LAZYPOCK_-prefixed
names are read. A value that cannot be parsed is refused at boot rather
than silently defaulted. The same list lives in .env.example.
Only LAZYPOCK_DATABASE_URL is
required — everything else has a default, so a server starts with a
database URL and nothing more. The exceptions are per row: an S3 bucket needs
its credentials, and a stored secret needs LAZYPOCK_SECRET_KEY before it can
be saved.
The environment supplies defaults; the database overrides them
There are three kinds of setting, and they behave differently:
- Bootstrap. The database URL, listen address, pool size, log level and the secrets. There is no other source — these are read from the environment and nowhere else.
- Defaults for a setting the database also holds. File
storage is the one case today:
LAZYPOCK_FILES_*,LAZYPOCK_S3_*andLAZYPOCK_IMAGE_SERVICE_*supply the starting configuration, and a value saved in Studio → Settings → Storage wins over all of them — and takes effect without a restart. Leave them unset and configure storage in the UI once, or set them so a deployment is reproducible from its environment alone. - Database only. CORS origins, SMTP credentials, cron jobs, API keys, collection rules and image presets have no environment variable at all; they are edited in the Studio.
One direction only: the environment is the default, the database is the
override. deploy/compose.example.yml is a worked deployment showing how they fit together.
Core
| Variable | Description | Default / example | Required? |
|---|---|---|---|
LAZYPOCK_DATABASE_URL | Direct Postgres connection string (required) | postgres://user:pass@host:5432/lazypock | yes |
LAZYPOCK_ENV | development / production / test | development | no |
LAZYPOCK_LISTEN_ADDR | Address the HTTP server binds to | 0.0.0.0:4000 | no |
LAZYPOCK_DATA_DIR | Local data directory (local files, outbox spool) | data | no |
LAZYPOCK_HOOKS_DIR | Directory for TypeScript hook files | hooks | no |
LAZYPOCK_LOG_LEVEL | trace|debug|info|warn|error | info | no |
LAZYPOCK_LOG_FORMAT | text / json | text (json in production) | no |
LAZYPOCK_POOL_SIZE | Postgres pool per node (1-64); total connections = nodes × (pool + 1) | 5 | no |
LAZYPOCK_PG_PREPARED_STATEMENTS | Use named prepared statements | true | no |
LAZYPOCK_UPLOAD_MAX_BYTES | Maximum accepted upload size, in bytes | 5242880 (5 MiB) | no |
LAZYPOCK_PUBLIC_URL | Public base URL when it differs from the host | — | no |
Request handling
| Variable | Description | Default / example | Required? |
|---|---|---|---|
LAZYPOCK_TRUSTED_PROXIES | Peers whose forwarding headers are believed (address or CIDR, comma separated). Empty reads no header at all. | — | no |
LAZYPOCK_RATE_LIMIT_AUTH | Requests/min per address that spend credentials; 0 disables | 20 | no |
LAZYPOCK_RATE_LIMIT_API | Requests/min per address for everything else | 600 | no |
LAZYPOCK_RATE_LIMIT_ACCOUNT | Failed sign-ins per account before a 15-minute lockout. Only failures count; 0 disables it. | 10 | no |
Secrets
| Variable | Description | Default / example | Required? |
|---|---|---|---|
LAZYPOCK_SECRET_KEY | AES-256-GCM key for the secrets stored in _settings: the storage
secret, and the access-token signing key when that is not supplied
below. Required before a storage secret can be saved | openssl rand -hex 32 | no |
LAZYPOCK_SIGNING_KEY | Access-token signing key, kept out of the database entirely.
Base64 of 32 random bytes, or base64 of the PKCS#8 key — the der value — copied from
an installation's auth.signing_key setting. Without it the key is generated into _settings, encrypted
with LAZYPOCK_SECRET_KEY when that is set | openssl rand -base64 32 | no |
LAZYPOCK_SUPERUSER_EMAIL | With the password below, creates the first superuser at boot —
only while none exists. Also read by lazypock superuser create. There is no default account and no default password | you@example.com | no |
LAZYPOCK_SUPERUSER_PASSWORD | The first superuser's password. Set both of this pair or neither: half of one is a boot error | — | no |
File storage and images
| Variable | Description | Default / example | Required? |
|---|---|---|---|
LAZYPOCK_FILES_ADAPTER | local / s3; a saved Studio
setting overrides it | local | no |
LAZYPOCK_FILES_DIR | Root directory for the local adapter | data/files | no |
LAZYPOCK_FILES_PUBLIC_URL | Public/CDN base URL for served objects | — | no |
LAZYPOCK_S3_ENDPOINT, _BUCKET, _REGION, _ACCESS_KEY, _SECRET_KEY | S3/R2 backend. All required when the adapter is s3. | region: auto | no (S3 only) |
LAZYPOCK_FILES_PREFIX | Namespace every object key is written under. Set it when more than one deployment shares a bucket: the orphan sweeper lists only this prefix. | — | no |
LAZYPOCK_FILES_FORCE_REMOTE | Refuse the local adapter at boot, and refuse saving one in the Studio (a saved setting overrides the environment). | off | no |
LAZYPOCK_PRESIGN_TTL | Presigned URL lifetime, in seconds (1 s-7 days) | 900 | no |
LAZYPOCK_IMAGE_SERVICE_URL | Resize in this service instead of in-process | — | no |
LAZYPOCK_IMAGE_SERVICE_TOKEN | Bearer token sent to that service | — | no |
Background workers
| Variable | Description | Default | Required? |
|---|---|---|---|
LAZYPOCK_REFLECT_EXISTING | Adopt tables lazypock did not create as superuser-only collections
(on/off) | on | no |
LAZYPOCK_REQUIRE_RLS | Fail boot when the restricted roles cannot be switched to. Set off only for a
superuser-only deployment, where every end-user request would fail
anyway | on | no |
LAZYPOCK_CRON_INTERVAL | Cron scheduler tick, in seconds; 0 disables it (loudly) | 30 | no |
LAZYPOCK_JOB_INTERVAL | Job-queue worker tick, in seconds; 0 disables it (loudly) | 5 | no |
LAZYPOCK_JOB_LEASE | How long a job claim is trusted before it is handed back, in seconds | 300 | no |
LAZYPOCK_FILE_REAPER_INTERVAL | File-reaper tick, in seconds; 0 disables it (loudly) | 3600 | no |
LAZYPOCK_IMPORT_CONFIRM_RECORDS | Records above which an import must be confirmed | 10000 | no |
Two further variables are CLI/build-time and are not read by a running
server: LAZYPOCK_STUDIO_DIR (a prebuilt Studio bundle to embed) and LAZYPOCK_SUPERUSER_PASSWORD (for superuser create).