Skip to main content
The Marfa server is configured entirely via environment variables. No config file. Every variable has a sensible default — typical local-dev is zero-config.

Server

Storage

Database

SQLite is perfectly fine for single-tenant self-hosts. Postgres is recommended for multi-tenant, multi-app, high-traffic deployments.

Blobs

Cloudflare R2 example

R2 is S3-compatible. Use the dedicated R2 access key (Cloudflare dashboard → R2 → Manage API Tokens), the bucket’s account-scoped endpoint, and any region string — R2 ignores the region but the SDK requires one (auto is the convention).
The same shape works for any S3-compatible object store (MinIO, Backblaze B2, Wasabi).

Retention and lifecycle

Authentication (Better Auth + OAuth)

Account lifecycle

User-initiated account deletion runs a grace-window cascade. See Account lifecycle for the flow; the levers below tune timing.

Email transport

Transactional email (sign-up verification, forgot-password, account-deletion confirm + cancel). Default is unconfigured — email-dependent flows return HTTP 503 email_transport_not_configured until an operator picks a backend. The Cloudflare backend has no documented send-time idempotency header — a transient retry can produce duplicate deliveries. Acceptable for the transactional flows wired today (each ships a single-use token; a second send is semantically harmless).

Per-tenant quotas

Default ceilings applied to every tenant. Per-tenant overrides in tenant_quotas rows take precedence; unset = unlimited (no enforcement). Errors carry details: { resource, limit, current } so SDK / CLI / operator alerts can wire off the shape.

Connections runtime (Cloudflare control plane)

The Connections build runs Integration Workers on Cloudflare. The server reaches them through these.

Observability

Structured logs are always emitted to stdout as JSON lines. Each log carries a UUIDv7 request_id that’s also returned in the X-Request-Id response header. GET /health returns database and blob-backend reachability with latency metrics. GET /metrics (admin) returns per-tenant counts and rates.

Minimal self-host

The smallest working server:
Hits SQLite on disk, filesystem blobs in ./data/blobs, rate-limited at 1000 req/min per credential. Bootstrap the first admin key:
Capture the key field from the response. That’s your admin credential.

Production-shape self-host

Behind a reverse proxy (nginx, Caddy, Traefik) terminating TLS.

Reverse proxy

If the server sits behind a reverse proxy, the proxy must pass through:
  • The Authorization header.
  • The raw request body (unbuffered) for webhook signature verification and SSE streaming.
  • text/event-stream responses without buffering. Disable response buffering for /events.
SSE connections are long-lived. Set read timeouts to at least 120 seconds. Set TRUSTED_PROXY_CIDRS to the CIDR of your proxy so the rate limiter and any future per-IP logic see the real client IP rather than the proxy’s address. Without it, x-forwarded-for is ignored as a precaution against spoofing.

Migration scripts

One-shot scripts that ride alongside the server, used during upgrades. They take their own environment variables — set them only when running the script, not on the long-running server process. Run with pnpm --filter @withmarfa/server migrate:sync-json-to-connection. See the script source in packages/server/src/scripts/ for invocation details.

What is not configurable

  • Core types. Registered at startup from the bundled registry.
  • Reserved namespaces. core.*, system.*, and marfa.* are hard-coded and only writable by platform-flagged credentials.
These are deliberate — surfacing them as knobs would encourage drift between deployments.