Self-hosting CMDash
Where to run it. This page is the Docker host path. To run CMDash in your own AWS account from a CloudFormation template and the CMDash AMI, read self-host-aws.md. Every install needs an entitlement: a signed CMDash licence file (this page), or, on the AWS template only, an AWS Marketplace subscription (
Licensing=marketplace). Licence verification is offline.
CMDash is built to run on your own hardware, with the same code the hosted service runs. This guide is the path from "I want to try it" to a logged-in workspace on a machine you control.
It is deliberately short. The material that already exists in the repository is
linked, not repeated — the installer's own reference is
deploy/onprem/README.md, and the full operator runbook (air-gap, backup and
restore, upgrade, troubleshooting) is docs/runbooks/onprem-install.md. What
lives here and nowhere else is the trial-licence path, what preflight actually
checks, and first login.
What you get
| Trial term | 30 days, at the Command feature tier, one per account |
| Topology | All-in-one Docker Compose: Postgres, Redis, MinIO, Vault, plus api, web, worker and the updater sidecar |
| Phones home | Only if you configure it to. CONTROL_PLANE_URL empty = no beacon, no outbound licence refresh |
| At the end | Nothing is deleted or locked. The licence degrades through a grace period to read-only; reads and data export keep working |
A trial installation watermarks itself. The licence carries a signed
trial claim, so the product shows an evaluation banner while it is in force.
The claim is inside the signed payload — editing it out of license.json
invalidates the file rather than removing the banner.
Before you start
You need a Linux host (x86_64 or arm64) with:
- Docker Engine and the compose v2 plugin (
docker compose version). Compose v1 is not supported. - 2 CPU cores and 4 GB RAM as a hard floor; 4 cores and 8 GB is the recommendation. 10 GB free on the Docker root directory, 20 GB recommended.
- Ports 80 and 443 free for the bundled Caddy edge (or
EDGE_HTTP_PORT/EDGE_HTTPS_PORTset to ones that are), and 3000 / 3001 (WEB_PORT/API_PORT, bound to 127.0.0.1). jq, optional but worth installing: with it, preflight reads your licence file and tells you what is in it before anything is installed.
TLS is terminated by the Caddy edge the all-in-one topology ships on 80/443; the
web and api ports listen on 127.0.0.1 only. Point your DNS name at this host. If you
already run your own reverse proxy on 80/443, move Caddy with EDGE_HTTP_PORT /
EDGE_HTTPS_PORT and proxy to it.
1. Get a trial licence
Your licence is a signed license.json that CMDash issues for your account. A self-host trial licence runs
30 days, one per account; an Enterprise plan carries a licence for its term. Licences appear under Licenses
in the customer portal, which shows the file once. Save it. If you
lose it before the install, you can retrieve the same bytes from the running
deployment later (Licenses → Download license.json), but not from the portal —
each account gets one trial, and asking for a second returns the date the first
one was issued rather than a new licence.
The Ed25519 public key the licence is verified against is compiled into the
released images, so there is nothing to download or configure for it
(LICENSE_PUBLIC_KEY / LICENSE_PUBLIC_KEYS matter only on an unpinned build,
and a released image ignores them). Verification is entirely
offline — the install never contacts CMDash to check a licence. That said,
air-gapped installation is not supported for a separate reason: see
"Air-gapped installs" in §5 below.
Already self-hosting and just renewing? Skip to §4 of
docs/runbooks/onprem-install.md. Renewal is an upload, not a reinstall.
2. Configure
git clone https://github.com/bgovanlu/cmdash.git
cd cmdash/deploy/onprem
cp cmdash.env.example cmdash.env
Open cmdash.env and fill in every CHANGE_ME. Preflight fails on any that
are left, which is the point of the placeholder. The ones that matter most:
| Variable | Why |
|---|---|
APP_DOMAIN | The hostname Caddy serves with TLS. The web app builds its API URL from it at image build time; get it wrong and the browser calls localhost:3001 (runbook §7.5) |
POSTGRES_PASSWORD, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, VAULT_TOKEN | Infrastructure credentials |
JWT_SECRET | Session signing. Preflight warns below 64 characters |
LICENSE_PUBLIC_KEY or LICENSE_PUBLIC_KEYS | Only for an unpinned build. A released image verifies against its compiled-in key and ignores these |
LICENSE_DIR | Where license.json lives on the host. Default ./secrets/license |
The single-line PEM trap. A PEM is multi-line and a raw multi-line value breaks compose's dotenv parser — the stack then fails to start with an "invalid interpolation" error that says nothing about licences. Either write the value as one quoted line with literal
\nescapes, or leaveLICENSE_PUBLIC_KEYempty and pointLICENSE_PUBLIC_KEY_FILEat a.pemfile, whichinstall.shinlines for you.cmdash.env.examplecarries theawkone-liner that does the conversion. Preflight warns when it sees a raw PEM.
Every variable, including the optional ones, is documented in
deploy/compose/README.md and deploy/compose/.env.example.
3. Place the licence
mkdir -p ./secrets/license
cp ~/Downloads/license.json ./secrets/license/license.json
LICENSE_DIR is bind-mounted to /etc/cmdash in the containers, and
install.sh chowns it to uid 1001 so the in-app upload can later write a
renewed licence over it.
To install without a licence and upload one from the UI afterwards, pass
--skip-license to both scripts. The stack boots unlicensed — a read-only setup
mode, not a failure.
4. Preflight
./preflight.sh # or: make preflight
Preflight changes nothing. It checks the host (OS, Docker, cores, RAM, disk,
free ports), the env file (required keys, no leftover placeholders, file
permissions that are not world-readable), and the licence file. Resolve every
✘; ⚠ lines are advisory and do not block.
With jq installed it also reads inside license.json and reports the envelope
shape, product, deployment type, key id and how much term is left — and names a
trial licence as one:
✔ License file present: /opt/cmdash/deploy/onprem/secrets/license/license.json
✔ License file parses as JSON
✔ License is for on-prem, tier COMMAND (key k1)
✔ License valid for 29 more day(s), until 2026-10-10T18:42:53Z
[preflight] TRIAL — this is a self-serve evaluation license. The product
watermarks itself as an evaluation while it is in force; convert it from the
portal to remove the watermark.
Preflight does not verify the signature. It cannot — the host may have no Node runtime, no Ed25519-capable OpenSSL and (air-gapped) no network. The Ed25519 check runs in the API at every boot, against the key you configured, and that is the authority. Preflight's licence checks exist to catch the boring mistakes early: the wrong file copied, a truncated download, a licence that expired while it sat in an inbox, or the portal's status export saved by mistake in place of the licence itself.
5. Install
./install.sh # or: make install
install.sh is idempotent — re-run it for configuration changes, repairs and
upgrades. It runs preflight, obtains images, brings the stack up, applies the
schema, bootstraps the MinIO bucket, fixes the licence directory ownership,
waits for /api/health, and prints your access details.
Air-gapped installs. A connected host can build and verify a signed
bundle and pass it to install.sh --bundle — quick-starts are in
deploy/onprem/README.md, and the signing and verification steps (an
unsigned bundle is rejected by default) are in §8 of
docs/runbooks/onprem-install.md. Air-gapped installation is not
supported, though: today's bundle ships 3 of the 9 images a full stack
needs, so the remaining images still have to come from the network, and a
genuinely disconnected host cannot complete an install from it.
6. First login
curl -fsS http://localhost:3001/api/health # expect 200
make status # every service Up/healthy
Then browse to https://$APP_DOMAIN/ and complete first-run administrator
setup. The first account you create is the workspace owner, and the page asks for
the setup token install.sh printed at the end (it is FIRST_RUN_BOOTSTRAP_TOKEN
in cmdash.env), so a stranger who reaches the URL first cannot take it. On an
install that can send no mail that first owner is marked verified, so invitations,
exports and API keys work at once.
What protects first-run, with or without mail. On a self-hosted production install (on-prem, customer-aws) the first
account owns the whole installation, so the setup token is demanded for it on every path: the email form (even when mail
is configured and a verification link would be sent), and first-run with no mail. Google and Microsoft sign-in cannot
create the first workspace; connect them after the owner exists. An install with no token (or one shorter than 16
characters) is closed to first-run rather than open: set FIRST_RUN_BOOTSTRAP_TOKEN and restart. install.sh,
the AWS first boot (read it over SSM: sudo grep '^FIRST_RUN_BOOTSTRAP_TOKEN=' /opt/cmdash/.env; it is never written to a
log) and the Helm chart (a Secret key, kept across upgrades) all generate one. Outside production (development) there is
no token and the first-run owner is left unverified. Before a first owner exists, nothing else protects the URL, so do not
publish it until you have signed in.
If the only owner loses their password on an install with no mail, run
./reset-owner.sh --email <owner> on the host (it prints a temporary password, revokes
their sessions, and with --disable-mfa removes a lost authenticator).
Confirm two things before you go further:
- The licence took. Settings → Licenses shows the state, tier, expiry and
days remaining. On a trial you will also see the evaluation banner across the
top of the app. If it says unlicensed, the file did not verify — almost
always a
LICENSE_PUBLIC_KEYthat does not match the key the licence was signed with. - The API URL is right. If the browser's network tab shows calls to
localhost:3001rather than your domain,APP_DOMAINwas wrong at build or start; runbook §7.5 has the fix.
While the trial is running
- The evaluation banner is expected. It disappears when the trial licence is replaced with a purchased one — the watermark is a property of the licence, not a setting.
- Nothing is time-bombed. At the end of the 30 days the licence enters a grace period (14 days by default) with full function, then read-only. Reads and data export never stop working; you can always get your data out.
- Updates. Settings → Updates drives the
updatersidecar, which rollsapiandwebon this host and rolls back automatically on a failed health gate.deploy/onprem/README.mddescribes the sidecar, including the fact that its container-runtime socket mount is root-equivalent access. - Backups are yours. Postgres, the MinIO bucket, and
secrets/pluscmdash.env. Runbook §5 has the exact commands.
Converting to a full licence
Buy from the portal's billing page, or talk to us for annual prepay by invoice.
You will be issued a replacement license.json; upload it at Settings →
Licenses (permitted even in read-only, so an expired trial can renew its way
out) or, on an air-gapped host, copy it over the old file and restart api and
worker. Nothing is reinstalled and no data moves.
If something goes wrong
Runbook §7 covers the known traps by symptom, including the schema push failing,
compose interpolation errors from a multi-line PEM, licence upload permission
errors, and the localhost:3001 case. make logs SVC=api is usually the
fastest first look.
Integration sync frequency
On CMDash's hosted service, each plan has a sync floor: a connection syncs at most once per 24 hours on Free, once per 4 hours on Starter, and once per hour on Essentials and above. The floor covers scheduled syncs, "Sync now" and the public API's sync route. It exists to bound CMDash's hosting cost per plan.
A self-hosted install runs on your own hardware, so there is no cost of ours to bound. The floor is off
by default when DEPLOYMENT_MODE is on-prem or customer-aws: each connection syncs at its own
configured interval, and "Sync now" is not limited. Webhook events still collapse into one sync per
connection per minute; events inside that minute are synced at its end rather than dropped.
To apply your licensed plan's floors anyway, set this in cmdash.env:
INTEGRATION_SYNC_TIER_FLOOR=on # on | off | auto (auto, the default, is off when self-hosted)
The API logs which rule applies when it starts (per-tier sync floor ENFORCED or OFF, with the reason).
Related
deploy/onprem/README.md— the installer's own reference: file-by-file purpose, connected and air-gapped quick-starts, the updater sidecar.docs/runbooks/onprem-install.md— the full operator runbook.deploy/compose/README.md— every configuration variable.- Public API reference — what to automate against once you are up.