Repository navigation
Kubernetes
The Helm chart in charts/marmot deploys the published image
(ghcr.io/thinkhumandotdev/marmot) with each role in its own Deployment, a migration Job, an Ingress
that routes /socket.io to the realtime process, and optionally a bundled Postgres and Redis and
probe agents. Each release publishes it as an OCI artifact at
oci://ghcr.io/thinkhumandotdev/charts/marmot, with the same version as the image. The commands below
install the newest release; add --version X.Y.Z to pin one (the chart version selects the image).
┌── Ingress <release>-web / ──▶ Service web :3000 ──▶ Deployment web (N)
internet ──┤ │ uploads PVC or S3
└── Ingress <release>-realtime /socket.io ──▶ Service realtime :3001 ─▶ Deployment realtime
Deployment worker (N) ──┐
Job migrate (install: regular Job, upgrade: pre-upgrade hook) ───▶ postgres | mongodb ◀┤
redis ◀──────────────┘
Requirements: Kubernetes 1.25 or later, Helm 3.8 or later (OCI support), an ingress controller that passes WebSocket upgrades (ingress-nginx, Traefik, HAProxy and most cloud controllers do) and a default storage class for the uploads volume and the bundled databases.
The bundled Postgres and Redis are single pods without backups. They are fine for trying Marmot out and for small installs you are happy to back up yourself.
helm install marmot oci://ghcr.io/thinkhumandotdev/charts/marmot \
--namespace marmot --create-namespace \
--set serverUrl=https://status.example.com \
--set postgresql.enabled=true --set redis.enabled=true \
--set ingress.enabled=true --set ingress.className=nginx --set ingress.host=status.example.comThe install creates the schema with a Job and web turns ready once it has finished (kubectl -n marmot logs -f job/marmot-migrate-1). Then open the URL and complete the setup wizard
(Getting started). Without an ingress, kubectl port-forward svc/marmot-web 3000
serves the UI, but live updates need /socket.io on the same origin (see Ingress).
helm test marmot -n marmot checks /api/health, the realtime /healthz and a socket.io handshake from
inside the cluster.
Use managed databases (or operators such as CloudNativePG) and keep the secrets out of the values file:
kubectl -n marmot create secret generic marmot-secrets \
--from-literal=PAYLOAD_SECRET="$(openssl rand -hex 32)" \
--from-literal=DATABASE_URL='postgres://marmot:...@db.internal:5432/marmot' \
--from-literal=REDIS_URL='redis://:...@redis.internal:6379' \
--from-literal=SMTP_PASSWORD='...'# values.yaml
serverUrl: https://status.example.com
secrets:
existingSecret: marmot-secrets
config:
DISABLE_SIGNUP: 'true'
EMAIL_FROM: Marmot <marmot@example.com>
SMTP_HOST: smtp.example.com
SMTP_USER: marmot
S3_BUCKET: marmot-uploads # several web replicas need S3 (or a ReadWriteMany volume)
S3_REGION: eu-central-1
persistence:
enabled: false # uploads go to S3
web:
replicas: 2
pdb: { enabled: true }
worker:
replicas: 2
ingress:
enabled: true
className: nginx
host: status.example.com
annotations:
cert-manager.io/cluster-issuer: letsencrypt
tls:
- secretName: marmot-tls
hosts: [status.example.com]
realtime:
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: '3600'
nginx.ingress.kubernetes.io/proxy-send-timeout: '3600'helm upgrade --install marmot oci://ghcr.io/thinkhumandotdev/charts/marmot \
-n marmot -f values.yamlEvery key of the existing Secret becomes an environment variable of the web, worker, realtime and
migration pods, so further secrets (OIDC_CLIENT_SECRET, S3_SECRET_ACCESS_KEY, GITHUB_CLIENT_SECRET,
STRIPE_SECRET_KEY, …) simply go into the same Secret. Without existingSecret the chart creates the
Secret from secrets.payloadSecret, secrets.databaseUrl, secrets.redisUrl and secrets.extra, and
generates a PAYLOAD_SECRET that is kept across upgrades.
The full list with comments is values.yaml. The main ones:
| Key | Default | What it does |
|---|---|---|
serverUrl |
http://localhost:3000 |
NEXT_PUBLIC_SERVER_URL: the exact URL users open. |
mode |
split |
split: web, worker and realtime Deployments. all: one MARMOT_ROLE=all pod. probes: only probe agents. |
image.tag / image.digest
|
chart appVersion
|
Image to run; the chart version and the image version are the same. |
database.adapter |
postgres |
postgres or mongodb (external only). |
config |
LOG_LEVEL, DOCKER_SOCKET_ENABLED=false
|
Non-secret variables from Configuration, as strings. |
secrets.existingSecret |
empty | Secret with PAYLOAD_SECRET, DATABASE_URL, REDIS_URL and any other secret variables. |
secrets.databaseUrl / redisUrl
|
empty | Connection strings for the chart-managed Secret. |
extraEnv / extraEnvFrom
|
[] |
Extra env / envFrom entries for every server pod. |
web/worker/realtime.replicas |
1 |
Replicas per role (see Scaling). |
*.autoscaling.enabled |
false |
A CPU-based HorizontalPodAutoscaler per role. |
*.pdb.enabled |
false |
A PodDisruptionBudget per role. |
*.resources, *.nodeSelector, … |
see values | Per-role scheduling; the health probes are overridable per role too. |
migrations.enabled |
true |
Run migrations in a Job (see Migrations). |
persistence.* |
2 Gi RWO PVC | Uploads volume for web when S3 is not configured. |
ingress.* |
disabled | Host, extra hosts, TLS, class and annotations; ingress.realtime for /socket.io. |
postgresql.enabled / redis.enabled
|
false |
Bundled single-pod Postgres 16 / Redis 7 (official images). |
probes.agents |
[] |
Probe agent Deployments, one per location. |
podSecurityContext, securityContext
|
non-root uid 1001, no capabilities | Pod and container security settings. |
| Role | Startup | Readiness | Liveness |
|---|---|---|---|
web |
GET /api/health, up to 10 minutes |
GET /api/health |
GET /api/health, 6 failures × 20 s |
realtime |
– | GET /healthz |
GET /healthz |
worker |
– | – | – (a failing worker exits and is restarted) |
all |
GET /api/health, up to 10 minutes |
GET /api/health |
entrypoint.sh healthcheck (web + realtime) |
/api/health also checks the database, so a database outage takes web out of the Service. The liveness
threshold (two minutes) keeps short outages from restarting every web pod; raise it if your database
fails over more slowly. The long startup window covers the first install, when web waits for the
migration Job.
Migrations run in a Job (entrypoint.sh migrate), never in the web pods (SKIP_MIGRATIONS=true):
-
helm install: a regular Job named<release>-migrate-1. It retries (migrations.backoffLimit) until the database accepts connections, which covers a bundled Postgres that starts in the same release.webstays unready until the schema exists; the worker waits for it as well (WORKER_SCHEMA_WAIT_MS, then a restart). -
helm upgrade: apre-upgradehook. Helm runs it with the new image before it updates any Deployment, and aborts the upgrade if it fails, so new pods only ever start against a migrated schema and the old pods keep serving while the Job runs, as they do whilewebmigrates in the compose stack.
The hook runs before Helm updates the chart's own resources, so it reads the Secret of the previous
release. Changing DATABASE_URL and the image in the same upgrade migrates the old database; change the
connection first, then upgrade the version. MongoDB has no migrations: the Job finishes immediately and
indexes are created when the processes start.
With migrations.enabled=false the web pod migrates on start instead, as in the compose stack; the chart
then refuses more than one web replica. Argo CD and Flux render the chart with helm template, where every
sync looks like an install: add migrations.annotations such as argocd.argoproj.io/hook: PreSync and
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation so the Job runs before each sync.
Rollbacks (helm rollback) do not run the Job; a schema migrated by a newer version stays migrated. Take a
database backup before upgrading across minor versions and read the release notes.
The browser opens socket.io on the page's own origin at /socket.io, with the payload-token cookie, so
/socket.io must be served on the same host as the UI and reach the realtime Service; everything else
goes to web. The chart renders two Ingress objects for the same host: <release>-web (/) and
<release>-realtime (/socket.io, longest prefix wins), so the realtime path can carry its own
annotations. Controllers that create one load balancer per Ingress (GKE's built-in controller) need both
paths in one object: set ingress.realtime.separate: false.
-
WebSockets: ingress-nginx and Traefik pass upgrades through without configuration. Raise idle timeouts for the realtime path (nginx:
proxy-read-timeout/proxy-send-timeoutof3600), or idle dashboards reconnect every minute. -
Cookies and origins:
serverUrlmust match the browser URL exactly (scheme, host and port), or the CSRF/CORS allow-list rejects the auth cookie and every request after login fails with 403. Additional origins that call the API with the cookie go intoconfig.ADDITIONAL_ORIGINS. -
Client addresses: enable Trust proxy headers in the instance settings once Marmot is only reachable through the ingress (Security).
-
Sticky sessions: more than one realtime replica needs them, because socket.io's polling handshake has to reach the same pod. With ingress-nginx:
ingress: realtime: annotations: nginx.ingress.kubernetes.io/affinity: cookie nginx.ingress.kubernetes.io/session-cookie-name: marmot-rt
Without a sticky ingress keep
realtime.replicas: 1.
For Marmot's own host, cert-manager is the usual choice: cert-manager.io/cluster-issuer in
ingress.annotations and a tls entry with the host.
Custom domains of status pages are plain extra hosts that point at the
same backends; web picks the page from the Host / X-Forwarded-Host header. Two ways to get
certificates for them:
-
Known domains: list them in
ingress.extraHostsand in atlsentry (one certificate with several names, or one entry per domain). cert-manager issues them with HTTP-01 once their DNS points at the ingress. A wildcard (*.status.example.com, DNS-01) covers status pages on subdomains of one zone. -
Domains added at runtime: an ingress cannot obtain certificates for hostnames it does not know about.
Put Caddy with on-demand TLS in front (a Deployment with the Caddyfile from
Status pages,
ask http://<release>-web:3000/api/status-pages/resolve-domain, upstreams<release>-web:3000and<release>-realtime:3001, exposed with aLoadBalancerService) and leaveingress.enabledoff. Caddy then issues a certificate on the first request for any hostname of a published page and refuses all others.
The figures in Deployment and Sizing apply unchanged:
-
Workers: one replica per ~25 checks/s with
WORKER_CONCURRENCYat 10–20 (config.WORKER_CONCURRENCY).worker.autoscalingscales on CPU, which tracks the check rate well. -
Web: stateless apart from uploads. Several replicas need S3 (
config.S3_BUCKETplus credentials in the Secret) or a ReadWriteManypersistence.storageClass. With the default ReadWriteOnce volume,webupdates with theRecreatestrategy (a short gap during upgrades); setweb.strategyto override. - Realtime: see sticky sessions above.
-
Redis must run with
maxmemory-policy noevictionand persistence; the bundled one does both. Check your managed Redis' eviction policy.
mode: all runs one MARMOT_ROLE=all pod (web, worker and realtime in one container) behind the same
Services and Ingress. It is always a single replica; use it to keep a small install small.
Each entry in probes.agents becomes a Deployment with MARMOT_ROLE=probe. Agents only receive
MARMOT_URL (default serverUrl) and their location token, never the server secrets:
probes:
agents:
- name: dc-west
existingSecret: marmot-probe-dc-west # key MARMOT_PROBE_TOKEN (tokenKey to change)
env:
- name: WORKER_CONCURRENCY
value: '5'A probe agent usually runs in another cluster than Marmot (that is its point). There, install the chart
with mode: probes: it deploys only the agents, without web, worker, realtime, migrations, databases or
ingress.
kubectl -n marmot-probe create secret generic marmot-probe-dc-west --from-literal=MARMOT_PROBE_TOKEN=mp_...
helm install marmot-probe oci://ghcr.io/thinkhumandotdev/charts/marmot -n marmot-probe \
--set mode=probes --set serverUrl=https://status.example.com \
--set 'probes.agents[0].name=dc-west' --set 'probes.agents[0].existingSecret=marmot-probe-dc-west'Use the same chart version as the server, so agent and server speak the same wire format.
Set database.adapter: mongodb and point DATABASE_URL at a replica set (Atlas or an operator); the chart
has no bundled MongoDB.
-
Ping monitors run
pingas the unprivilegedmarmotuser, which needs unprivileged ICMP sockets. If ping checks fail with a permission error, allow them for the pod's group with the safe sysctl:podSecurityContext: sysctls: - name: net.ipv4.ping_group_range value: '0 2147483647'
-
Docker monitors with a socket host are off (
DOCKER_SOCKET_ENABLED=false); mount a socket withextraVolumes/extraVolumeMountsand turn it back on only if you mean it. -
NetworkPolicies: workers need egress to everything they monitor; web, worker and realtime need the database and Redis.
Back up the database as for any other deployment (Deployment). With the bundled Postgres:
kubectl -n marmot exec marmot-postgresql-0 -- pg_dump -U marmot -Fc marmot > marmot-$(date +%F).dumphelm uninstall keeps the uploads PVC, the bundled databases' PVCs (StatefulSet volumes) and the bundled
Postgres password Secret, so a reinstall with the same release name finds its data again. Delete them by
hand to start over.
-
webnever gets ready on install:kubectl logs job/<release>-migrate-1. UsuallyDATABASE_URLis wrong or the database refuses the connection. -
helm upgradefails withpre-upgrade hooks failed: the migration failed and nothing was rolled out;kubectl logs job/<release>-migrate-<revision>(failed hook Jobs are kept). - Live updates missing:
curl https://status.example.com/socket.io/?EIO=4\&transport=pollingmust return asid. If it returns the Marmot 404 page, the/socket.iopath goes toweb. - 403 after login:
serverUrldiffers from the URL in the browser.
This wiki is published from docs/ on every push to main; edits made in the wiki are overwritten.
Found a mistake? Open a pull request with the docs(docs): … prefix.
Run it
- Getting started
- Configuration
- Deployment
- Kubernetes
- Single sign-on
- Security
- Telemetry
- Billing
- Demo mode
Use it
- Monitors
- Monitor types
- Probe locations
- Notifications
- Status pages
- Maintenance
- Organizations and members
- Integrations
- MCP server for AI agents
- Import and export
- CLI and monitors as code
- GitHub Action
Change it