Web Platform
Self-hosted web analytics platform
A separately deployable analytics platform that keeps the promo site static while adding self-hosted telemetry, persistent data ownership, protected administration, and narrowly scoped public collection endpoints.
I wanted to add analytics to this site, and ideally build something I could reuse for future sites too. After looking at a few options, I landed on Umami, an open-source analytics platform that fit what I was after.
One thing I was sure of from the start: I didn’t want promo.brandonjclark.org to become dynamic just to support analytics. I wanted it to stay small, static, and simple.
So I set out to build an entirely separate Analytics platform, fully automated in it’s build, test, deploy, upgrade and backup lifecycle. The build is as follows…
Platform Boundary
The analytics repository is a deployment and operations repository. It does not fork Umami, vendor the application source, or build a custom analytics image.
Runtime is deliberately boring:
official Umami image -> PostgreSQL
Umami is pinned to ghcr.io/umami-software/umami:3.2.0. PostgreSQL is pinned independently to postgres:15-alpine. The two services run in the same Docker Compose stack, with PostgreSQL kept private to the Compose network and no database port published on the host.
Production traffic reaches Umami only through the intended ingress path. The application listener is bound to the Docker host’s private interface, not broadly exposed on 0.0.0.0.
Runtime architecture
Static site telemetry enters a separately operated analytics platform
The public site stays static. Browser telemetry crosses a narrow collection surface, while administration stays behind a separate access boundary.
Split Security Model
The analytics hostname has two different jobs:
- Serve anonymous browser telemetry endpoints for public visitors.
- Protect the human administration surface.
Cloudflare Access protects the broad hostname path. Only an explicitly allowed identity can reach the Umami UI, and then Umami still requires its own application login.
Cloudflare Access -> Umami authentication -> Umami administration
Anonymous visitors do not authenticate just to load the tracker or send a pageview. Only two exact paths bypass Cloudflare Access:
/telemetry.js
/api/telemetry
That is narrower than making /api/* public. It is also easier to reason about than exposing Umami’s default tracker names. The platform renames the collection surface with:
TRACKER_SCRIPT_NAME=telemetry.js
COLLECT_API_ENDPOINT=/api/telemetry
Administration path
Human administration gets two authentication layers
Browser telemetry and human management are different traffic classes, so the architecture treats them differently.
Static Site Integration
The promo site integration is intentionally small. The static Astro app includes only the browser tracker:
<script
defer
src="https://analytics.brandonjclark.org/telemetry.js"
data-website-id="<site-id>"
data-domains="promo.brandonjclark.org"
></script>
The data-domains setting is part of the architecture, not just a tracker option. The same static build can exist at promotest.brandonjclark.org, but production analytics are limited to promo.brandonjclark.org. Test traffic should not pollute production telemetry, and the site does not need a dynamic server-side environment switch to enforce that behavior.
Delivery Lifecycle
The analytics platform has its own GitLab CI/CD lifecycle. Feature branches validate configuration and boot a real ephemeral stack. Main deploys production only after the same kind of stack behavior has already been exercised.
Delivery lifecycle
The pipeline tests the real application topology before production
Validation is more than linting Compose YAML. The feature-branch path starts PostgreSQL, allows Umami migrations, checks health, and removes the temporary stack afterward.
- Feature branch validate deployment configuration without production writes
- Ephemeral Compose project unique project name, temporary volume, generated test secrets
- PostgreSQL health database starts with disposable storage and no production path
- Umami migrations the real application initializes its own schema
- Heartbeat check verified from the running application container context
- Main deploy and verify production deployment only after branch validation succeeds
Feature-branch tests never touch /srv/analytics/postgres. They use a unique Compose project name, temporary storage, generated test-only secrets, and cleanup that removes the ephemeral stack and volumes.
Main branch deployment validates, tests, deploys, and verifies production. Production secrets live in protected GitLab CI/CD variables. DATABASE_URL is derived during deployment instead of being stored as a separate secret, and the pipeline materializes /srv/analytics/.env with restrictive permissions.
Production State
Production state is deliberately visible on the host:
/srv/analytics/
app/
postgres/
backups/
.env
app/ receives synchronized, version-controlled deployment artifacts. postgres/ owns durable PostgreSQL data. backups/ holds local logical database backups. .env is materialized at deploy time and protected by file permissions.
The deployment sync is scoped to the application area. It must not blindly delete database files, backups, or runtime secrets. Production PostgreSQL persistence does not depend on an anonymous Docker named volume because the operating model benefits from making the state location and ownership obvious.
The host bootstrap process follows the same principle. It creates missing directories, establishes expected ownership for a new PostgreSQL path, and avoids destructive “fixes” when existing state looks unexpected. Operational safety is more important than a clever script that always exits green.
Backup and Recovery
The analytics repository owns local database backups because it owns the deployed PostgreSQL instance.
Logical backups use pg_dump. Retention keeps 7 daily backups and 4 weekly backups, with the weekly copy derived from the daily backup instead of dumping the same database twice. Backups are validated before finalization.
Before deploying a changed Umami application version, the pipeline creates a fresh pre-upgrade database backup. Application rollback is controlled through Git and versioned deployment artifacts. Database rollback is intentionally not automatic; recovery is a deliberate operational action.
Off-host replication is outside this repository on purpose. That belongs to a future centralized backup capability, not to a small analytics deployment repository pretending to be a whole backup platform.
Change Management
There are no latest runtime tags.
Umami and PostgreSQL versions are managed independently. Umami changes are explicit Git changes to the pinned image tag. PostgreSQL major upgrades are treated as operational events, not accidental image drift.
Umami owns its own schema initialization and migrations. The platform boots the real application and verifies that it becomes healthy, but it does not duplicate Umami’s migration logic in an external script.
The runtime posture is equally pragmatic:
restart: unless-stoppedfor both services.- Compose health checks and post-deployment verification.
- Docker-native log rotation.
- A private Compose network.
- No published PostgreSQL port.
- No fixed
container_namevalues. - No custom Umami image.
- No Kubernetes, service mesh, external database platform, or observability stack for v1.
The absence of those components is a decision. The platform is small, independently owned, and easier to operate when the architecture only carries what the operating model can justify.
Outcome
The implemented system is an independently deployable self-hosted analytics platform with Umami 3.2.0, dedicated PostgreSQL persistence, GitLab CI/CD, real ephemeral integration testing, automated production deployment, explicit host-managed state, local backup lifecycle, pre-upgrade backups, protected secrets, Cloudflare Tunnel ingress, Nginx Proxy Manager routing, Cloudflare Access-protected administration, and exactly two anonymous telemetry endpoints.
promo.brandonjclark.org remains static.
That is the point of the architecture. The analytics capability is real, but it does not become a reason to make every site that consumes it larger. Strong boundaries made the implementation straightforward enough to design, debug, secure, integrate, and place into operation in roughly one working day.