Skip to content

Latest commit

 

History

History
494 lines (384 loc) · 18.1 KB

File metadata and controls

494 lines (384 loc) · 18.1 KB

Self-hosting

Two containers, one .env, one reverse proxy. This page covers the whole lifecycle: install, TLS, backups, upgrades and what to expect from the hardware.

Resource expectations

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).

Install

git clone https://github.com/baselyra/baselyra.git /opt/baselyra
cd /opt/baselyra
./scripts/setup.sh          # writes .env, prints the admin password once

Edit .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=info
docker 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, audit

Two 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.

Reverse proxy

Both shipped vhosts are in deploy/. Replace BASELYRA_DOMAIN and BASELYRA_PORT in whichever you use.

The one rule

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.

nginx

deploy/nginx-baselyra.conf:

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

Apache

deploy/apache-baselyra.conf:

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

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) and that nothing untrusted can reach port 3130 directly.

TLS with certbot

sudo apt install certbot
sudo mkdir -p /var/www/letsencrypt

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.

sudo certbot certonly --webroot -w /var/www/letsencrypt -d api.example.com

Renewal 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 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.

Email

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

  • 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, 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_DAYS are 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>&1

Copy the directory off the machine. A backup on the same disk as the database is not a backup.

Restore

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.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. Pass the control dump as the third argument.

Rehearse it once on a scratch machine. A backup you have never restored is a hypothesis.

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

Running without Docker

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.js

Run it under systemd with Restart=always, an EnvironmentFile, and a dedicated user that owns STORAGE_ROOT.

Operations

Health

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.

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; request logging is off in production by default.

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. Read it under Studio → Settings, or:

curl -s "$URL/admin/v1/logs?limit=100" -H "authorization: Bearer $ADMIN_TOKEN"

Rotating secrets

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.

Tuning

command:
  - postgres
  - -c
  - max_connections=200
  - -c
  - shared_buffers=256MB     # ~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 (the shipped compose file does this) and firewalled.
  • Postgres publishes no port.
  • .env is chmod 600 (setup.sh does this) and not in version control.
  • The service key is not in any client bundle or public env var.
  • Every table in public has RLS on and at least one policy — row-level-security.md.
  • TLS is on and BASELYRA_PUBLIC_URL is https://.
  • 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_URL does 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.

Troubleshooting

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.