Skip to content

Docker (Single-VM)

A self-hosted OpenTremor instance runs as a set of Docker containers on a single host — there is no separate installer package. The workspace root ships three Compose files. All three describe the same topology — core, dashboard, docs and MongoDB behind one HAProxy front door — and differ only in how the code gets into the containers and how traffic reaches them.

docker-compose.yamldocker-compose.dev.yamldocker-compose.prod.yaml
Use forchecking it builds and runs as shippedediting codea real single-VM install
Start withdocker compose up --builddocker compose -f docker-compose.dev.yaml upfour one-time steps, below
corebuilt image opentremor-core-test:latest from docker/core-with-analyzers.Dockerfile, dependencies baked in at buildpython:3.14-slim over bind-mounted repos, poetry install at container startbuilt image opentremor-core-prod:latest, same Dockerfile
dashboardnext build baked into opentremor-dashboard:latestnode:22-slim, npm run dev, live reloadopentremor-dashboard-prod:latest, built domain-rooted rather than path-prefixed
docsstatic Starlight build behind nginxnpm run dev --hoststatic build behind nginx
task-schedulerabsentpresentpresent
Editing sourceneeds a rebuild (--build)picked up liveneeds a rebuild
First bootslow — three images buildslow — poetry install + npm install, then cached in named volumesslow — four images build
Routingpath-routed under one localhost origin (haproxy/dev.cfg)sameone subdomain per service, with TLS (haproxy/prod.cfg)
HAProxy8080808080, 8080 and 443
core published80008001not published
MongoDB published2701727018not published
dashboard / docs published3001 / 43213001 / 4321not published
Config fileconfigs/opentremor-local.yamlsame fileopentremor-core-prod.yaml
Secretsnone — dev credentials live in the config filesame.env required; the stack refuses to start without JWT_SECRET, LLM_CREDENTIAL_KEY, TWO_FACTOR_SECRET_KEY and AUTH_API_KEY
Restart policynonenoneunless-stopped on every service
TLSnonenoneLet’s Encrypt, requested once and renewed unattended

Only the production file is covered on this page; for the other two see Quick Start. For a Kubernetes deployment instead of a single host, see the Helm chart in OpenTremor-deployments.


All at the workspace root, alongside OpenTremor-core, both Terraform analyzer repos, OpenTremor-dashboard, and OpenTremor-docs checked out as siblings:

  • mongodb — persistent storage (./data/mongodb)
  • opentremor-core — API, built from docker/core-with-analyzers.Dockerfile
  • dashboard — built domain-rooted (not path-prefixed), pointed at the API’s own subdomain
  • docs — static Starlight build served via nginx
  • haproxy — TLS termination and routing, the only container publishing anything (80, 8080 and 443)
  • certbot / certbot-bootstrap / certbot-webroot / haproxy-reloader — request and renew Let’s Encrypt certificates and hot-reload HAProxy with zero dropped connections, with a temporary self-signed cert generated on first boot so HAProxy can bind :443 before any real certificate exists

Each app service gets its own subdomain rather than being path-prefixed under one origin — see haproxy/prod.cfg:

HostServes
api.example.comthe API
docs.example.comthe docs
app.example.comthe dashboard
example.comyour own website, if you run one; otherwise a redirect to app.example.com

The bare domain is left free for a site of your own — a landing or marketing page, which OpenTremor doesn’t ship. Deploy it from its own repository, as its own Compose project: a service named website, serving HTTP on port 3000, attached to this stack’s network as an external network. That network is <project>_default, where the project defaults to the name of the folder holding docker-compose.prod.yaml — opentremor_default for a checkout in /srv/opentremor:

services:
website:
build: .
networks: [opentremor]
networks:
opentremor:
external: true
name: opentremor_default

HAProxy sends example.com to it as soon as it passes a health check. Without one, HAProxy starts normally and answers the bare domain with a temporary (302) redirect to app.example.com, path kept — example.com/login lands on app.example.com/login — and it goes back to that redirect if the site stops. On the same network the site reaches the API as http://opentremor-core:8000.


  • A Linux host with Docker + Compose
  • Four DNS records (API, docs, dashboard app., bare domain) already pointing at the host’s public IP — Let’s Encrypt’s HTTP-01 challenge needs to reach the host over the real internet on port 80 before any cert can be issued
  • Ports 80 and 443 free on the host (HAProxy owns them)

Copy and edit OpenTremor-core/configs/opentremor-core-prod.yaml — see that file’s own header comment for which fields have an environment-variable override. At minimum, set:

  • auth.api_key (bootstrap key — file-only, no env override)
  • cors.allow_origins to your dashboard’s real origin (a credentialed request can’t use *) — https://app.example.com. The bare domain never serves the dashboard, so it needs no entry

