Your Own Server
Learn how to run Jazz in your own Node server, VM or container, with the data in SQLite on its disk.On your own machine, Jazz is part of your backend's process, with the data in a directory on its disk.
This page sets up your main backend: the backend that runs createJazz, serves sync to your
browsers and apps, and decides who each user is. Other trusted machines reach Jazz through it as
backend clients.
Install
Terminalnpm install jazz-tools
This installs Jazz 2, which is in alpha and leaves alpha soon. Versions before 2.0 are
Classic Jazz, which has a different API: projects on jazz-tools 0.x
and guides that use co.map, CoValue or Group are Classic.
Create Jazz once
jazz.server.tsimport { createJazz, sqlite } from "jazz-tools/backend";import { app } from "./schema";import permissions from "./permissions";export const jazz = createJazz({app,permissions,store: sqlite({ path: process.env.JAZZ_DATA_DIR ?? "./data/jazz" }),authenticate: async (request) => /* your session */ null,});
appandpermissionsare the ones your code imports. The bundle that's running is the bundle Jazz enforces, so rolling back your backend rolls back its permissions too.authenticate(request)reads your own session (a cookie, a header) and returns{ userId, claims? }, ornullwhen signed out. It is the only place that decides who a user is: sign-ins, sync connections andforReqUserall ask it. There's no Jazz token in the browser. If your provider issues JWTs,fromJWT()is a ready-madeauthenticate. See How Auth Works in Jazz.- Admin and backend routes never leave the process: from outside, only sync and sign-in are
reachable under
/jazz. createJazzopens nothing when your module loads: the store opens on first use, such as the first request orjazz.dbcall, and there's one Jazz per store per process. So it's safe at a module's top level everywhere, including Next.js, whose build and dev workers import your modules too.await jazz.ready()opens it right away, for a server that should fail at startup rather than on its first request.
The examples on this page are ES modules and use top-level await, so the backend package needs
"type": "module" (or .mts files). In a CommonJS package, such as an Expo app's, a default import
like permissions arrives as { default }, and createJazz fails with "permissions.ts defines
permissions for unknown table(s): default". Run the backend from its own ESM package instead.
Large values
Large values, such as files and long streams, are stored in chunks. Rows and the chunk index always
stay in SQLite. By default sqlite() keeps the chunks as files in <path>/blobs. Choose where they
go with blobs:
import { files, s3, sqlite } from "jazz-tools/backend";sqlite({ path: "./data/jazz" }); // chunks in ./data/jazz/blobssqlite({ path: "./data/jazz", blobs: files({ dir: "/mnt/blobs" }) }); // a directory of your choicesqlite({ path: "./data/jazz", blobs: s3() }); // an S3-compatible bucketsqlite({ path: "./data/jazz", blobs: "sqlite" }); // inside the SQLite database (not recommended)
"sqlite" works, but the database file grows with every large value.
s3() works with AWS S3, Cloudflare R2 and MinIO. All its options are optional:
| Option | Default | What it does |
|---|---|---|
bucket | S3_BUCKET_NAME | The bucket |
region | AWS_REGION | The bucket's region; auto for R2 |
endpoint | AWS | Another S3-compatible endpoint, such as R2's |
prefix | jazz/<store id>/ | Prepended to every object name |
// Cloudflare R2s3({bucket: "my-app",region: "auto",endpoint: "https://<account>.r2.cloudflarestorage.com",});
The backend reads its credentials from AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or from
AWS_ROLE_ARN with AWS_WEB_IDENTITY_TOKEN_FILE. Each chunk is uploaded with its SHA-256, so the
bucket rejects a corrupted upload. One bucket can serve many apps and environments: each data
directory keeps its objects under its own prefix, claims that prefix the first time it uses it, and
deletes the chunks it no longer needs. Another data directory is refused on a prefix that is
already claimed.
The data directory records its choice in <path>/blob-store. You can move an existing directory
from "sqlite" to files() or s3() later; any other switch is not supported yet and refuses to
start, so treat it as a one-way choice. A blob directory belongs to exactly one data directory, so two data directories can't share
one. To copy a data directory, give the copy its own blob directory or prefix and delete its
store-id and blob-store files before it first starts, or it claims the original's.
History keeps every version of a row, so deleting rows doesn't free their large values, and there is no way to erase them yet. Expect blob storage to only grow.
Mount it
app/jazz/[...path]/route.tsimport { jazz } from "@/jazz.server";export const { GET, POST, DELETE } = jazz.next();
Install ws and @vercel/functions, and add serverExternalPackages: ["jazz-napi"] to
next.config.ts so Next doesn't bundle Jazz's native module; next dev needs all three. On
Vercel, sync uses a WebSocket (through upgradeWebSocket from @vercel/functions). Under plain
next dev and next start, which can't upgrade sockets, the browser switches to an HTTP stream
on its own. With sqlite() (not vercel()), also install @vercel/sandbox for now: Next
resolves it while building even though sqlite() never loads it.
A custom Next server can't use
jazz.attach(server) yet: Next's own upgrade handler closes the /jazz sockets. Leave sockets to
the HTTP stream there.
In a server component or server action there is no Request. forReqUser only hands the
request to your authenticate, so build one from the incoming headers:
import { headers } from "next/headers";import { jazz } from "@/jazz.server";const db = await jazz.forReqUser(new Request("http://localhost", { headers: await headers() }));
Self-hosted on sqlite(), import jazz.server.ts only from the Next app itself. A custom server
file or a script that opens the same data directory in another process is refused (the
directory is locked to one process).
server.tsimport type { Server } from "node:http";import { Hono } from "hono";import { serve } from "@hono/node-server";import { jazz } from "./jazz.server";const api = new Hono();api.all(`${jazz.basePath}/*`, (c) => jazz.handle(c.req.raw));const server = serve({ fetch: api.fetch, port: 3000 });await jazz.attach(server as Server); // WebSocket upgrades on /jazz (needs `ws`)
Install ws too: attach loads it, and without it the server fails at startup with
ERR_MODULE_NOT_FOUND. serve() is typed as a server that may be HTTP/2, so attach needs the
Server cast.
server.tsimport express from "express";import { jazz } from "./jazz.server";const api = express();api.use((req, res, next) => jazz.nodeHandler(req, res, next)); // before any body parserapi.use(express.json());const server = api.listen(3000);await jazz.attach(server); // WebSocket upgrades on /jazz (needs `ws`)
Mount Jazz before express.json() or any other body parser: Jazz reads sign-in bodies itself, and
a parser that has already read one makes sign-in fail with a 500. forReqUser takes a Fetch
Request; from Express, build one with the cookies your authenticate reads:
const request = new Request(new URL(req.originalUrl, `http://${req.headers.host}`), {headers: new Headers(req.headers as Record<string, string>),});const db = await jazz.forReqUser(request);
Express 5 turns an error thrown in a route into a 500 HTML page. Map signed-out requests to a 401 with an error handler after your routes:
import { isRequestAuthenticationError } from "jazz-tools/backend";api.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {if (isRequestAuthenticationError(err)) return res.sendStatus(401);next(err);});
On Fastify, mount Jazz in an onRequest hook, not a route: a route rejects sign-in's empty JSON
body with a 400 before your handler runs. For URLs under jazz.basePath, call reply.hijack(),
then jazz.nodeHandler(request.raw, reply.raw).
server.tsimport { jazz } from "./jazz.server";import { app } from "./schema";Bun.serve({port: 3000,routes: {"/api/todos": async (request) => {const db = await jazz.forReqUser(request);return Response.json(await db.all(app.todos));},},fetch: jazz.handle, // sync and sign-in under /jazz});
This syncs over an HTTP stream: the browser switches to it on its own. Bun closes idle
connections after 10 seconds by default; raise idleTimeout if streams drop.
For WebSockets, set upgradesSockets: true in createJazz, call server.upgrade(request) for
requests under jazz.basePath that ask for an upgrade, and hand each opened socket to
jazz.acceptSocket(request, socket). It takes a socket of the ws package's shape (send,
close, and on("message" | "close" | "error")), so pass a small adapter around Bun's
ServerWebSocket.
server.tsimport { serve } from "@hono/node-server";import { Effect, ManagedRuntime } from "effect";import { HttpRouter, HttpServerResponse } from "effect/http";import { Jazz, JazzBackend } from "jazz-tools/effect/backend";import { jazz } from "./jazz.server";import { app } from "./schema";const listTodos = HttpRouter.add("GET","/api/todos",Effect.gen(function* () {const jazz = yield* Jazz;return yield* HttpServerResponse.json(yield* jazz.all(app.todos));}).pipe(JazzBackend.forCurrentReqUser()), // as the signed-in user);await jazz.ready(); // open the store before Effect handlers use itconst runtime = ManagedRuntime.make(JazzBackend.layerJazz(jazz));const services = await runtime.context();const { handler } = HttpRouter.toWebHandler(listTodos);const server = serve({port: 3000,fetch: (request) =>new URL(request.url).pathname.startsWith("/jazz/")? jazz.handle(request) // sync and sign-in under /jazz: handler(request, services),});await jazz.attach(server); // WebSocket upgrades on /jazz (needs `ws`)
JazzBackend.layerJazz hands your createJazz instance to Effect handlers, and /jazz is
served by Jazz itself next to your routes. See Effect for the Jazz
service.
A separate frontend dev server
When the frontend runs on its own dev server (Vite, say) and the backend on another port, proxy
/jazz and your auth routes to the backend so the browser stays on one origin, with WebSockets
on:
vite.config.tsimport { defineConfig } from "vite";export default defineConfig({server: {proxy: {"/jazz": { target: "http://localhost:3000", ws: true },"/api/auth": { target: "http://localhost:3000" },},},});
With Better Auth, add the dev server's origin to trustedOrigins, or sign-in answers
403 INVALID_ORIGIN (see Better Auth).
Keep ws: true on: a proxy that leaves the WebSocket upgrade hanging costs about 20 seconds on
every page load before the client falls back to an HTTP stream.
In production, serve the built frontend from the same origin as the backend (or put both behind one proxy that passes WebSocket upgrades through).
Deploy it
That's the whole deployment:
- No separate process and no port. Sync is served by your own HTTP server under
/jazz. - No deploy step. On start, Jazz applies the schema, migrations and permissions your code declares. If they can't be applied (a schema with no migration path), the process refuses to start instead of serving with the wrong rules.
- No secrets to manage. Browsers hold no Jazz credential: your
authenticatefunction decides who each request is (see How Auth Works in Jazz).
Keep the data directory on a durable disk (a Docker volume, an attached block device), and run one
process per directory: a second process on the same directory is refused (<path>/jazz.lock). sqlite() refuses to start on serverless hosts with no
lasting disk (Vercel, AWS Lambda, Netlify); on Vercel, use vercel().
Jazz creates the directory's files readable by every user on the machine (mode 0644), so on a shared
host restrict the directory itself to the service's user (for example chmod 700).
DockerfileFROM node:22-slimWORKDIR /appCOPY . .RUN npm ci && npm run buildENV JAZZ_DATA_DIR=/data/jazzVOLUME /dataCMD ["node", "dist/server.js"]
On shutdown, close Jazz so writes still in flight reach the store and open sockets end cleanly:
server.tsprocess.on("SIGTERM", () => {void jazz.close().then(() => server.close());});
Proxies, paths and origins
These createJazz options cover a backend behind a proxy or on a non-default path:
| Option | Default | What it does |
|---|---|---|
basePath | "/jazz" | The path Jazz is served under. Mount your routes with jazz.basePath, and pass the same path as serverUrl in the browser |
publicUrl | the request's host | The URL (or URLs) browsers reach Jazz at, when a proxy changes the host or path. Recommended in production; list your dev URL too if it uses this backend |
allowedOrigins | the backend's own | Other browser origins that may sync and sign in |
upgradesSockets | false | Set when your host upgrades sockets itself and passes them to jazz.acceptSocket (Bun, Deno, your own ws); attach() and Vercel set it on their own |
A proxy in front of your backend must pass WebSocket upgrades through to /jazz. Pages on another
origin can't sign in with your app's cookie yet, even with allowedOrigins; serve the page and
/jazz from the same origin for now.
Connect the browser
The browser talks to /jazz on the same origin by default, so there's no serverUrl to set, and your
app's own session cookie is all it needs. It needs no Jazz API key or token:
App.tsx<JazzProvider signedOut={<SignIn />}><App /></JazzProvider>
The client asks your backend who is signed in: authenticate answers, and the client gets the
account, identity and claims as plain data. It asks again when the app starts, when the page regains
focus and when another tab signs in or out. After your own sign-in or sign-out, call
reauthenticate() from useJazzAuth(). Every sync connection asks authenticate again, so a
sign-out on the server or changed claims apply without client code. See
How Auth Works in Jazz.
Trusted backend use
jazz.db acts with backend authority: it sees and writes everything, so check access in your own
code first. Use it for system work: jobs, webhooks, migrations, admin routes.
import { jazz } from "./jazz.server";import { app } from "./schema";await jazz.db.insert(app.auditLog, { event: "invoice.paid" });
User-scoped use
await jazz.forReqUser(request) runs your authenticate and returns a database with that user's
policies and authorship. It's the default for anything a user triggers: route handlers, server
actions, server components. Concurrent requests stay isolated.
app/api/todos/route.tsimport { jazz } from "@/jazz.server";import { app } from "@/schema";export async function GET(request: Request) {const db = await jazz.forReqUser(request);return Response.json(await db.all(app.todos));}
For a signed-out request, forReqUser throws a RequestAuthenticationError (code: "signed_out").
Map it to a 401 rather than a 500:
import { isRequestAuthenticationError } from "jazz-tools/backend";try {const db = await jazz.forReqUser(request);// …} catch (error) {if (isRequestAuthenticationError(error)) return new Response(null, { status: 401 });throw error;}
When a route acts with backend authority on a user's behalf (placing an order the user can't write
directly, say), await jazz.accountFor(request) names that user's account, or null when signed
out or when the user has no Jazz account yet, so the backend's db can write rows that point at it.
Users without an account
Your backend never creates an account on its own. A user gets one when they sign in from a Jazz
client, when a guest links to them, or when you call
jazz.createAccountFor(userId). Until then, forReqUser and withAttribution throw a
NoJazzAccountError (code: "no_account", with the userId), and accountFor returns null.
For users who never open a client, such as API-only users, webhooks or imports, create the account yourself, for example in your sign-in route. It returns the account id, and calling it again returns the same one:
const user = await authenticate(request);if (!user) return new Response(null, { status: 401 });const account = await jazz.createAccountFor(user.userId);
Once a user has an account, a guest can no longer link to them, so don't call createAccountFor in
a sign-up flow whose browser may hold a local-first guest. To tell the error apart, use
isNoJazzAccountError(error) from jazz-tools/backend.
A browser whose user no longer has an account on the core, for example after a reset dev store or on a new core, signs in again when sync refuses it, instead of reconnecting forever.
Backend clients
Other trusted machines, such as a job runner, a worker on another cloud or a partner's service,
connect to this backend's /jazz as backend clients, with their own
machine-to-machine credential. List the ones you trust in createJazz({ backendClients }).
Data on another machine
When the data should live apart from your app servers (several app instances sharing one database,
serverless functions, an existing Jazz Cloud app), keep it in Jazz Cloud
and point createJazz at it with remote():
jazz.server.tsimport { createJazz, remote } from "jazz-tools/backend";import { app } from "./schema";import permissions from "./permissions";export const jazz = createJazz({app,permissions,store: remote({appId: process.env.JAZZ_APP_ID!,url: process.env.JAZZ_SERVER_URL!,secret: process.env.JAZZ_ADMIN_SECRET!, // the app's admin secret}),authenticate: async (request) => /* your session */ null,});
Everything else stays the same. Browsers still sync with your backend under /jazz and never see
the core's address or secret. Your backend sends its schema, migrations and permissions to the core
on start, and tells the core who each user is; the core does no auth of its own and trusts only the
holder of the app's admin secret. Keep it server-side, like a database password.
Jazz always runs inside a backend like this one. There is no separate Jazz server to install or run.
Coming from the alpha.59 server
Alpha.59 ran Jazz as its own process (jazz-tools server), and clients connected to its port
(:1625/ by default). In alpha.60 that process is gone:
- Clients sync with your backend under
/jazz, on your app's own origin, instead of with a separate Jazz server. --jwks-urland the other JWT flags move intoauthenticate. Pass the same values tofromJWT().jazz-tools deployis gone. Your backend applies its schema, migrations and permissions each time it starts.- Move the data directory with the import tool (see
Upgrading from alpha.59).
The import keeps large values inside
jazz.sqlite. On its first start your backend moves them toblobs/, or whereverblobspoints, without logging anything. Run the import'sverifybefore that first start.
Coming next
- systemd recipe
- TLS and data dir on durable disks (reverse proxies work today with
publicUrl; see Proxies, paths and origins) - Backups and upgrades (see Operating Jazz)