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.
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:
| Variable | Purpose |
|---|---|
COLORS_PAR_DO_TOKEN | DigitalOcean compute and firewall |
COLORS_PAR_CLOUDFLARE_API_TOKEN | DNS record for the configured host |
COLORS_PAR_POSTGRES_PASSWORD | PostgreSQL password, interpolated at converge time |
COLORS_PAR_APP_SECRET_KEY | Umami APP_SECRET; signs sessions. COLORS_PAR_UMAMI_APP_SECRET is accepted as an alternate name |
COLORS_PAR_UMAMI_ADMIN_PASSWORD | Replaces the seeded administrator password during convergence |
COLORS_PAR_BACKUP_R2_ACCESS_KEY_IDCOLORS_PAR_BACKUP_R2_SECRET_ACCESS_KEY | R2 credentials for the backup bucket |
- Never export
COLORS_PAR_PROFILE. The profile keys remote state, and overlaying it points one deployment at another’s. - Never edit or commit generated
.colors/output. - Keep
compute-prevent-destroy: truein committed desired state. - The compose file is rendered on the host with
ansible.builtin.templateat mode0600, so credentials never appear in generated or tracked files.
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:
- HTTPS with certificate verification.
/api/heartbeatmust answer over a valid certificate; the check never disables verification. - The seeded credentials must fail. If
admin/umamistill authenticates, acceptance fails. - Ingestion is read back from PostgreSQL. A synthetic event is posted to
/api/sendand the row count inwebsite_eventmust rise. A 2xx with no stored row is reported as dropped, not success. With no website configured yet the verdict is not-configured, which passes and claims nothing. - The backup drill is confirmed in R2. The unit is started and the profile prefix must then contain a non-empty object newer than the run.
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.
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.