L Lazypock
Server · Rust + axum + PostgreSQL

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 latest in 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:

  1. 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.
  2. From the CLI. lazypock superuser create --email you@example.com --password … (or set LAZYPOCK_SUPERUSER_EMAIL / LAZYPOCK_SUPERUSER_PASSWORD to 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.
  3. From the environment at boot, for a deployment with no browser and nothing to exec into yet: set both LAZYPOCK_SUPERUSER_EMAIL and LAZYPOCK_SUPERUSER_PASSWORD and 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.

Email

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'
OperatorMeaning
= !=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:

  1. System migrations create its internal _-prefixed tables (_collections, _fields, _superusers, _settings, _logs, …) plus its migration bookkeeping. These are namespaced and never touch your tables.
  2. 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}/records routes 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.
  3. 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 needs LISTEN/NOTIFY on 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_* and LAZYPOCK_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

VariableDescriptionDefault / exampleRequired?
LAZYPOCK_DATABASE_URLDirect Postgres connection string (required)postgres://user:pass@host:5432/lazypockyes
LAZYPOCK_ENVdevelopment / production / testdevelopmentno
LAZYPOCK_LISTEN_ADDRAddress the HTTP server binds to0.0.0.0:4000no
LAZYPOCK_DATA_DIRLocal data directory (local files, outbox spool)datano
LAZYPOCK_HOOKS_DIRDirectory for TypeScript hook fileshooksno
LAZYPOCK_LOG_LEVELtrace|debug|info|warn|errorinfono
LAZYPOCK_LOG_FORMATtext / jsontext (json in production)no
LAZYPOCK_POOL_SIZEPostgres pool per node (1-64); total connections = nodes × (pool + 1)5no
LAZYPOCK_PG_PREPARED_STATEMENTSUse named prepared statementstrueno
LAZYPOCK_UPLOAD_MAX_BYTESMaximum accepted upload size, in bytes5242880 (5 MiB)no
LAZYPOCK_PUBLIC_URLPublic base URL when it differs from the host—no

Request handling

VariableDescriptionDefault / exampleRequired?
LAZYPOCK_TRUSTED_PROXIESPeers whose forwarding headers are believed (address or CIDR, comma separated). Empty reads no header at all.—no
LAZYPOCK_RATE_LIMIT_AUTHRequests/min per address that spend credentials; 0 disables20no
LAZYPOCK_RATE_LIMIT_APIRequests/min per address for everything else600no
LAZYPOCK_RATE_LIMIT_ACCOUNTFailed sign-ins per account before a 15-minute lockout. Only failures count; 0 disables it.10no

Secrets

VariableDescriptionDefault / exampleRequired?
LAZYPOCK_SECRET_KEYAES-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 savedopenssl rand -hex 32no
LAZYPOCK_SIGNING_KEYAccess-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 setopenssl rand -base64 32no
LAZYPOCK_SUPERUSER_EMAILWith 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 passwordyou@example.comno
LAZYPOCK_SUPERUSER_PASSWORDThe first superuser's password. Set both of this pair or neither: half of one is a boot error—no

File storage and images

VariableDescriptionDefault / exampleRequired?
LAZYPOCK_FILES_ADAPTERlocal / s3; a saved Studio setting overrides itlocalno
LAZYPOCK_FILES_DIRRoot directory for the local adapterdata/filesno
LAZYPOCK_FILES_PUBLIC_URLPublic/CDN base URL for served objects—no
LAZYPOCK_S3_ENDPOINT, _BUCKET, _REGION, _ACCESS_KEY, _SECRET_KEYS3/R2 backend. All required when the adapter is s3.region: autono (S3 only)
LAZYPOCK_FILES_PREFIXNamespace 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_REMOTERefuse the local adapter at boot, and refuse saving one in the Studio (a saved setting overrides the environment).offno
LAZYPOCK_PRESIGN_TTLPresigned URL lifetime, in seconds (1 s-7 days)900no
LAZYPOCK_IMAGE_SERVICE_URLResize in this service instead of in-process—no
LAZYPOCK_IMAGE_SERVICE_TOKENBearer token sent to that service—no

Background workers

VariableDescriptionDefaultRequired?
LAZYPOCK_REFLECT_EXISTINGAdopt tables lazypock did not create as superuser-only collections (on/off)onno
LAZYPOCK_REQUIRE_RLSFail boot when the restricted roles cannot be switched to. Set off only for a superuser-only deployment, where every end-user request would fail anywayonno
LAZYPOCK_CRON_INTERVALCron scheduler tick, in seconds; 0 disables it (loudly)30no
LAZYPOCK_JOB_INTERVALJob-queue worker tick, in seconds; 0 disables it (loudly)5no
LAZYPOCK_JOB_LEASEHow long a job claim is trusted before it is handed back, in seconds300no
LAZYPOCK_FILE_REAPER_INTERVALFile-reaper tick, in seconds; 0 disables it (loudly)3600no
LAZYPOCK_IMPORT_CONFIRM_RECORDSRecords above which an import must be confirmed10000no

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).