Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersSelf-hosting CMDash

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 term30 days, at the Command feature tier, one per account
TopologyAll-in-one Docker Compose: Postgres, Redis, MinIO, Vault, plus api, web, worker and the updater sidecar
Phones homeOnly if you configure it to. CONTROL_PLANE_URL empty = no beacon, no outbound licence refresh
At the endNothing 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_PORT set 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

bash
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:

VariableWhy
APP_DOMAINThe 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_TOKENInfrastructure credentials
JWT_SECRETSession signing. Preflight warns below 64 characters
LICENSE_PUBLIC_KEY or LICENSE_PUBLIC_KEYSOnly for an unpinned build. A released image verifies against its compiled-in key and ignores these
LICENSE_DIRWhere 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 \n escapes, or leave LICENSE_PUBLIC_KEY empty and point LICENSE_PUBLIC_KEY_FILE at a .pem file, which install.sh inlines for you. cmdash.env.example carries the awk one-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

bash
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

bash
./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:

code
  ✔ 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

bash
./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

bash
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:

  1. 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_KEY that does not match the key the licence was signed with.
  2. The API URL is right. If the browser's network tab shows calls to localhost:3001 rather than your domain, APP_DOMAIN was 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 updater sidecar, which rolls api and web on this host and rolls back automatically on a failed health gate. deploy/onprem/README.md describes the sidecar, including the fact that its container-runtime socket mount is root-equivalent access.
  • Backups are yours. Postgres, the MinIO bucket, and secrets/ plus cmdash.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:

bash
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).

  • 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.

This page is the product manual, published from the repository at docs/developers/self-host.md. Customers read the same text inside CMDash under Help. Last updated October 12, 2026.

Self-hosting CMDash — CMDash Docs