You resell this stack to multiple clients. There are three realistic ways to lay that out on infrastructure. This doc covers all three with honest tradeoffs and the exact commands. Short version: one VM per client is the default and what we recommend — the other two exist for cost reasons and come with caveats you need to understand before you pick them.
Whatever you pick, the isolation primitive is the same: STACK_NAME in each client's
.env becomes the Compose project name (name: ${STACK_NAME} in
docker-compose.yml), which prefixes that client's networks (${STACK_NAME}_web,
${STACK_NAME}_internal) and volumes (${STACK_NAME}_prometheus_data, …). That's what
keeps two stacks from ever sharing state.
Model 1 — One VM per client (default, recommended)
Each client gets their own box: a dedicated droplet / EC2 instance / bare-metal VM that runs exactly one copy of the stack.
When to pick it: almost always. Any client big enough to pay for monitoring, any
client with compliance or data-isolation expectations, and any time you want a clean
blast-radius story ("your monitoring is on your own server, nobody else's data touches
it"). This is what new-client.sh targets by default.
Why it's the default:
- Strongest isolation — separate kernel, separate resources, separate IP, separate everything. A runaway Prometheus on client A cannot starve client B.
- Simplest failure story — reboot, resize, snapshot, or destroy one client without touching another.
- Traefik owns ports 80/443 on that box outright — no contention, Let's Encrypt HTTP-01 just works.
- Per-client billing maps cleanly to per-client infra cost.
Cost: one small VM per client (this stack runs comfortably on ~2 vCPU / 4 GB for a
handful of monitored hosts; add RAM/disk for long PROM_RETENTION or the logs
profile). That's the price of the isolation.
Deploy:
# On a fresh Ubuntu 24.04 VM with Docker installed:
cd stack
../multi-client/new-client.sh # generates .env (secrets, admin hash, alertmanager.yml)
../white-label/rebrand.sh --name "Acme Corp" --logo ./acme.png \
--color "#3b82f6" --domain monitoring.acme.example.com
docker compose up -d # core
docker compose --profile backup up -d # + backups (recommended for prod)
Because there's only one stack on the box, plain docker compose (which reads .env
in the current dir) is all you need.
Model 2 — Shared host, multiple isolated stacks (cost-efficient for small clients)
One VM runs several clients, each as its own Compose project. They share the kernel and the machine's resources, but each client still gets separate Docker networks and volumes — no shared monitoring data.
When to pick it: several small, low-stakes clients (a couple of monitored hosts each) where a full VM per client isn't worth it, and where you're comfortable that "same box, separate stacks" is enough isolation for them. Good for internal projects, trials, and price-sensitive clients.
How isolation works: you drive each client from a per-client env file and an explicit project name. The project name prefixes networks + volumes, so the stacks never collide:
# Keep one env file per client, e.g. clients/acme.env, clients/globex.env
# (each sets its own STACK_NAME, DOMAIN, passwords, etc.)
docker compose -p acme --env-file clients/acme.env up -d
docker compose -p globex --env-file clients/globex.env up -d
# Operate them independently:
docker compose -p acme ps
docker compose -p globex logs -f prometheus
docker compose -p acme --profile backup up -d
Set -p to the same value as that file's STACK_NAME (the -p flag wins over the
name: field, so keep them identical to avoid confusion).
The Traefik / ports 80+443 caveat — read this
The shipped docker-compose.yml binds Traefik to host ports 80 and 443:
ports:
- "80:80"
- "443:443"
Only one process can own port 80 (and 443) on a host. So you cannot naively bring up two full stacks on the same box — the second one's Traefik fails to bind. You have two honest ways to resolve it:
-
One shared front proxy (recommended for shared-host). Run a single reverse proxy (one shared Traefik, or Caddy/nginx) that owns 80/443 on the host, and have each client stack's services sit behind it instead of each stack running its own internet-facing Traefik. This is the clean answer, but it's setup you add yourself — the kit ships a per-stack Traefik, not a shared one — and all clients' TLS then terminates in one place (a shared blast radius for the proxy).
-
Distinct host ports per stack. Override the port mapping so each client's Traefik uses different host ports (e.g. client A
80/443, client B8080/8443). This is simple to state but has a real gotcha: Let's Encrypt HTTP-01 needs port 80, and only one stack can own it. The others can't complete the HTTP-01 challenge, so you'd have to switch those to the DNS-01 challenge (a Traefik config change + DNS-provider API credentials). Workable, but more moving parts than it looks.
If either of those feels like too much operational surface for the money you're saving, that's exactly the signal to use Model 1 instead. This tension is why one-VM-per- client is the default.
Resource sizing: size the shared box for the sum of its tenants. Each client stack
runs its own Prometheus, Grafana, Uptime Kuma, Alertmanager, and exporters — that adds
up. Budget roughly per-client what a solo stack uses (~1–1.5 GB RAM baseline per stack,
more with the logs profile and long retention), plus headroom. Keep PROM_RETENTION
modest on shared boxes and watch disk — every client's TSDB lives on the same volume
group.
Model 3 — One shared stack, multi-tenant by labels (NOT recommended — out of scope)
The tempting "efficient" idea: run one Prometheus / Grafana / Alertmanager and
separate clients only by labels (a client="acme" label on every series, Grafana
folders/orgs per client, routing by label in Alertmanager).
This kit deliberately does not do this, and you shouldn't build it on top of this kit. Reasons:
- No hard isolation. All clients' metrics live in one TSDB and one Grafana. One bad dashboard query, one mis-scoped permission, one label typo, and client A can see client B's data. Isolation-by-convention breaks under human error.
- Shared blast radius. One Prometheus OOMs → every client is blind at once. One upgrade or config mistake takes down the whole fleet.
- Painful per-client operations. No clean per-client backup/restore, retention, offboarding, or "reset just this client" — everything is entangled in shared volumes.
- White-labeling fights you. Grafana root URL, branding, and the public status page are per-instance in this stack; a single shared instance can't cleanly present as each client's own branded platform, which is the whole product.
Per-client isolation (Models 1 and 2) is the safer default, and it's why this kit is
built around STACK_NAME-scoped projects rather than a single multi-tenant instance.
(A future Kubernetes/Helm add-on is noted as out of scope in stack/compose.profiles.md;
even that keeps per-client isolation.)
Decision table
| Client profile | Budget | Isolation needs | Pick |
|---|---|---|---|
| Any paying client of meaningful size | Can afford a small VM | Standard-to-strict | Model 1 — VM per client |
| Compliance / sensitive data / "our own server" ask | Any | Strict | Model 1 — VM per client |
| Several tiny clients (1–2 hosts each), low stakes | Tight | "Separate data is fine, shared box OK" | Model 2 — shared host |
| Internal projects / trials / demos | Minimal | Low | Model 2 — shared host |
| "Just make it cheap, one instance for everyone" | Minimal | You think low | Reconsider — use Model 2, not Model 3 |
| Regulated, multi-tenant SaaS-grade separation | Any | Very strict | Model 1 (never Model 3) |
Rule of thumb: start every client on Model 1. Drop to Model 2 only when you have a cluster of genuinely small clients and you've accepted the shared-host caveats above. Skip Model 3.
DNS — required for every client, every model
Regardless of model, each client needs these five subdomains, all A-record'd to the public IP of the VM their stack runs on (the same IP for all five):
status.<client-domain> # public status page (Uptime Kuma)
grafana.<client-domain> # dashboards
metrics.<client-domain> # Prometheus (admin basic-auth)
alerts.<client-domain> # Alertmanager (admin basic-auth)
traefik.<client-domain> # Traefik dashboard (admin basic-auth)
<client-domain> is the DOMAIN value in that client's .env. TLS certificates are
issued automatically by Traefik via Let's Encrypt's HTTP-01 challenge on port 80,
so DNS must resolve before you bring the stack up, and port 80 must be reachable from
the internet. On a shared host, remember only one stack's Traefik can own port 80 — see
the Model 2 caveat above.