Installation
Baselyra ships as two containers and a .env file. This page covers what the images contain, what happens on every boot, how to run it without Docker, and how to verify the install before you trust it.
Requirements
| Needs | |
|---|---|
| With Docker | Docker and Docker Compose. Nothing else. |
| Without Docker | Node 22 or newer and Postgres 17. The database role must be allowed to CREATE DATABASE, CREATE ROLE (two of them with BYPASSRLS) and CREATE EXTENSION. |
| Memory | 1 GB is enough to start. 2 GB is comfortable for production. |
| Disk | The Postgres volume plus whatever you put in STORAGE_ROOT. |
At idle expect roughly 250–400 MB for Postgres with the shipped
shared_buffers=256MB, and 80–150 MB for the Node process. Under load the Node side
grows with concurrent uploads and open WebSockets; Postgres grows with
work_mem times concurrent sorts.
With Docker Compose
git clone https://github.com/baselyra/baselyra.git /opt/baselyra
cd /opt/baselyra
./scripts/setup.sh # writes .env with fresh secrets, prints the admin password once
$EDITOR .env # set BASELYRA_PUBLIC_URL, BASELYRA_SITE_URL, SMTP, CORS_ORIGINS
docker compose up -d --build
docker compose logs -f app
The shipped docker-compose.yml is two services and two named volumes.
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=und-x-icu --encoding=UTF8"
command: [postgres, -c, max_connections=200, -c, shared_buffers=256MB, -c, work_mem=8MB]
volumes: [db-data:/var/lib/postgresql/data]
# No published port: only the app container reaches Postgres.
app:
image: baselyra:latest
env_file: .env
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
PORT: 3000
ports: ["127.0.0.1:${BASELYRA_PORT:-3130}:3000"]
volumes: [storage-data:/var/lib/baselyra/storage]
The collation is pinned with --locale-provider=icu so ORDER BY does not
change under a locale upgrade of the base image.
What is in the image
Three build stages, so neither toolchain reaches the runtime layer. What ships is
production node_modules, the compiled server in dist/, the compiled Studio in
studio/, the SQL in db/ and the scripts.
| Detail | Value |
|---|---|
| Base | node:22-alpine, plus tini and postgresql17-client |
| User | Runs as uid 10001, not root |
| Entrypoint | tini, so signals reach Node and a SIGTERM drains cleanly |
| Command | node scripts/migrate.js && node dist/index.js |
| Healthcheck | GET /health every 30s after a 20s start period |
| Runtime dependencies | Eight npm packages: fastify, five Fastify plugins, pg and nodemailer |
What happens on every boot
scripts/migrate.js runs before the server, on every start, and every step in it
is a no-op the second time.
Wait for Postgres
Thirty attempts at two-second intervals, logging
database not readywith the driver's error code each time.Create the databases if they are absent
CREATE DATABASEcannot run inside a transaction and cannot run from a connection to the database being created, so it is issued from a connection topostgres, the maintenance database every server ships with.Create and pin the SQL console role
baselyra_sqlis createdNOLOGINand then forcednosuperuser nocreatedb nocreaterole noreplication nologin inherit bypassrlson every boot, so a hand-runalter role baselyra_sql superusersurvives exactly until the next restart. See The SQL editor.Apply the migrations
db/control/*.sqlto the control database anddb/project/*.sqlto every registered project database, in filename order, each file inside one transaction. Each database tracks what it has applied in its ownmigrationstable with a checksum. A file whose checksum changed is re-run — every file indb/is written to be idempotent, and that is the intended way to evolve the schema.Move an older instance's operating data
An instance created before the control/project split keeps
platform_users,audit_log,import_runsandrequest_statsin the project database. They are copied across in pages of 5000 rows preserving ids, hashes, roles and timestamps, and the originals are dropped only once a row-count comparison confirms the copy. If the counts disagree it aborts loudly and drops nothing.Create the first Studio account
From
BASELYRA_ADMIN_EMAILandBASELYRA_ADMIN_PASSWORD. On a re-run the account is promoted toownerbut its password is never overwritten, so changing it in the Studio survives a restart.
A successful run ends with [migrate] done, and then
Baselyra listening on 0.0.0.0:3000.
Verify the install
curl -s http://127.0.0.1:3130/health
{"status":"ok","service":"baselyra","version":"0.1.0","database":"up","time":"2026-08-24T09:14:02.481Z"}
database is up only when both pools answer SELECT 1. Without the
control database nobody can open the Studio, so a half-up instance reports itself
down.
Then the end-to-end check, which drives auth, REST, storage, realtime, the admin API and the Studio the way a real client would, and removes everything it created:
./scripts/smoke.sh http://127.0.0.1:3130 admin@example.com 'the-printed-password'
Reaching Postgres directly
docker compose exec db psql -U baselyra -d baselyra # your project
docker compose exec db psql -U baselyra -d baselyra_control # Studio accounts, audit log
Two databases on one server. DATABASE_URL names the first;
CONTROL_DATABASE_URL is optional and defaults to the same server with the database
name swapped to baselyra_control. Pointing both at one database is
refused at boot — that would put Studio password hashes back inside the
database the SQL editor can read.
Without Docker
Build both halves, then assemble the runtime layout. The server serves the Studio
from ../studio relative to dist/, so the built frontend has to sit next to
dist/ — which is exactly what the Dockerfile does.
npm ci && npm run build # -> dist/
(cd studio && npm ci && npm run build) # -> studio/dist/
install -d /opt/baselyra
cp -r dist package.json package-lock.json db scripts /opt/baselyra/
cp -r studio/dist /opt/baselyra/studio
(cd /opt/baselyra && npm ci --omit=dev)
cd /opt/baselyra
DATABASE_URL=postgres://… JWT_SECRET=… node scripts/migrate.js
DATABASE_URL=postgres://… JWT_SECRET=… node dist/index.js
Run it under systemd with Restart=always, an EnvironmentFile, and a
dedicated user that owns STORAGE_ROOT.
Upgrading
cd /opt/baselyra
./scripts/backup.sh
git pull
docker compose up -d --build
docker compose logs -f app
./scripts/smoke.sh http://127.0.0.1:3130 admin@example.com 'password'
Migrations run automatically at boot, transactionally, with checksums. Rolling back means restoring the backup: there are no down-migrations. Backups, restores and the reverse proxy are covered in Self-hosting.
Install problems
| Symptom | Cause and fix |
|---|---|
| The app container restarts in a loop | Usually a missing required variable — DATABASE_URL or JWT_SECRET — or a failed migration, which prints the file and the character position. docker compose logs app. |
CONTROL_DATABASE_URL must name a different database than DATABASE_URL | Both URLs point at the same database. Unset CONTROL_DATABASE_URL and let it default. |
the SQL console role baselyra_sql does not exist | A migration directory was applied without scripts/migrate.js — a role belongs to the cluster rather than to one database, so migrate.js creates it once before applying the files. Run node scripts/migrate.js. |
baselyra_sql is a superuser: the SQL console would be a shell on this host | Someone granted it. alter role baselyra_sql nosuperuser and restart. The migration aborts on purpose rather than starting an instance whose admin console is remote code execution. |
database not ready thirty times, then exit | Postgres never came up. docker compose logs db, usually a volume permission problem. |
| Studio loads as a blank page | The server is pointed at the Vite source rather than a built Studio. See above. |