feat(umami): add Umami analytics service + deploy

Two-container stack (umami + postgres) deployed to the hetzner host via a
remote Docker context, attached to npm_default so Nginx Proxy Manager fronts
stats.gethumanai.pl. Secrets live in a gitignored .env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
oskar 2026-06-24 16:24:01 +02:00
parent ab4af36e86
commit 7fd3bb37e6
5 changed files with 185 additions and 0 deletions

View file

@ -0,0 +1,10 @@
# Copy to .env and fill in. Never commit .env.
# Generate secrets with:
# openssl rand -hex 32 # APP_SECRET
# openssl rand -hex 24 # POSTGRES_PASSWORD
# Signing secret for sessions/tokens. Changing it invalidates existing sessions.
APP_SECRET=
# PostgreSQL password (shared by the umami app and the umami-db container).
POSTGRES_PASSWORD=

1
services/umami/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

57
services/umami/README.md Normal file
View file

@ -0,0 +1,57 @@
# Umami
Self-hosted privacy-friendly web analytics, served at **stats.gethumanai.pl**.
Runs as two containers (`umami` + `umami-db` PostgreSQL) on the **hetzner** host,
deployed via a remote Docker context — same convention as `humanai-web`.
`umami` joins the `npm_default` network so Nginx Proxy Manager can proxy it by
container name; no host port is exposed.
## Layout
| File | Purpose |
|---|---|
| `docker-compose.yml` | umami app + postgres + volume + networks |
| `.env.example` | template for the two required secrets |
| `.env` | real secrets — **gitignored, never commit** |
| `deploy.sh` | pulls images on the remote, starts the stack, smoke-tests `/api/heartbeat` |
## First-time setup
```bash
cp .env.example .env
# fill in:
# openssl rand -hex 32 -> APP_SECRET
# openssl rand -hex 24 -> POSTGRES_PASSWORD
```
## Deploy
```bash
./deploy.sh
```
The script creates the `hetzner` Docker context if missing, verifies the
`npm_default` network exists, runs `docker compose up -d --pull always`, waits
for the `umami` healthcheck, and smoke-tests the heartbeat endpoint.
## Manual step in Nginx Proxy Manager
Add a Proxy Host:
| Field | Value |
|---|---|
| Domain | `stats.gethumanai.pl` |
| Scheme | `http` |
| Forward hostname | `umami` |
| Forward port | `3000` |
| Websockets | on |
| SSL | request a Let's Encrypt cert + Force SSL |
Then open <https://stats.gethumanai.pl>, log in with the default **admin / umami**,
and **change the password immediately**.
## Add the tracking snippet
In Umami: *Settings → Websites → Add* (domain `gethumanai.pl`), then copy the
generated `<script>` tag into the `humanai-web` site's `index.html`.

63
services/umami/deploy.sh Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
# Deploy Umami to the hetzner host via a remote Docker context.
# Same convention as humanai-web: build/run remotely, attach to NPM's network.
CONTEXT="hetzner"
REMOTE="ssh://oskar@135.181.153.108"
NETWORK="npm_default"
cd "$(dirname "$0")"
if [[ ! -f .env ]]; then
echo "ERROR: .env missing. Copy .env.example to .env and fill in secrets." >&2
exit 1
fi
# Create docker context if missing
docker context inspect "$CONTEXT" &>/dev/null || \
docker context create "$CONTEXT" --docker "host=$REMOTE"
D="docker --context $CONTEXT"
# The external NPM network must exist on the remote host.
if ! $D network inspect "$NETWORK" &>/dev/null; then
echo "ERROR: docker network '$NETWORK' not found on $CONTEXT." >&2
echo " Start Nginx Proxy Manager first (it owns that network)." >&2
exit 1
fi
echo "==> Pulling images and starting Umami on $CONTEXT"
$D compose --env-file .env up -d --pull always --remove-orphans
echo "==> Waiting for umami to become healthy..."
for i in $(seq 1 30); do
STATUS=$($D inspect -f '{{.State.Health.Status}}' umami 2>/dev/null || echo "starting")
if [[ "$STATUS" == "healthy" ]]; then
echo " OK: umami is healthy"
break
fi
if [[ "$i" == "30" ]]; then
echo "FAIL: umami did not become healthy in time" >&2
$D logs --tail 50 umami >&2 || true
exit 1
fi
sleep 5
done
echo "==> Smoke test: /api/heartbeat must return 200"
CODE=$($D exec umami curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/heartbeat)
if [[ "$CODE" != "200" ]]; then
echo "FAIL: heartbeat returned $CODE" >&2
exit 1
fi
echo " OK: heartbeat 200"
echo ""
echo "Deploy complete."
echo "Next steps (manual):"
echo " 1. In NPM: add Proxy Host stats.gethumanai.pl -> http://umami:3000 (scheme http)"
echo " enable WebSockets + Force SSL (Let's Encrypt)."
echo " 2. Open https://stats.gethumanai.pl and log in (default admin / umami)."
echo " 3. CHANGE the default admin password immediately."

View file

@ -0,0 +1,54 @@
name: umami
services:
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
container_name: umami
restart: unless-stopped
environment:
DATABASE_TYPE: postgresql
DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@umami-db:5432/umami
APP_SECRET: ${APP_SECRET}
depends_on:
umami-db:
condition: service_healthy
networks:
- internal
- npm
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/heartbeat || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 40s
umami-db:
image: postgres:16-alpine
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- umami-db-data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
volumes:
umami-db-data:
networks:
# Internal-only network for umami <-> postgres traffic.
internal:
# Shared network with Nginx Proxy Manager so it can reach `umami:3000`
# by container name. No host port is published.
npm:
external: true
name: npm_default