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.yaml | docker-compose.dev.yaml | docker-compose.prod.yaml | |
|---|---|---|---|
| Use for | checking it builds and runs as shipped | editing code | a real single-VM install |
| Start with | docker compose up --build | docker compose -f docker-compose.dev.yaml up | four one-time steps, below |
| core | built image opentremor-core-test:latest from docker/core-with-analyzers.Dockerfile, dependencies baked in at build | python:3.14-slim over bind-mounted repos, poetry install at container start | built image opentremor-core-prod:latest, same Dockerfile |
| dashboard | next build baked into opentremor-dashboard:latest | node:22-slim, npm run dev, live reload | opentremor-dashboard-prod:latest, built domain-rooted rather than path-prefixed |
| docs | static Starlight build behind nginx | npm run dev --host | static build behind nginx |
| task-scheduler | absent | present | present |
| Editing source | needs a rebuild (--build) | picked up live | needs a rebuild |
| First boot | slow — three images build | slow — poetry install + npm install, then cached in named volumes | slow — four images build |
| Routing | path-routed under one localhost origin (haproxy/dev.cfg) | same | one subdomain per service, with TLS (haproxy/prod.cfg) |
| HAProxy | 8080 | 8080 | 80, 8080 and 443 |
| core published | 8000 | 8001 | not published |
| MongoDB published | 27017 | 27018 | not published |
| dashboard / docs published | 3001 / 4321 | 3001 / 4321 | not published |
| Config file | configs/opentremor-local.yaml | same file | opentremor-core-prod.yaml |
| Secrets | none — dev credentials live in the config file | same | .env required; the stack refuses to start without JWT_SECRET, LLM_CREDENTIAL_KEY, TWO_FACTOR_SECRET_KEY and AUTH_API_KEY |
| Restart policy | none | none | unless-stopped on every service |
| TLS | none | none | Let’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.
What docker-compose.prod.yaml runs
Section titled “What docker-compose.prod.yaml runs”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 fromdocker/core-with-analyzers.Dockerfiledashboard— built domain-rooted (not path-prefixed), pointed at the API’s own subdomaindocs— static Starlight build served via nginxhaproxy— TLS termination and routing, the only container publishing anything (80,8080and443)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:443before any real certificate exists
Each app service gets its own subdomain rather than being path-prefixed under one origin
— see haproxy/prod.cfg:
| Host | Serves |
|---|---|
api.example.com | the API |
docs.example.com | the docs |
app.example.com | the dashboard |
example.com | your 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_defaultHAProxy 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.
Prerequisites
Section titled “Prerequisites”- 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
80and443free on the host (HAProxy owns them)
Install
Section titled “Install”1. Configure
Section titled “1. Configure”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_originsto 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.
2. Bring up the stack
Section titled “2. Bring up the stack”docker compose -f docker-compose.prod.yaml up --build -dHAProxy binds :443 immediately using a temporary self-signed certificate — this is
expected before the next step.
3. Request real certificates (one-time)
Section titled “3. Request real certificates (one-time)”docker compose -f docker-compose.prod.yaml --profile init run --rm certbot-initEdit 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:
docker compose -f docker-compose.prod.yaml restart haproxy-reloader4. Set the dashboard base URL
Section titled “4. Set the dashboard base URL”Same one-time step as the quickstart, pointed at your real domain:
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"}'Renewal
Section titled “Renewal”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.
Upgrade
Section titled “Upgrade”docker compose -f docker-compose.prod.yaml up --build -dRebuilds and replaces the app images; MongoDB data and certificates persist in their volumes/bind mounts untouched.
Automatic rebuild on rsync
Section titled “Automatic rebuild on rsync”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):
sudo mkdir -p /var/lib/opentremor-autodeploysudo chown $RSYNC_USER /var/lib/opentremor-autodeploysudo STATE_DIR=/var/lib/opentremor-autodeploy /srv/opentremor/scripts/autodeploy.sh --seedsudo cp /srv/opentremor/systemd/opentremor-autodeploy.{path,service} /etc/systemd/system/sudo systemctl daemon-reloadsudo systemctl enable --now opentremor-autodeploy.pathThen have your local sync touch the sentinel file the .path unit watches:
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.
What a sync rebuilds
Section titled “What a sync rebuilds”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 path | Effect |
|---|---|
OpenTremor-core, OpenTremor-platform, docker/core-with-analyzers.Dockerfile | rebuild opentremor-core |
OpenTremor-analyzer-* | rebuild opentremor-core and docs — both copy the analyzer repos |
OpenTremor-dashboard | rebuild dashboard |
OpenTremor-docs, docker/docs.Dockerfile, docker/docs-nginx.conf | rebuild docs |
OpenTremor-task-scheduler | rebuild task-scheduler |
haproxy/prod.cfg | restart haproxy — it’s a bind mount, so no rebuild |
docker-compose.prod.yaml | rebuild 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.
When a deploy fails
Section titled “When a deploy fails”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.
journalctl -fu opentremor-autodeploy # follow a run./scripts/autodeploy.sh --dry-run # report what would rebuildBackup
Section titled “Backup”Persistent state lives entirely in bind mounts under ./data/ — ./data/mongodb and
./data/certbot. Back those up; the containers themselves are stateless and disposable.