Self-hosting

Install Xeplr Analytics on your own server from a deployment bundle, with nginx and systemd in front.

What you need

Requirement Notes
Node.js 22 or newer install.sh refuses older versions
PostgreSQL reachable with the credentials in production.env
Redis sessions, the access cache and SSE tickets. The sign-in service checks Redis at startup and refuses to start without it
DuckDB nothing to install. It is a native npm module that stores files under DW_STORAGE_PATH, so put that on a disk that is backed up
nginx or any reverse proxy; see Routing
Python 3 only if you use AutoML, which runs model training as a subprocess

The bundle

A bundle is a zip, xeplr-bi-<stamp>-<sha>.zip, containing everything except the packages that can be fetched from npm:

BUNDLE.json     version, commit, build time, contents
install.sh      run this
ui/             the built SPA, static files served by nginx
api/            node source
  vendor/       private packages that are not on npm, shipped as source
  production.env.example
deploy/         nginx.conf.template, systemd units

node_modules is not included. install.sh runs npm ci against the shipped lockfile, so every server gets the exact versions and native modules (pg, DuckDB) build on the machine that runs them. The private @xeplr-bi/*, @xeplr-dw/*, @xeplr-workflow/* and @xeplr-automl/* packages ship under api/vendor/.

Where bundles come from

The Bundle GitHub Actions workflow (.github/workflows/bundle.yml in xeplr-bi) builds them. It runs only when started by hand, never on push. It checks out xeplr-bi beside xeplr-autoML, xeplr-datawarehouse and xeplr-workflow, installs on Node 24, and runs scripts/bundle.sh, which runs the backend and UI tests as a gate. It then uploads the zip to S3 and announces it on Slack.

Input Default
skip_tests false skip the test gate, only when it has already run
publish true upload to S3 and post to Slack
Configuration Kind
WORKSPACE_TOKEN secret read access to the three sibling repositories
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY secret required when publishing
SLACK_WEBHOOK_URL secret optional. Without it the bundle uploads but is not announced, and the run warns
S3_BUCKET variable required when publishing, with no default
AWS_REGION variable defaults to ap-south-1
S3_PREFIX variable defaults to xeplr-bi/releases

A preflight step names any missing item and stops. The zip is also attached to the run as xeplr-bi-bundle for 30 days.

Install

unzip xeplr-bi-<stamp>-<sha>.zip -d /opt
cd /opt/xeplr-bi-<stamp>-<sha>
cp api/production.env.example api/production.env
$EDITOR api/production.env          # see the notes in the file
chmod 600 api/production.env
./install.sh

install.sh installs dependencies, checks that every required setting is present, creates the storage directories, renders deploy/nginx.conf and the systemd units with this bundle’s real paths and ports, and runs the migrations. You can safely run it again, and running it again is also how you upgrade.

Then:

sudo cp deploy/nginx.conf /etc/nginx/sites-available/xeplr-bi.conf
sudo ln -sf /etc/nginx/sites-available/xeplr-bi.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

cd deploy && for f in *.rendered.service; do sudo cp "$f" "/etc/systemd/system/${f/.rendered/}"; done
sudo systemctl daemon-reload
sudo systemctl enable --now xeplr-bi-auth xeplr-bi-api

production.env

Every value in it is one the application refuses to invent. A missing setting is named at startup, all at once. A wrong-but-present database name or storage path would start cleanly, migrate, serve traffic and read as empty data, which is why nothing is defaulted.

Group Settings Notes
Served as SERVER_NAME the hostname nginx answers on. Empty means _ (any)
Ports BI_PORT (19102), AUTH_PORT (19101) localhost only, with nginx as the public face
Auth gate AUTH_URL (http://127.0.0.1:19101) every non-public request validates here. If auth is down, nothing serves
Connections BI_CONNECTION, JOBS_CONNECTION, WORKFLOW_CONNECTION, AUTH_DB_CONNECTION_INFO_ENCRYPTED, XCFG_DB_CONNECTION_INFO_ENCRYPTED encrypted with this install’s ENCRYPTION_KEY, so produce them on this machine and do not copy them from another install
Database names DB_API (xeplr_bi), DB_JOBS (xeplr_bi_jobs), DB_WORKFLOW (xeplr_bi_workflow), AUTH_DB_NAME (xeplr_bi_auth), EMAIL_DB_NAME (xeplr_bi_email), XCFG_DB_NAME (xeplr_configs) each embedded product gets its own. That separation is the only thing keeping their rows apart
Secrets ENCRYPTION_KEY, AUTH_JWT_SECRET, AUTH_SUPER_ADMIN_EMAIL, AUTH_SUPER_ADMIN_PASSWORD generate the key with openssl rand -hex 32. Changing it means re-encrypting every connection
Tokens AUTH_ACCESS_TOKEN_TTL_MINUTES, AUTH_ACCESS_TOKEN_TOLERANCE_SECONDS tolerance above 0 turns on sliding refresh
Email links AUTH_ACTIVATION_URL, AUTH_INVITE_URL public URLs, not localhost. People click them from their own machines
Auth migrations XEPLR_AUTH_MIGRATIONS leave empty. install.sh fills in the three directories inside the bundle and checks that each exists
Storage DW_STORAGE_PATH, UPLOAD_DIR, LOG_DIR absolute, and outside the bundle directory, which a redeploy replaces
Jobs JOBS_SCHEDULER, JOBS_POLL_MS see More than one API host
Email EMAIL_PROVIDER with SMTP_*, or BREVO_* activation and invite mail
Reading mail IMAP_HOST, IMAP_PORT, IMAP_SECURE only if a workflow reads a mailbox
Redis REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB, REDIS_PREFIX not in production.env.example, so add them. REDIS_PREFIX must be set, unique to this app, and not xeplr:. Host and port default to 127.0.0.1:6379

To produce an encrypted connection on the server, set ENCRYPTION_KEY first:

cd api && DOTENV_CONFIG_PATH=./production.env node -r dotenv/config node_modules/.bin/xeplr-db-encrypt

Before connecting real data

These rules come from the package READMEs. Each fails quietly if it is skipped. Security explains the reasoning.

  • Give SQL Server sources a login with only db_datareader. Previews, samples and report aggregation connect read-only. On PostgreSQL and MySQL the driver enforces that. SQL Server has no session read-only mode, so the login is the only thing that stops a write.
  • Set REDIS_PREFIX to a value unique to this app and use it in every process of the app. Two apps on one Redis under the same prefix serve each other’s sessions, menus and API permissions. @xeplr/auth refuses to start when the prefix is unset or is xeplr:.
  • Check the email row in the sign-in banner. At startup the sign-in service tests whether it can send mail and prints email ✓ … or ✗ NOT WORKING — reason. Email is never fatal to sign-in, but registration, invites and password reset cannot send their links without it.
  • Keep production.env at mode 0600 and out of git.

Routing

deploy/nginx.conf is generated from the UI’s own development proxy table, so a URL routes the same way in production as in development:

  • /auth/api/* goes to the sign-in service
  • the API prefixes (/reports, /datasets, /dashboards, /workflow, …) go to the API, with buffering off on /events so server-sent events stream
  • everything else goes to ui/index.html

That last rule is safe because every screen lives under /:companyId/:workspaceId/..., while every API route sits at the root.

If you use a different proxy, translate deploy/nginx.conf rather than writing your own list. A path missing from the list does not 404. It serves index.html, so the fetch succeeds and only the JSON parse fails.

Health

systemctl status xeplr-bi-auth xeplr-bi-api
journalctl -u xeplr-bi-api -f
curl -s -o /dev/null -w '%{http_code}\n' localhost:19101/auth/api/me   # 401 means it is up

If the API answers 503 on every route, it cannot reach the sign-in service. The gate fails closed on purpose: auth being down with everything served unauthenticated would be the worse failure.

Upgrading

Unpack the new bundle beside the old one and run its install.sh. The storage paths point outside the bundle directory, so data survives. Keep the previous directory until the new one is serving. To roll back, run systemctl stop, point nginx back at the old directory, and start again.

Migrations run forward only. Take a database backup first.

Migrations

The API runs its own migrations at every start. The sign-in service runs auth’s migrations and then each directory in XEPLR_AUTH_MIGRATIONS. The migration ledger is keyed by filename alone, so a second 0003_menus.sql in another directory is treated as already applied and silently skipped. Keep filenames unique across directories.

install.sh --no-migrate skips migrations. Use it on every host but one, and run them once from that host:

cd api && DOTENV_CONFIG_PATH=$PWD/production.env npm run migrate:up

More than one API host

  • Jobs. The cron loop runs inside the API process by default. With more than one host, set JOBS_SCHEDULER=false everywhere and run xeplr-jobs-server on exactly one host. Otherwise every instance picks the same due job.
  • Runs. Report and dashboard run results are held in memory for 10 minutes, per process. This is correct only while the API runs as a single process.
  • DuckDB. One writer per file, and the lock is local to the machine.

Sizing the warehouse

The xeplr-datawarehouse repository has a bench harness. Run it on the target machine, not on a laptop. Disk speed, memory bandwidth and antivirus scanning change the result more than RAM does.

cd bench && npm install && npm run bench     # 20M rows, about a minute
ROWS=50000000 npm run bench

On Windows, exclude the .duckdb files from Defender before trusting any result.

Last updated 16 September 2026