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_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_PREFIXto 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/authrefuses to start when the prefix is unset or isxeplr:. - 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.envat 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/eventsso 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=falseeverywhere and runxeplr-jobs-serveron 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