Umami on Kubernetes without Docker Compose, with Kapitan
Umami 3.4.1, web analytics without cookies, rendered by the kadet-hub kubernetes generator 1.14.0 from its Docker Compose file. The database is the included recipe postgresql.
- Latest version:
1.0.1, published - Source: Docker Compose file
umami-software/umami@559e3c607c23d2c72d9bd4f6481bda428255323c(docker-compose.yml),sha256:fb542a641c14fc2b1ff0d59558a197ed4f607b6608bf812afd20dad662ae0e8c - Proof level:
deploys, deployed on kind and smoke tested by the kadet-hub/recipes CI renderspassed in the release run of with check 0.18.0, kind v0.33.0- Output capabilities: none
- Inventory backends:
omegaconf - Prerequisites, installed once per cluster before the recipe:
cnpg - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080 - Image security: grade D 60 findings, one per advisory and package, in the 1 image of version 1.0.1: CRITICAL 0, HIGH 8, MEDIUM 7, LOW 45, UNKNOWN 0. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:ghcr.io/umami-software/umami.
What it deploys
recipes.umami(init.yml)recipes.umami.umami(umami.yml):umami
Required parameters
applications.umami.app_secret is APP_SECRET, which signs Umami’s login
sessions and the tokens it returns to the tracker. A new value logs every
user out. applications.umami.two_factor_encryption_key is
TWO_FACTOR_ENCRYPTION_KEY, 64 hexadecimal digits such as the output of
openssl rand -hex 32. It encrypts the secrets of two-factor
authentication, so keep it once a user has enabled that. Umami offers no
two-factor authentication with any other value. Set both as refs with the
backend of your choice. Without one of them the compile stops with
Interpolation key '<key>' not found.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.umami
parameters:
namespace: umami
kapitan:
vars:
target: umami
applications:
umami:
app_secret: <ref>
two_factor_encryption_key: <ref>
Fetching it
The quickstart goes through these steps on a kind cluster with the uptime-kuma recipe, from installing Kapitan to the running app.
A separate fetch inventory pins the recipe by version and digest. This is fetch/targets/recipes.yml for version 1.0.1:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/umami:1.0.1@sha256:f25b1a6ed12457c6083ae69ec4d8985600ad741286b47bf5863313a12e19dbfc
output_path: inventory/classes/recipes/umami
Version 1.0.1 includes postgresql 1.1.1. The consumer workflow adds the included recipes at the digests the index lists. Kapitan's --fetch reads no index, so for a local compile pull them by digest first:
tmp=$(mktemp -d)
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9
mkdir -p inventory/classes/recipes/postgresql
tar -xf "$tmp/postgresql.tar" -C inventory/classes/recipes/postgresql --no-same-owner
The first command fetches the recipe into inventory/classes/recipes/umami, the second the generators and compiles:
kapitan compile --inventory-path fetch --fetch --output-path .
kapitan compile --fetch
The first command exits with status 1 because the fetch target compiles nothing. Kapitan's --fetch checks neither the digest nor the attestation. This verifies the attestation of the recipe and of the recipes it includes:
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/umami@sha256:f25b1a6ed12457c6083ae69ec4d8985600ad741286b47bf5863313a12e19dbfc \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
The attested index must also list each digest under its recipe with the pinned version, and not as yanked. Verifying by hand shows how to read it.
Settings
| Setting | Inventory |
|---|---|
| Umami version | components.umami.image, tag and digest |
Umami’s other environment variables, such as COLLECT_API_ENDPOINT or SALT_ROTATION |
components.umami.env.<variable> |
| exposure | a route or Ingress of the target to the Service umami on port 3000 |
| resources | components.umami.resources |
| the Cluster, its database and its size | applications.postgresql, which postgresql’s README lists |
The Service umami is a ClusterIP Service on port 3000. The compose file
publishes that port on the host. Here an Ingress or Gateway in the target
exposes it. Visitors’ browsers load the tracker /script.js and send to
/api/send on the same address as the web UI.
Mapping
The service umami becomes the component umami. db becomes the
included recipe postgresql, and Umami reaches it through the Service
postgresql-cluster-rw in place of db.
| Compose | Recipe |
|---|---|
service umami |
Deployment umami, Service umami on port 3000, Secret umami with APP_SECRET and TWO_FACTOR_ENCRYPTION_KEY |
ghcr.io/umami-software/umami:latest |
ghcr.io/umami-software/umami:3.4.1@sha256:e69d1a7932e9dab0fc523edc7be1f3a53f3c261f57856fc2167ae74a8fc022e8, which latest was at migration |
service db, postgres:15-alpine with the database, user and password umami and the volume umami-db-data |
the included recipe postgresql, PostgreSQL 18.6 on CloudNativePG, with the database and user app |
DATABASE_URL: postgresql://umami:umami@db:5432/umami |
the key uri of the Secret postgresql-cluster-app, with the generated password and without TLS, as in the compose file (DEC-125) |
APP_SECRET and TWO_FACTOR_ENCRYPTION_KEY, placeholders |
applications.umami.app_secret and .two_factor_encryption_key in the Secret umami |
port 3000:3000 |
Service port 3000, ClusterIP |
healthcheck curl http://localhost:3000/api/heartbeat every 5s, timeout 5s, 5 retries |
readiness probe with the same values |
depends_on db with condition: service_healthy |
the init container wait-for-database of Umami’s image, which waits until the Service postgresql-cluster-rw accepts connections, because Umami’s start script exits while the database does not answer (DEC-121) |
init: true |
not rendered. The server runs as PID 1 and ends on SIGTERM (DEC-120). |
restart: always |
not rendered |
| not in the compose file | UID 1001 and GID 65533 under restricted (DEC-122) |
Proof
check umami --level deploys with tests/target.yml, on kind for each
Kubernetes minor CI tests, with check 0.15.0 and cnpg installed first.
The objects of postgresql are applied with the recipe. A compose file has
no Kubernetes objects to compare with, so the recipe is proven by
deploying it (RCP-38). tests/target.yml sets low-entropy test values for
both parameters and runs one PostgreSQL instance.
tests/smoke requires that the pods pass Pod Security restricted and
that Umami did not restart while the Cluster started. It then starts a pod
of Umami’s image, which carries curl. From there, through the Service
umami on port 3000, it
- gets
{"ok":true}from/api/heartbeat - gets 401 for a wrong password and logs in as
adminwithumami - requires
isConfiguredfrom/api/2fa/status, so the key reached Umami - creates a website and sends a page view to
/api/sendas the tracker script does, with a browser’sUser-Agent, since Umami drops requests from bots such as curl - requires one page view from
/api/websites/<id>/stats, which Umami reads from PostgreSQL - changes the password as above and logs in with the new one
- deletes the pod and, once its successor is ready, reads the page view
again with the session of that login, which the new pod accepts only
with the same
APP_SECRET, and gets 401 forumami.
Not tested: the tracker script in a browser, the web UI, two-factor authentication itself, and a route or Ingress in front of the Service.
Versions
1.0.1, :sha256:f25b1a6ed12457c6083ae69ec4d8985600ad741286b47bf5863313a12e19dbfc1.0.0, :sha256:7c4ee00e3d213310515e77e5359b36cbc0d8ebef0a283f2aaede0a2a9a3edfb8
The recipe's files are in kadet-hub/recipes. All recipes