Self-hosting
Two containers, one .env, one reverse proxy. Installation covers getting it running; this page is everything after that — the proxy rule realtime depends on, TLS, what to back up, how to restore it, and what to watch.
Sizing
| 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 times concurrent sorts. Disk is the Postgres volume plus whatever you
store in STORAGE_ROOT — both are Docker named volumes by default,
baselyra_db-data and baselyra_storage-data.
Reverse proxy
Both shipped vhosts are in deploy/. Replace BASELYRA_DOMAIN and
BASELYRA_PORT in whichever you use.
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
}
}
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 apache2
Verify the upgrade
curl -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.
Two more proxy settings that matter
| Setting | Why |
|---|---|
proxy_buffering off / no buffering on Apache | Two endpoints stream: the AI chat stream and the import progress stream. With buffering on, both arrive only once finished. The app already sends X-Accel-Buffering: no, but an explicit proxy_buffering on in your config wins. |
A body limit above STORAGE_MAX_FILE_BYTES | Otherwise a large upload fails at the proxy, with the proxy's own error page rather than Baselyra's 413. |
Client IPs
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.
TLS with certbot
sudo apt install certbot
sudo mkdir -p /var/www/letsencrypt
sudo certbot certonly --webroot -w /var/www/letsencrypt -d api.example.com
Both shipped vhosts already serve /.well-known/acme-challenge/ from
/var/www/letsencrypt on port 80 without redirecting it, which is what webroot
issuance needs.
# /etc/letsencrypt/renewal-hooks/deploy/reload-proxy.sh
#!/bin/sh
systemctl reload nginx # or apache2
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-proxy.sh
sudo certbot renew --dry-run
After 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 and links that do not
work.
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.
Backups
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 with
BASELYRA_ADMIN_EMAILandBASELYRA_ADMIN_PASSWORDset recreates an owner account.
BASELYRA_BACKUP_DIR=/var/backups/baselyra \
BASELYRA_BACKUP_KEEP_DAYS=14 \
./scripts/backup.sh
One 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.
| Detail | Why |
|---|---|
pg_dump -Fc | The custom format: compressed, and restorable table-by-table with pg_restore, which a plain SQL file cannot do |
A tar.gz of the storage volume | Read through a throwaway container, so it does not matter where Docker put the volume |
Each file is written as .part and renamed on success | 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_DAYS are pruned |
15 2 * * * cd /opt/baselyra && ./scripts/backup.sh >> /var/log/baselyra-backup.log 2>&1
Restore
./scripts/restore.sh /var/backups/baselyra/db-20260823T020000Z.dump \
/var/backups/baselyra/storage-20260823T020000Z.tar.gz
The 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.
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, in filename order, each file inside one
transaction, tracked with a checksum in each database's own migrations table. A
migration that fails halfway leaves nothing behind, which is the only way an
unattended restart loop stays safe. Rolling back means restoring the backup —
there are no down-migrations.
Upgrading from a single-database instance is handled automatically and is safe to run twice; the detail is in the boot sequence.
Operations
Health
curl -s https://api.example.com/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. The container has its own
HEALTHCHECK hitting the same route, so docker compose ps shows
(healthy) or not.
Logs
docker compose logs -f app
docker compose logs -f db
JSON 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; per-request logging is off in production by
default.
The audit log
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.
curl -s "$URL/admin/v1/logs?limit=100" -H "authorization: Bearer $ADMIN_TOKEN"
Rotating secrets
The Postgres password is in .env; changing it means changing it in Postgres
too (alter role baselyra password '…') and restarting both containers.
Tuning
command:
- postgres
- -c
- max_connections=200
- -c
- shared_buffers=256MB # roughly 25% of RAM
- -c
- work_mem=8MB
DATABASE_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
Security checklist
CORS_ORIGINSnames your origins, not*.- Port 3130 is bound to
127.0.0.1and firewalled; Postgres publishes no port. .envischmod 600and not in version control.- The service key is in no client bundle or public env var.
- Every table in
publichas RLS on and at least one policy — the audit query. - TLS is on and
BASELYRA_PUBLIC_URLishttps://. - SMTP is configured, so recovery emails leave the machine.
- Backups run nightly, land off the machine, and have been restored once.
GET /admin/v1/teamlists only people who still work here.DATABASE_URLdoes not authenticate as a Postgres superuser — see the hardening checklist, which explains what the SQL editor can reach and why this one matters most.
Failure modes
| What you see | Why | Fix |
|---|---|---|
| The app restarts in a loop | A missing required variable, or a failed migration | docker compose logs app — it prints the file and the character position |
| Realtime dead in production, fine locally | The proxy is not forwarding Upgrade | The 101 test above |
| Uploads fail at a few megabytes | The proxy's body limit is below STORAGE_MAX_FILE_BYTES | client_max_body_size, or LimitRequestBody 0 |
| Streaming endpoints deliver everything at the end | Proxy buffering | proxy_buffering off |
Every request is 401 after a redeploy | JWT_SECRET changed — a new .env, or setup.sh on a fresh clone | Restore the old value, or re-issue keys |
| Disk full | There are no storage quotas; a bucket can fill the disk | docker system df -v, then prune or move STORAGE_ROOT |
| Rate limits trip for everyone at once | The app port is reachable directly and X-Forwarded-For is being spoofed, or every client shares one NAT | Bind to 127.0.0.1 and firewall the port |
More, with the exact messages: Troubleshooting.