From aa40ccb58b8c7afad399c20483cec87d7e5297dd Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Tue, 18 Aug 2026 13:17:07 +0200 Subject: [PATCH] analytics Signed-off-by: Melvin Strobl --- Makefile | 28 +++++++++++++++++++- docker/compose.yml | 49 +++++++++++++++++++++++++++++++++++ docker/provision-umami-db.sql | 20 ++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 docker/provision-umami-db.sql diff --git a/Makefile b/Makefile index 3d87d21..1b52d1c 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ .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 \ - lint-frontend clean help + lint-frontend umami clean help COMPOSE_ROOT := $(CURDIR) # 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 $(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 git pull $(COMPOSE_PROD_RUN) build diff --git a/docker/compose.yml b/docker/compose.yml index 02f4e0b..e6cb3b8 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -238,6 +238,55 @@ services: volumes: - /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: app-db-data: app-redis-data: diff --git a/docker/provision-umami-db.sql b/docker/provision-umami-db.sql new file mode 100644 index 0000000..8e446ec --- /dev/null +++ b/docker/provision-umami-db.sql @@ -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