Two containers, one .env, one reverse proxy. This page covers the whole
lifecycle: install, TLS, backups, upgrades and what to expect from the hardware.
| Instance | Works? | Notes |
|---|---|---|
| 1 vCPU / 1 GB | Yes | Fine for development and a small production app. Add swap. |
| 2 vCPU / 2 GB | Comfortable | The sensible default for production. |
| 4 vCPU / 4 GB+ | Room to grow | Raise shared_buffers and DATABASE_POOL_MAX. |
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 × concurrent sorts.
Disk: the Postgres volume plus whatever you store in STORAGE_ROOT. Both are
Docker named volumes by default (baselyra_db-data, baselyra_storage-data).
git clone https://github.com/baselyra/baselyra.git /opt/baselyra
cd /opt/baselyra
./scripts/setup.sh # writes .env, prints the admin password onceEdit .env. The values that matter in production:
BASELYRA_PUBLIC_URL=https://api.example.com # this instance's public origin
BASELYRA_SITE_URL=https://app.example.com # your frontend
BASELYRA_PORT=3130 # host port, bound to 127.0.0.1
CORS_ORIGINS=https://app.example.com # not * in production
AUTH_CONFIRM_EMAIL=true
SMTP_HOST=smtp.example.com # empty means emails only go to the log
LOG_LEVEL=infodocker compose up -d --build
docker compose logs -f app
./scripts/smoke.sh http://127.0.0.1:3130 admin@example.com 'the-printed-password'docker-compose.yml publishes the app on 127.0.0.1 only and does not publish
Postgres at all. The database is reachable from the app container and nowhere
else. If you need psql from the host:
docker compose exec db psql -U baselyra -d baselyra # your project
docker compose exec db psql -U baselyra -d baselyra_control # Studio accounts, auditTwo databases on the 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. Set it explicitly only if the
control database lives somewhere else — pointing both at the same database is
refused at boot, because that would put Studio password hashes back inside the
database the SQL editor can read.
Both shipped vhosts are in deploy/. Replace BASELYRA_DOMAIN and
BASELYRA_PORT in whichever you use.
Baselyra's realtime endpoint is a WebSocket. A proxy that does not forward the
Upgrade handshake proxies it as plain HTTP, the handshake never completes, and
realtime silently does nothing while every other route works perfectly.
There is no error in any log. This is the most common self-hosting complaint for
a product of this shape, and it is always this.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name api.example.com;
location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
# Keep above STORAGE_MAX_FILE_BYTES or large uploads fail at the proxy.
client_max_body_size 100m;
proxy_read_timeout 300s;
location / {
proxy_pass http://127.0.0.1:3130;
proxy_http_version 1.1; # 1.0 cannot upgrade
# On every location, not just /realtime, so the upgrade works whatever
# path a future version listens on.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # SSE arrives as it is produced
}
}proxy_buffering off matters for two streaming endpoints: the AI chat stream and
the import progress stream. With buffering on, both arrive only once finished.
sudo cp deploy/nginx-baselyra.conf /etc/nginx/sites-available/baselyra
sudo ln -s /etc/nginx/sites-available/baselyra /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx<VirtualHost *:443>
ServerName api.example.com
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyTimeout 300
# MUST come before the catch-all ProxyPass below, or /realtime/v1 is
# proxied as plain HTTP and the WebSocket upgrade never completes.
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule ^/?(.*) ws://127.0.0.1:3130/$1 [P,L]
ProxyPass / http://127.0.0.1:3130/
ProxyPassReverse / http://127.0.0.1:3130/
LimitRequestBody 0 # the app enforces its own upload limit
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/api.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/api.example.com/privkey.pem
</VirtualHost>sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl
sudo cp deploy/apache-baselyra.conf /etc/apache2/sites-available/baselyra.conf
sudo a2ensite baselyra
sudo apachectl configtest && sudo systemctl reload apache2curl -i -N \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
"https://api.example.com/realtime/v1?apikey=$ANON_KEY"HTTP/1.1 101 Switching Protocols is a pass. Anything else — a 200 with HTML,
a 404, a 502 — means the proxy answered instead of upgrading. Recheck the
module list and the rule order.
The app runs with trustProxy on and reads X-Forwarded-For. Rate limiting and
auth.sessions.ip depend on it, so make sure your proxy sets it (both shipped
configs do) and that nothing untrusted can reach port 3130 directly.
sudo apt install certbot
sudo mkdir -p /var/www/letsencryptBoth shipped vhosts already serve /.well-known/acme-challenge/ from
/var/www/letsencrypt on port 80 without redirecting it, which is what webroot
issuance needs.
sudo certbot certonly --webroot -w /var/www/letsencrypt -d api.example.comRenewal is a systemd timer or cron from the package. Make sure it reloads the proxy:
# /etc/letsencrypt/renewal-hooks/deploy/reload-proxy.sh
#!/bin/sh
systemctl reload nginx # or apache2sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-proxy.sh
sudo certbot renew --dry-runAfter issuing, set BASELYRA_PUBLIC_URL=https://api.example.com and
docker compose up -d. Signed URLs and email links use that value; leaving it
on http:// produces mixed-content failures in the browser.
With SMTP_HOST empty, nothing is sent — confirmation links are printed to the
app log. That is right for a laptop and never right in production: users cannot
confirm their address or reset a password.
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false # true only for implicit TLS on 465
SMTP_USER=…
SMTP_PASS=…
SMTP_FROM="Acme <no-reply@acme.com>"Test from Studio → Email → Send test, which uses your real templates and
reports the SMTP error verbatim if there is one. A relay that rejects a message
raises an error rather than falling back to the log — only an empty SMTP_HOST
skips sending.
Three things, and the third is the cheap one:
- The project database (
baselyra) — your tables, your users, your bucket and object metadata. Losing it loses the product. - The storage volume — the actual file bytes. The dump alone restores an
instance whose
storage.objectsrows point at files that are gone, which looks like a working restore right up until someone opens an image. - The control database (
baselyra_control) — Studio accounts, the audit log, import history, request metering. Losing it costs you your Studio logins and your operator history, not your application: a restart withBASELYRA_ADMIN_EMAILandBASELYRA_ADMIN_PASSWORDset recreates an owner account, and everything else in it is a record rather than a dependency.
scripts/backup.sh covers all three:
BASELYRA_BACKUP_DIR=/var/backups/baselyra \
BASELYRA_BACKUP_KEEP_DAYS=14 \
./scripts/backup.shOne run writes three files sharing one UTC stamp: db-<stamp>.dump,
control-<stamp>.dump and storage-<stamp>.tar.gz. Restore reads them as a set,
so keep them together.
pg_dump -Fc— the custom format: compressed, and restorable table-by-table withpg_restore, which a plain SQL file cannot do.- A
tar.gzof the storage volume, read through a throwaway container so it does not matter where Docker put the volume. - Each file is written as
.partand renamed on success, so a half-written archive is never mistaken for a backup. - A zero-byte dump of either database fails the run loudly — the classic silent backup failure.
- Files older than
KEEP_DAYSare pruned.
The control database is assumed to be baselyra_control, the same default
CONTROL_DATABASE_URL derives. Set CONTROL_POSTGRES_DB if yours is elsewhere.
Nightly:
15 2 * * * cd /opt/baselyra && ./scripts/backup.sh >> /var/log/baselyra-backup.log 2>&1Copy the directory off the machine. A backup on the same disk as the database is not a backup.
scripts/restore.sh is destructive: it stops the app,
drops and recreates the databases it is restoring, replaces the storage volume,
and starts the app. It asks you to type the project database name to confirm,
and it refuses to drop anything until pg_restore --list has read every archive
it was handed — a truncated dump passes a size check and then fails halfway,
by which point the live database is already gone.
./scripts/restore.sh /var/backups/baselyra/db-20260823T020000Z.dump \
/var/backups/baselyra/storage-20260823T020000Z.tar.gzThe control dump is found beside the project dump by name — db-<stamp>.dump
becomes control-<stamp>.dump — or passed as a third argument. Both databases
are then replaced together, which is what you want: Studio accounts, the audit
log and the project registry come back to the same moment as the data they
describe.
A backup taken before the control/project split has no control dump, and it is
still a legitimate restore: the Studio accounts are inside the project dump, in
its baselyra schema. restore.sh recognises that, restores only the project
database, and leaves the live control database alone — dropping it would throw
away accounts the dump predates — and the upgrade in scripts/migrate.js moves
those accounts across on the next boot. The one case it refuses is the ambiguous
one: no control dump beside the project dump and no Studio accounts inside it
either, which would leave an instance nobody can sign in to. Pass the control
dump as the third argument.
Rehearse it once on a scratch machine. A backup you have never restored is a hypothesis.
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. scripts/migrate.js creates both
databases if they are absent, then applies db/project/*.sql to the project
database and db/control/*.sql to the control database, in filename order,
inside one transaction each. Each database tracks what it has applied in its own
migrations table with a checksum, and anything already applied unchanged is
skipped. A migration that fails halfway leaves nothing behind, which is the only
way an unattended restart loop stays safe.
Every file in db/ is written to be idempotent, so an edited migration is
re-run deliberately — that is the intended way to evolve the schema.
Upgrading from a single-database instance. Earlier builds kept Baselyra's
operating data in the project database, in a baselyra schema. The upgrade
copies platform_users, audit_log, import_runs and request_stats into the
control database preserving ids, password hashes, roles and timestamps, and
drops the originals only once a row-count comparison confirms the copy. If
the counts disagree it aborts loudly and drops nothing, because losing the last
owner account leaves the instance unopenable. It is safe to run twice.
Rolling back means restoring the backup. There are no down-migrations.
You need Node 22+ and Postgres 17. The migrations create pgcrypto, citext
and pg_trgm themselves, so the database user must be allowed to create
extensions; scripts/migrate.js issues CREATE DATABASE for whichever of the
two databases is missing, so it must be allowed to do that; and it creates the
anon, authenticated, service_role and baselyra_sql roles, two of them
with BYPASSRLS, so it must be allowed to do that too.
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)Do not point the server at the repository checkout: <repo>/studio is the Vite
source there, and its index.html loads /src/main.tsx, which does not exist
in a production build.
cd /opt/baselyra
DATABASE_URL=postgres://… JWT_SECRET=… node scripts/migrate.js
DATABASE_URL=postgres://… JWT_SECRET=… node dist/index.jsRun it under systemd with Restart=always, an EnvironmentFile, and a
dedicated user that owns STORAGE_ROOT.
curl -s https://api.example.com/health{"status":"ok","database":"up",…}. The container has its own HEALTHCHECK
hitting the same route, so docker compose ps shows (healthy) or not.
docker compose logs -f app
docker compose logs -f dbJSON lines from Fastify, rotated by Docker at 10 MB × 3 files.
Authorization, apikey and Cookie headers are redacted before anything is
written. LOG_LEVEL=debug for more; request logging is off in production by
default.
Every Studio sign-in, SQL execution, DDL statement, policy change and import is
written to control.audit_log — in the control database, so the SQL editor
cannot read it and an operator cannot quietly edit their own trail. Read it
under Studio → Settings, or:
curl -s "$URL/admin/v1/logs?limit=100" -H "authorization: Bearer $ADMIN_TOKEN"Changing JWT_SECRET invalidates every access token, every refresh token, both
project keys and every Studio session at once. Everyone signs in again and every
client needs the new anon key. Do it deliberately.
The Postgres password is in .env; changing it means changing it in Postgres too
(ALTER ROLE baselyra PASSWORD …) and restarting both containers.
command:
- postgres
- -c
- max_connections=200
- -c
- shared_buffers=256MB # ~25% of RAM
- -c
- work_mem=8MBDATABASE_POOL_MAX=12 # per app process; stay well under max_connections
DATABASE_STATEMENT_TIMEOUT_MS=15000 # kills a runaway API query
STORAGE_MAX_FILE_BYTES=52428800 # keep the proxy's body limit above this-
CORS_ORIGINSnames your origins, not*. - Port 3130 is bound to
127.0.0.1(the shipped compose file does this) and firewalled. - Postgres publishes no port.
-
.envischmod 600(setup.shdoes this) and not in version control. - The service key is not in any client bundle or public env var.
- Every table in
publichas RLS on and at least one policy — row-level-security.md. - TLS is on and
BASELYRA_PUBLIC_URLishttps://. - SMTP is configured, so recovery emails actually leave the machine.
- Backups run nightly, land off the machine, and have been restored once.
- The Studio has no leftover accounts for people who left
(
GET /admin/v1/team). -
DATABASE_URLdoes not authenticate as a Postgres superuser, and you have read security.md — it explains what the SQL editor can reach, what the service key can do, and what is not protected.
App restarts in a loop. docker compose logs app. Usually a missing
required variable (DATABASE_URL, JWT_SECRET) or a failed migration, which
prints the file and the character position.
database not ready repeating. Postgres is still initialising. Thirty
retries at two seconds; past that, check docker compose logs db for a volume
permission problem.
Realtime dead in production, fine locally. The proxy. See above.
Uploads fail at a few megabytes. The proxy's body limit is below
STORAGE_MAX_FILE_BYTES. Raise client_max_body_size, or LimitRequestBody 0
on Apache.
Streaming endpoints deliver everything at the end. proxy_buffering off on
nginx. The app already sends X-Accel-Buffering: no, but an explicit
proxy_buffering on in your config wins.
Every request is 401 after a redeploy. JWT_SECRET changed — a new .env,
or setup.sh run again on a fresh clone.
Disk full. Check the storage volume before the database:
docker system df -v. There are no quotas; a bucket can fill the disk.