Systems

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.

  • Self-Hosted Analytics
  • Platform Engineering
  • CI/CD
  • Docker Compose
  • PostgreSQL
  • Security

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.

promo.brandonjclark.org static Astro site with client-side telemetry script
analytics.brandonjclark.org edge boundary
Cloudflare public DNS, edge policy, and hostname boundary
/telemetry.js public bypass /api/telemetry public bypass all other paths require Access no broad /api/* bypass
Cloudflare Tunnel private origin reachability without exposing the host directly
Nginx Proxy Manager routes the analytics hostname to the private Umami listener
Umami 3.2.0 official upstream container image, no source fork
PostgreSQL 15 private Compose-network persistence service
/srv/analytics/postgres host-managed production database state
The public collection surface is intentionally tiny. The rest of the analytics application is treated as an administrative system, not as a generally public web app.

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.

Administrator owner-managed identity
Cloudflare Access external identity gate for the management surface
Umami login application-level authentication remains active
Umami administration dashboards, site configuration, and analytics review
The management surface is not protected by obscurity or by the application login alone. Edge access policy and application authentication both have to pass.

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.

  1. Feature branch validate deployment configuration without production writes
  2. Ephemeral Compose project unique project name, temporary volume, generated test secrets
  3. PostgreSQL health database starts with disposable storage and no production path
  4. Umami migrations the real application initializes its own schema
  5. Heartbeat check verified from the running application container context
  6. Main deploy and verify production deployment only after branch validation succeeds
The initial CI implementation caught a real namespace mistake: publishing a random localhost port on the Docker host did not make that address reachable from inside the GitLab job container. The corrected test runs the heartbeat from the Umami container's own network context.

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-stopped for both services.
  • Compose health checks and post-deployment verification.
  • Docker-native log rotation.
  • A private Compose network.
  • No published PostgreSQL port.
  • No fixed container_name values.
  • 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.