GREEN PACKAGE SKILL · DIGITALOCEAN · UMAMI

Umami Package Skill

A reproducible Green workflow for one production-oriented Umami web analytics server: pinned Umami and PostgreSQL 17 behind Caddy, Cloudflare DNS and TLS, rotated credentials, and a nightly backup that proves it can be restored before it is uploaded.

The seeded administrator password is rotated during convergence. Umami ships with admin/umami and offers no way to set it from the environment, so a fresh server is reachable on the public internet with a published password until something changes it. This package changes it, and acceptance fails if the seeded credentials still authenticate.

Quick start

npx skills add getcolors/umami
cp .agents/skills/package-umami-green/green ./green
chmod +x green
./green build
./green create --dry-run

build renders the deployment without credentials. The dry-run walks the whole workflow while skipping every side effect. A real create or delete touches live infrastructure and requires separate authorization.

Architecture and sizing

Compute

OpenTofu discovers the configured region’s default VPC at plan time and creates one Ubuntu 24.04 Droplet plus a firewall. No VPC is created and no VPC UUID belongs in desired state; validation rejects one.

Runtime

Ansible installs Docker and runs three containers: umami v2.14.0, postgres:17-alpine, and caddy:2.11.4. PostgreSQL is reachable only on the private Compose network.

Public boundary

Cloudflare publishes one A record. Only ports 22, 80 and 443 are admitted, from the CIDRs named in desired state. Caddy terminates TLS and proxies to umami:3000; the application port is never published.

Sizing

s-4vcpu-8gb. Umami is a single Node process over PostgreSQL, so this is generous; the headroom is for Docker, image replacement and backup restore checks running beside the live service.

This is deliberately a single-node design. Process and host restarts recover; a Droplet, disk, or regional failure causes downtime, and there is no automatic failover.

Desired state and credentials

colors.yml is the only file you edit, and it holds non-secret values only. It selects the profile, the DigitalOcean region, size and SSH sources, the Cloudflare zone and record, the Umami and PostgreSQL versions, and the backup destination, schedule and retention. See the configuration reference for every key.

Secrets are supplied only as COLORS_PAR_* environment variables, from an ignored .envrc.private. A real create or delete fails before contacting any provider when one is missing:

VariablePurpose
COLORS_PAR_DO_TOKENDigitalOcean compute and firewall
COLORS_PAR_CLOUDFLARE_API_TOKENDNS record for the configured host
COLORS_PAR_POSTGRES_PASSWORDPostgreSQL password, interpolated at converge time
COLORS_PAR_APP_SECRET_KEYUmami APP_SECRET; signs sessions. COLORS_PAR_UMAMI_APP_SECRET is accepted as an alternate name
COLORS_PAR_UMAMI_ADMIN_PASSWORDReplaces the seeded administrator password during convergence
COLORS_PAR_BACKUP_R2_ACCESS_KEY_ID
COLORS_PAR_BACKUP_R2_SECRET_ACCESS_KEY
R2 credentials for the backup bucket

Acceptance and operations

./green create
ssh <droplet> 'cd /opt/umami && docker compose ps'
ssh <droplet> 'systemctl status umami-backup.timer'
ssh <droplet> 'journalctl -u umami-backup.service --since=-24h'

Acceptance runs at the end of create and reports only what it checked:

Backups, restoration, and disaster recovery

A systemd timer runs nightly at 03:00 UTC with a randomised delay. Each run takes a compressed pg_dump, restores it into a scratch database and requires the schema back, and only then uploads to the configured R2 bucket under the profile prefix. A truncated or malformed dump fails the unit instead of landing in the bucket looking like a backup.

Retention applies to both sides: rclone delete --min-age prunes the bucket to backup-retention-days, and a local find prunes the disk to the same horizon. Pruning only the disk is not a retention policy.

To recover, restore the newest object into an empty PostgreSQL 17 database, point a replacement deployment at it, and verify the web UI and event ingestion before moving DNS. Never test restoration over the live database.

Prevent-destroy is not a backup. Deleting compute does not authorize deleting R2 objects, the discovered default VPC, unrelated DNS, or any other provider resource. Rotating COLORS_PAR_POSTGRES_PASSWORD on an existing deployment also requires an ALTER USER inside the running container: the value is applied at database initialisation only.

Development and source

bb test
bb golden
bb golden:accept
./scripts/launcher.sh

Golden fixtures render the whole deployment from a fixture colors.yml and compare byte for byte; the accept step refuses to copy a rendered tree containing a credential-shaped value. Source on GitHub. Read every golden diff — never accept generated output merely to make the suite pass.