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:
parent
ab4af36e86
commit
7fd3bb37e6
10
services/umami/.env.example
Normal file
10
services/umami/.env.example
Normal 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
1
services/umami/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
57
services/umami/README.md
Normal file
57
services/umami/README.md
Normal 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
63
services/umami/deploy.sh
Executable 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."
|
||||
54
services/umami/docker-compose.yml
Normal file
54
services/umami/docker-compose.yml
Normal 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
|
||||
Loading…
Reference in a new issue