Prefer environment variables (or an untracked env file) over editing secrets directly into the YAML for anything that has an override — MONGODB_URI, JWT_SECRET, LLM_CREDENTIAL_KEY, DEFAULT_ADMIN_EMAIL, DEFAULT_ADMIN_PASSWORD.

Since the dashboard and API don’t share an origin here, also set SERVER_COOKIE_DOMAIN (e.g. .example.com) on the opentremor-core service in docker-compose.prod.yaml — without it the session cookie set at login defaults host-only to the API’s subdomain, and the dashboard’s server-side requests on the other host never see it. See Dashboard Deployment for the full explanation.

Terminal window
docker compose -f docker-compose.prod.yaml up --build -d

HAProxy binds :443 immediately using a temporary self-signed certificate — this is expected before the next step.

Terminal window
docker compose -f docker-compose.prod.yaml --profile init run --rm certbot-init

Edit the --email and the four -d domains in docker-compose.prod.yaml’s certbot-init service before running. This is deliberately not part of the normal up — running it repeatedly during testing risks Let’s Encrypt’s rate limits. Once it succeeds, haproxy-reloader picks up the real certs on its next check (hourly), or force it immediately:

Terminal window
docker compose -f docker-compose.prod.yaml restart haproxy-reloader

Same one-time step as the quickstart, pointed at your real domain:

Terminal window
curl -X PATCH https://api.example.com/admin/settings \
-H "X-API-Key: <bootstrap-api-key>" -H "Content-Type: application/json" \
-d '{"server_dashboard_base_url": "https://app.example.com"}'

Unattended. The certbot service calls certbot renew roughly twice a day (a no-op unless a certificate is within 30 days of expiry); haproxy-reloader notices the resulting file change and reloads HAProxy with zero downtime. Nothing to schedule yourself.


Terminal window
docker compose -f docker-compose.prod.yaml up --build -d

Rebuilds and replaces the app images; MongoDB data and certificates persist in their volumes/bind mounts untouched.


If you develop on one machine and rsync the workspace to the host running this stack, scripts/autodeploy.sh turns each completed sync into a targeted rebuild and redeploy, instead of you running the upgrade command above by hand. Build host and prod host are the same machine — images are built and replaced in place, with no registry involved.

Install it once (full steps, including the systemd units, in systemd/README.md at the workspace root):

Terminal window
sudo mkdir -p /var/lib/opentremor-autodeploy
sudo chown $RSYNC_USER /var/lib/opentremor-autodeploy
sudo STATE_DIR=/var/lib/opentremor-autodeploy /srv/opentremor/scripts/autodeploy.sh --seed
sudo cp /srv/opentremor/systemd/opentremor-autodeploy.{path,service} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now opentremor-autodeploy.path

Then have your local sync touch the sentinel file the .path unit watches:

Terminal window
rsync -az --delete ~/opentremor/ buildserver:/srv/opentremor/ \
&& ssh buildserver 'touch /var/lib/opentremor-autodeploy/rsync-done'

The && matters: a failed or interrupted transfer must not trigger a deploy. So does -a (or at least -t) — change detection hashes each file’s path, size and mtime, so mtimes have to survive the transfer. Sync without them and every image rebuilds every time; set FINGERPRINT_MODE=content in the service unit if you can’t.

Each service is fingerprinted over exactly the paths its Dockerfile copies, so a change in one repo doesn’t rebuild the other four images:

Changed pathEffect
OpenTremor-core, OpenTremor-platform, docker/core-with-analyzers.Dockerfilerebuild opentremor-core
OpenTremor-analyzer-*rebuild opentremor-core and docs — both copy the analyzer repos
OpenTremor-dashboardrebuild dashboard
OpenTremor-docs, docker/docs.Dockerfile, docker/docs-nginx.confrebuild docs
OpenTremor-task-schedulerrebuild task-scheduler
haproxy/prod.cfgrestart haproxy — it’s a bind mount, so no rebuild
docker-compose.prod.yamlrebuild everything (build args and image names live there)

certbot-init is never run automatically — issuing certificates stays the one-time manual step in step 3 above, and re-running it on every sync would hit Let’s Encrypt’s rate limits.

A failed build deploys nothing and leaves the running containers serving. A service that comes up but fails its health check — the image’s own HEALTHCHECK where it declares one, otherwise staying up without the daemon restarting it — is rolled back, along with everything else deployed in the same pass, to the image it was running before.

Either way the recorded state is not advanced, so the next sync retries the same work. A broken change therefore keeps being retried until you fix it, rather than being silently skipped.

Terminal window
journalctl -fu opentremor-autodeploy # follow a run
./scripts/autodeploy.sh --dry-run # report what would rebuild

Persistent state lives entirely in bind mounts under ./data/ — ./data/mongodb and ./data/certbot. Back those up; the containers themselves are stateless and disposable.