Skip to content

Deployment

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

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-bundle
docker compose up -d

rebase 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 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:push

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

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.com
REBASE_ADMIN_PASSWORD=<at least 12 characters>
DISABLE_SELF_REGISTRATION=true

The 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/login accepts — it parses its body with z.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.

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.

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 run

Three ways through, in the order they are usually right:

  1. 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.
  2. Use a service. An image CDN or a transform API does the same work behind a URL.
  3. 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.

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 build

This way you only need to deploy one server that handles both SPA and API.

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 →

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"
});

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/*.