analytics
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

Signed-off-by: Melvin Strobl <lc3267@kit.edu>
This commit is contained in:
Melvin Strobl
2026-08-18 13:17:07 +02:00
parent 449a1472cd
commit a03d17975f
3 changed files with 96 additions and 1 deletions
+27 -1
View File
@@ -4,7 +4,7 @@
.PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \ .PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \
generate-client seed-example test test-backend test-frontend soak lint lint-backend \ generate-client seed-example test test-backend test-frontend soak lint lint-backend \
lint-frontend clean help lint-frontend umami clean help
COMPOSE_ROOT := $(CURDIR) COMPOSE_ROOT := $(CURDIR)
# Explicit project name keeps this stack isolated from the sibling website # Explicit project name keeps this stack isolated from the sibling website
@@ -39,6 +39,32 @@ dev-local: ## Start the integrated local stack (called by the root `make dev`)
up: ## Start the production stack up: ## Start the production stack
$(COMPOSE_PROD_RUN) up --build -d $(COMPOSE_PROD_RUN) up --build -d
umami: ## Provision + start the optional Umami site-analytics service (idempotent)
# Guard: the dashboard is internet-facing on analytics.$(DOMAIN); refuse to
# start when its session-signing secret is unset or still the placeholder.
@secret=$$(grep -E '^UMAMI_APP_SECRET=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
if [ -z "$$secret" ] || [ "$$secret" = "changethis" ]; then \
echo "ERROR: UMAMI_APP_SECRET is empty or 'changethis' in .env — run the workspace root's scripts/setup.sh (or set it: openssl rand -hex 32) before 'make umami'." >&2; \
exit 1; \
fi
# --no-recreate: reuse an already-running db instead of recreating it under
# the prod compose set, which would blip every service that depends on it.
$(COMPOSE_PROD) up -d --wait --no-recreate db
# Least-privilege role owning only the umami database, so the third-party
# image never holds the shared superuser credentials.
@pguser=$$(grep -E '^POSTGRES_USER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); pguser=$${pguser:-postgres}; \
role=$$(grep -E '^UMAMI_DB_USER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); role=$${role:-umami}; \
pw=$$(grep -E '^UMAMI_DB_PASSWORD=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
db=$$(grep -E '^UMAMI_DB=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); db=$${db:-umami}; \
if [ -z "$$pw" ] || [ "$$pw" = "changethis" ]; then \
echo "ERROR: UMAMI_DB_PASSWORD is empty or 'changethis' in .env — run the workspace root's scripts/setup.sh before 'make umami'." >&2; \
exit 1; \
fi; \
$(COMPOSE_PROD) exec -T db psql -v ON_ERROR_STOP=1 -U "$$pguser" -d postgres \
-v role="$$role" -v pw="$$pw" -v db="$$db" < docker/provision-umami-db.sql
# --no-deps: only (re)create umami, never restart the shared db underneath it.
$(COMPOSE_PROD) --profile analytics up -d --no-deps umami
update: ## Pull, rebuild using the layer cache, and recreate changed containers update: ## Pull, rebuild using the layer cache, and recreate changed containers
git pull git pull
$(COMPOSE_PROD_RUN) build $(COMPOSE_PROD_RUN) build
+49
View File
@@ -238,6 +238,55 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
# ── Site analytics (optional) ──────────────────────────────────────
# Self-hosted, cookieless Umami on analytics.${DOMAIN}. Gated behind the
# `analytics` profile, so a plain `make up` never starts it; bring it up with
# `make umami`, which also provisions its database.
umami:
container_name: fluksio-umami
# Pinned by digest so a bump is a deliberate step: this is an internet-facing
# third-party dashboard, not a library.
image: ghcr.io/umami-software/umami:postgresql-latest@sha256:87312d334d009ee67ee0d2fba8fed01435547cc468e452243aef5133a9984d48
restart: always
security_opt:
- no-new-privileges:true
profiles: ["analytics"]
networks:
- proxy
- default
depends_on:
db:
condition: service_healthy
# Deliberately no `env_file: ../.env` -- this third-party image receives the
# three variables it needs and none of the app's secrets.
environment:
- DATABASE_TYPE=postgresql
# Least-privilege role owning only the umami database, never the shared
# Postgres superuser (provisioned by `make umami`).
- DATABASE_URL=postgresql://${UMAMI_DB_USER:-umami}:${UMAMI_DB_PASSWORD}@db:5432/${UMAMI_DB:-umami}
- APP_SECRET=${UMAMI_APP_SECRET:-}
expose:
- "3000"
# No healthcheck on purpose: the upstream image ships neither curl nor wget,
# so a probe would leave the container permanently `starting` and Traefik
# would never route it (the same trap the backend's python healthcheck
# documents).
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.constraint-label=proxy
- traefik.http.services.${STACK_NAME?Variable not set}-umami.loadbalancer.server.port=3000
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-http.rule=Host(`analytics.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-http.entrypoints=http
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-http.middlewares=https-redirect
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-https.rule=Host(`analytics.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-https.entrypoints=https
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-https.tls=true
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-https.tls.certresolver=le
volumes: volumes:
app-db-data: app-db-data:
app-redis-data: app-redis-data:
+20
View File
@@ -0,0 +1,20 @@
-- Least-privilege Postgres role + database for the optional Umami analytics
-- service. Umami must not connect as the shared superuser, which can read the
-- `app` database holding every user, flow and credential. This role owns only
-- its own database, so it has full rights there and none on `app`.
--
-- Invoked by `make umami` (piped into psql in the db container). Idempotent.
-- Identifiers and the password arrive as psql variables (-v role=… -v pw=…
-- -v db=…) and are quoted via format()'s %I / %L, so they are injection-safe.
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'role', :'pw')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'role')
\gexec
SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'role', :'pw')
WHERE EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'role')
\gexec
SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'role')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db')
\gexec