Deployment
What a Deployment Serves
Section titled “What a Deployment Serves”A Rebase project deploys as one server (on Rebase Cloud: https://<project>.rebase.website). That server handles:
/api/*— the data API, authentication, realtime, and storage- everything else — your built
frontend/as a static SPA
There is no separate admin service: the admin panel is part of a frontend, so where it appears depends on what your frontend is, and on whether you give it a hostname of its own.
| Project type | Root URL shows | Admin panel is at |
|---|---|---|
Default scaffold (rebase init) |
The admin panel | / — the frontend is the admin |
| Custom product frontend | Your app | Wherever you mount it, commonly /admin — see Changing the Base URL |
| Admin as an app on its own hostname | Your app | https://admin.example.com/, served by the same server — see Changing the Base URL |
| Backend-only project | Nothing (API only) | Not deployed |
Docker Compose (Recommended)
Section titled “Docker Compose (Recommended)”The generated project already includes a working docker-compose.yml — that
file is the one to use for a scaffolded project, as-is rather than
hand-written or copied from elsewhere. rebase init filled in its secrets, its
first admin account and its pinned runtime version, and it is booted by the
framework’s own acceptance gate on every push. It runs two containers,
Postgres and the published Rebase runtime with your built bundle mounted into
it. There is no application image to build.
Self-Hosting covers the same deployment without
a scaffold behind it, using
infra/docker/docker-compose.selfhost.yml
from the Rebase repository — and the two things this file deliberately leaves
out: a connection pooler, and running functions and the job worker as their own
processes.
rebase build # produces ./dist-bundledocker compose up -drebase build first, always: the api service mounts ./dist-bundle, and
without it the container starts against an empty directory.
The shape of the generated file:
services: db: image: pgvector/pgvector:pg18 environment: POSTGRES_USER: rebase_app POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-changeme} POSTGRES_DB: rebase healthcheck: test: ["CMD-SHELL", "pg_isready -U rebase_app -d rebase"]
api: # The published runtime. Upgrading Rebase is a tag change, not a rebuild. image: rebasepro/server:${REBASE_VERSION:-latest} depends_on: db: condition: service_healthy ports: - "${PORT:-3001}:3001" environment: NODE_ENV: production DATABASE_URL: postgresql://rebase_app:${DATABASE_PASSWORD:-changeme}@db:5432/rebase JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env} REBASE_SERVICE_KEY: ${REBASE_SERVICE_KEY:?set REBASE_SERVICE_KEY in .env} CORS_ORIGINS: ${CORS_ORIGINS:?set CORS_ORIGINS in .env} # This service runs in production, where the first account to register is # not promoted to admin. So the admin is named instead. REBASE_ADMIN_EMAIL: ${REBASE_ADMIN_EMAIL:?set REBASE_ADMIN_EMAIL in .env} REBASE_ADMIN_PASSWORD: ${REBASE_ADMIN_PASSWORD:?set REBASE_ADMIN_PASSWORD in .env} DISABLE_SELF_REGISTRATION: ${DISABLE_SELF_REGISTRATION:-true} volumes: # Your built project, from `rebase build`. Read-only: the build vendors # the bundle's dependencies by default, so nothing has to write here. - ./dist-bundle:/bundle:ro
volumes: postgres_data:The three REBASE_ADMIN_* / DISABLE_SELF_REGISTRATION lines are new
— on 0.17.3 the first account to register becomes the administrator, in
production too. See Your first admin below.
The bundle is mounted read-only. rebase build installs the project’s declared
dependencies into dist-bundle unless you pass --no-vendor, in which case the
runtime installs them at every start and the mount has to be writable — drop the
:ro. See Self-hosting.
rebase init writes all of these into .env for you, including a generated
admin password. Each is declared with ${VAR:?…}, so a missing one stops the
stack with a message naming it rather than starting something half-configured —
and Compose interpolates the whole file before selecting services, so a missing
one stops docker compose up -d db too.
Change the admin email to yours, sign in, and change the password. See Your first admin.
The schema
Section titled “The schema”The runtime creates missing tables at boot, including your collections’ —
REBASE_MIGRATE_ON_BOOT defaults to ensure, which is additive across the whole
schema and applies row-level security with it. A first docker compose up
against an empty database comes up serving your collections.
What boot never does is change something that already exists: it does not alter a column type, drop anything, or edit an existing enum’s labels, because a container restart must not reshape a schema as a side effect of a deploy. Those go through the CLI, from a checkout or a CI job pointed at the production database:
pnpm run db:pushRun it for any change that is not purely additive — a renamed column, a narrowed type, a removed field.
For a versioned, team workflow, commit migration files with
pnpm run db:generate and run pnpm run db:migrate as a release step instead.
Either way it runs from a project checkout, not inside the running container —
the runtime image ships without the CLI.
Your first admin
Section titled “Your first admin”Set REBASE_ADMIN_EMAIL and REBASE_ADMIN_PASSWORD before the first boot.
Every platform guide on this site points here, because this is the one step that
has no recovery from the outside.
A fresh database has no users, and outside production the registration policy admits the first sign-up and promotes it to admin. It has to: bootstrapping an admin needs a caller who is already signed in, so an empty database with no such rule is a dead end. On a laptop the person at the keyboard is the operator and that is exactly right.
It is exactly wrong on a host with a public name. The shipped artifacts bring DNS and TLS up before the operator has typed anything, so the window is open to the internet from the first second — and whoever reaches the sign-up form first owns the deployment.
So under NODE_ENV=production that window is closed. An empty user table
refuses the bootstrap registration with SETUP_REQUIRED, an account created
through open registration is an ordinary account, GET /api/auth/config never
advertises needsSetup, and POST /api/admin/bootstrap refuses. In 0.17.3 and
earlier the window was open in production too, so upgrade before you expose a
fresh deployment.
rebase dev reads the same .env and deliberately ignores both variables, and
says so at boot: locally the first registration is still the way in. The values
rebase init wrote belong to the production boot. Seeding on both sides would
spend the window before the developer had opened the app, which is what made the
quickstart’s own first step produce a role-less account.
That leaves two ways in, neither of which is a race:
REBASE_ADMIN_EMAIL=you@example.comREBASE_ADMIN_PASSWORD=<at least 12 characters>DISABLE_SELF_REGISTRATION=trueThe runtime creates that account once, while the user table is empty, and does nothing on every boot after that. Or assign the role to an existing user with the service key, if you provision accounts out of band.
Two rules the runtime enforces at boot, both of which produce an account nobody can use if you get them wrong:
- The password must be at least 12 characters, or it is refused and no account is created.
- The address must be one
POST /api/auth/loginaccepts — it parses its body withz.string().email(), so a domain with no dot (admin@localhost) seeds fine and then answers 400 on every sign-in. Boot refuses that address too.
Set both or neither: half a credential is a typo, and the deployment it produces
— self-registration off, no admin — needs a psql prompt to recover. Boot warns
when the table is empty in production and no admin is named.
Sign in and change the password. It is sitting in plain text wherever you put your environment.
Production Checklist
Section titled “Production Checklist”Before deploying to production, ensure:
| Item | Details |
|---|---|
| First admin | Set REBASE_ADMIN_EMAIL and REBASE_ADMIN_PASSWORD before the first boot, and DISABLE_SELF_REGISTRATION=true. In production the first account to register is not promoted — see Your first admin. |
| NODE_ENV | NODE_ENV=production. It is what closes the bootstrap window, refuses local file storage, requires CORS_ORIGINS, and turns the OpenAPI docs off. A deployment left at the default is running in development mode. |
| Database schema | Boot creates your collection tables and their RLS additively. Run pnpm run db:push (or pnpm run db:migrate) for anything not purely additive. |
| JWT_SECRET | Use a cryptographically strong random string (≥ 32 chars). Never reuse across environments. |
| DATABASE_URL | Use a managed Postgres instance (Neon, Supabase, RDS) with TLS enabled |
| CORS_ORIGINS | Always, not only when the frontend is on another domain. The runtime refuses to start in production with neither CORS_ORIGINS nor FRONTEND_URL, because an API that guesses its allowed origins eventually allows the wrong one. |
| Storage access control | A configured bucket refuses to boot in production without an access-control model. Storage is not under row-level security and its keys share one flat namespace, so an allow-all default lets any signed-in user list (GET /storage/list?prefix=) and then read, overwrite or delete every other user’s files. Satisfy it with a storageAuthorize hook or storagePolicies (the scaffold ships a hook in config/storage.ts), or state the intent with STORAGE_PUBLIC_READ for a genuine public CDN, or STORAGE_ALLOW_ANY_AUTHENTICATED for a single-tenant app where every account is trusted with every file. |
| Storage backend | STORAGE_TYPE=local in production is dropped, and uploads answer 501 STORAGE_NOT_CONFIGURED — the container filesystem is destroyed on the next restart, so a local backend is silent data loss. Use s3 or gcs, or set FORCE_LOCAL_STORAGE=true if the path really is a durable volume. |
| MFA_ENCRYPTION_KEY | Set it (32+ random characters) if you use TOTP. Unset, stored secrets are encrypted with JWT_SECRET — so rotating that signs everybody out and makes every enrolled authenticator undecryptable. |
| HTTPS | Terminate TLS at your reverse proxy (nginx, Cloudflare, load balancer) |
| Public reads still need a caller | access: "public" widens which rows a caller sees, not who may call: an anonymous request to /api/data/* answers 401 while AUTH_REQUIRE is on. Set AUTH_REQUIRE=false for a public site that reads its own backend, and let RLS alone decide. It is an environment variable, so a local .env that sets it does not travel with your deploy. |
Native Modules on the Managed Runtime
Section titled “Native Modules on the Managed Runtime”Rebase Cloud’s managed runtime runs your bundle inside a shared image. It has no
compiler and no way to load a native module — anything shipping a prebuilt
.node binary. The most common one by far is sharp, which is also the obvious
dependency for anything serving images.
rebase cloud deploy refuses this before the upload rather than after:
This bundle depends on native modules (sharp), which the managed runtime cannot runThree ways through, in the order they are usually right:
- Move the work to build time. Resize and re-encode images in your build step and deploy the results. Nothing native runs in the request path.
- Use a service. An image CDN or a transform API does the same work behind a URL.
- Run your own container. A self-hosted deployment (Docker, Kubernetes, any of the platform guides) is your image, so it can carry whatever it likes.
Functions that merely need Node rather than a native binary are fine — the
deploy reports those separately (1 of 3 function(s) depend on Node) and runs
them.
Serving the Frontend
Section titled “Serving the Frontend”In production, the backend can serve the frontend as a static SPA:
import { serveSPA } from "@rebasepro/server";import path from "path";
// After initializeRebaseBackend()serveSPA(app, { frontendPath: path.resolve(process.cwd(), "../frontend/dist") });Build the frontend first:
cd frontend && pnpm buildThis way you only need to deploy one server that handles both SPA and API.
Platform Deployment Guides
Section titled “Platform Deployment Guides”Detailed step-by-step guides for each platform:
| Platform | Type | Guide |
|---|---|---|
| AWS | App Runner / ECS + RDS | Deploy on AWS → |
| Google Cloud | Cloud Run + Cloud SQL | Deploy on GCP → |
| Azure | Container Apps + PostgreSQL | Deploy on Azure → |
| Hetzner Cloud | VPS + Docker Compose | Deploy on Hetzner → |
| Scaleway | Serverless Containers | Deploy on Scaleway → |
| Railway | PaaS (auto-detect Dockerfile) | Deploy on Railway → |
| Fly.io | Container runtime | Deploy on Fly.io → |
Changing the Base URL
Section titled “Changing the Base URL”If you want the admin to run at a sub-path (e.g. /admin), change the app’s
path in rebase.json, and its cms with it:
"admin": { "type": "static", "root": "frontend", "build": "npm run build --workspace frontend", "output": "frontend/dist", "path": "/admin", "cms": "/admin"}cms is where the CMS is, which Rebase Cloud links to, and it has to be inside
the app’s path. The scaffold sets both to /, so moving path alone is
refused — the CMS would be at /, outside an app that now starts at /admin.
To give the admin a hostname of its own instead of a sub-path, make path a
full URL — "path": "https://admin.example.com" — and leave cms at "/".
The admin then answers only on that hostname, and not on the project’s own
address, so it is unreachable until the hostname’s DNS verifies. The API stays
at /api on that hostname too. See
An app on its own hostname
for how requests are matched and what a Rebase Cloud deploy does with the
hostname.
rebase build passes path to Vite as base (via REBASE_APP_BASE), Vite gives
it back as import.meta.env.BASE_URL, and the scaffold’s main.tsx already
feeds it to the router — so the assets, the routes and the server all agree
without the prefix being written down three times:
// At "/" this is "".const basename = import.meta.env.BASE_URL.replace(/\/$/, "");
const router = createBrowserRouter([ { path: "/*", element: <App/> }], { basename });Keep the admin on a data router — createBrowserRouter with
<RouterProvider>, as the scaffold does. Under it, a record with unsaved
changes also stops in-app navigation (a link in the drawer, the browser’s back
button) and asks first, through useBlocker, which only a data router
provides.
Since 0.25 The admin also
runs under a plain <BrowserRouter>. There the unsaved-changes guard falls
back to the browser’s own leave-page prompt, which covers a reload, closing the
tab or typing a URL; navigating inside the app goes ahead without asking. On
0.24 the admin throws outside a data router.
Backend — if you move the API too, update its base path:
await initializeRebaseBackend({ // ... basePath: "/admin/api"});Product App + Admin in One Deployment
Section titled “Product App + Admin in One Deployment”The common reason to move the admin to /admin is shipping your own product app
at the root of the same deployment. A single Vite entry can serve both, split by URL,
so each app is lazy-loaded and product visitors never download the admin bundle:
const isAdmin = window.location.pathname.startsWith("/admin");
const ProductApp = lazy(() => import("./App"));const AdminApp = lazy(() => import("./AdminApp"));
const router = isAdmin // The admin lives under /admin, and `basename` is how the router is told. ? createBrowserRouter([{ path: "/*", element: <AdminApp/> }], { basename: "/admin" }) : createBrowserRouter([{ path: "/*", element: <ProductApp/> }]);
root.render(<RouterProvider router={router}/>);One router for both halves: the admin is better off on a data router, which lets unsaved changes stop in-app navigation, and there is no reason for the product app to be on a different one.
The backend needs no changes for this pattern — the API stays at /api and the SPA
catch-all serves index.html for both / and /admin/*.
Next Steps
Section titled “Next Steps”- Backend Overview — Full backend configuration
- Storage Configuration — S3 setup for production