Baselyra Docs

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

InstanceWorks?Notes
1 vCPU / 1 GBYesFine for development and a small production app. Add swap.
2 vCPU / 2 GBComfortableThe sensible default for production.
4 vCPU / 4 GB+Room to growRaise 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

SettingWhy
proxy_buffering off / no buffering on ApacheTwo 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_BYTESOtherwise 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.

Email

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 → EmailSend 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.

  1. The project database (baselyra)

    Your tables, your users, your bucket and object metadata. Losing it loses the product.

  2. The storage volume

    The actual file bytes. The dump alone restores an instance whose storage.objects rows point at files that are gone — which looks like a working restore right up until someone opens an image.

  3. 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_EMAIL and BASELYRA_ADMIN_PASSWORD set 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.

DetailWhy
pg_dump -FcThe custom format: compressed, and restorable table-by-table with pg_restore, which a plain SQL file cannot do
A tar.gz of the storage volumeRead through a throwaway container, so it does not matter where Docker put the volume
Each file is written as .part and renamed on successA half-written archive is never mistaken for a backup
A zero-byte dump of either database fails the run loudlyThe 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_ORIGINS names your origins, not *.
  • Port 3130 is bound to 127.0.0.1 and firewalled; Postgres publishes no port.
  • .env is chmod 600 and not in version control.
  • The service key is in no client bundle or public env var.
  • Every table in public has RLS on and at least one policy — the audit query.
  • TLS is on and BASELYRA_PUBLIC_URL is https://.
  • SMTP is configured, so recovery emails leave the machine.
  • Backups run nightly, land off the machine, and have been restored once.
  • GET /admin/v1/team lists only people who still work here.
  • DATABASE_URL does 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 seeWhyFix
The app restarts in a loopA missing required variable, or a failed migrationdocker compose logs app — it prints the file and the character position
Realtime dead in production, fine locallyThe proxy is not forwarding UpgradeThe 101 test above
Uploads fail at a few megabytesThe proxy's body limit is below STORAGE_MAX_FILE_BYTESclient_max_body_size, or LimitRequestBody 0
Streaming endpoints deliver everything at the endProxy bufferingproxy_buffering off
Every request is 401 after a redeployJWT_SECRET changed — a new .env, or setup.sh on a fresh cloneRestore the old value, or re-issue keys
Disk fullThere are no storage quotas; a bucket can fill the diskdocker system df -v, then prune or move STORAGE_ROOT
Rate limits trip for everyone at onceThe app port is reachable directly and X-Forwarded-For is being spoofed, or every client shares one NATBind to 127.0.0.1 and firewall the port

More, with the exact messages: Troubleshooting.

Edit this page Report a problem

Esc
navigate open Esc close