Baselyra Docs

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 DockerDocker and Docker Compose. Nothing else.
Without DockerNode 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.
Memory1 GB is enough to start. 2 GB is comfortable for production.
DiskThe 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.

DetailValue
Basenode:22-alpine, plus tini and postgresql17-client
UserRuns as uid 10001, not root
Entrypointtini, so signals reach Node and a SIGTERM drains cleanly
Commandnode scripts/migrate.js && node dist/index.js
HealthcheckGET /health every 30s after a 20s start period
Runtime dependenciesEight 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.

  1. Wait for Postgres

    Thirty attempts at two-second intervals, logging database not ready with the driver's error code each time.

  2. Create the databases if they are absent

    CREATE DATABASE cannot run inside a transaction and cannot run from a connection to the database being created, so it is issued from a connection to postgres, the maintenance database every server ships with.

  3. Create and pin the SQL console role

    baselyra_sql is created NOLOGIN and then forced nosuperuser nocreatedb nocreaterole noreplication nologin inherit bypassrls on every boot, so a hand-run alter role baselyra_sql superuser survives exactly until the next restart. See The SQL editor.

  4. Apply the migrations

    db/control/*.sql to the control database and db/project/*.sql to every registered project database, in filename order, each file inside one transaction. Each database tracks what it has applied in its own migrations table with a checksum. A file whose checksum changed is re-run — every file in db/ is written to be idempotent, and that is the intended way to evolve the schema.

  5. Move an older instance's operating data

    An instance created before the control/project split keeps platform_users, audit_log, import_runs and request_stats in 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.

  6. Create the first Studio account

    From BASELYRA_ADMIN_EMAIL and BASELYRA_ADMIN_PASSWORD. On a re-run the account is promoted to owner but 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

SymptomCause and fix
The app container restarts in a loopUsually 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_URLBoth URLs point at the same database. Unset CONTROL_DATABASE_URL and let it default.
the SQL console role baselyra_sql does not existA 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 hostSomeone 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 exitPostgres never came up. docker compose logs db, usually a volume permission problem.
Studio loads as a blank pageThe server is pointed at the Vite source rather than a built Studio. See above.

Edit this page Report a problem

Esc
navigate open Esc close