Self-hosted Plausible Analytics on Kubernetes (with db operators and ArgoCD)

I’ve been running Plausible for my website analytics for over four years. They do a great job as a managed service and I really like their team…but I have a Kubernetes cluster on Hetzner Cloud running all my other infrastructure. So the natural question came up: Would it work and be reliable at all? So far I can say yes to both of these questions.

If you just want to see the k8s YAMLs, checkout https://github.com/simonfrey/hetzner_k8s/tree/main/gitops/apps/plausible

The secondary reason is control. With self-hosted Plausible, analytics data never leaves my cluster. No third-party processors, no data sharing agreements, no GDPR concerns beyond what I already handle for the website itself.

Last: The infrastructure I am running is there anyways, so self-hosting basically is free in comparison to a monthly SaaS payment (if you neglect the time it took me to spin it up, which easily could have payed for years of managed plausible…).

Architecture

Plausible CE needs three things: the application itself (an Elixir/Phoenix app I think – but as we load it as docker container anyways…who cares), PostgreSQL for user accounts and site configuration, and ClickHouse for the actual analytics event data. On my Kubernetes, this translates to:

flowchart TD
    %% External 
    Visitor([Visitor]) --> HLB[Hetzner LB]

    %% Kubernetes Cluster
    subgraph K8s [Kubernetes Cluster]
        Traefik{Traefik Ingress}
        Plausible[Plausible CE App<br/>Elixir/Phoenix]
        
        subgraph Databases [Data Storage]
            Postgres[(PostgreSQL<br/>Zalando Op.)]
            ClickHouse[(ClickHouse<br/>Altinity Op.)]
        end

        Traefik --> Plausible
        Plausible -->|Accounts & Config| Postgres
        Plausible -->|Analytics Event Data| ClickHouse
    end

    %% Routing into cluster
    HLB --> Traefik

Everything runs in a single plausible namespace. The databases are managed by Kubernetes operators rather than plain StatefulSets — operators handle password rotation, connection pooling, backup coordination, and failure recovery. Trust me: You don’t want to write liveness probes for database processes.

Database operators

PostgreSQL: Zalando Postgres Operator

The Zalando Postgres Operator (v1.15.1) manages PostgreSQL clusters as custom resources. My Plausible PostgreSQL instance is defined in about 30 lines:

apiVersion: acid.zalan.do/v1
kind: postgresql
metadata:
name: plausible-postgres
spec:
teamId: plausible
volume:
  size: 5Gi
  storageClass: hcloud-volumes
numberOfInstances: 1
users:
  plausible: []
databases:
  plausible: plausible
postgresql:
  version: "16"
resources:
  requests:
    cpu: 100m
    memory: 256Mi

The operator auto-generates credentials and stores them in a secret with a predictable name (plausible.plausible-postgres.credentials.postgresql.acid.zalan.do). Plausible references this secret to build its DATABASE_URL. No passwords in git, no manual secret creation.

The pg_hba config allows both SSL and non-SSL connections. This matters because the operator itself needs SSL to create roles and databases during initialization.

ClickHouse: Altinity Operator

ClickHouse handles all the event data, page views, and aggregation queries. The Altinity ClickHouse Operator manages it via a ClickHouseInstallation custom resource:

apiVersion: clickhouse.altinity.com/v1
kind: ClickHouseInstallation
metadata:
name: plausible-clickhouse
spec:
configuration:
  users:
    plausible/k8s_secret_password: plausible/plausible-credentials/CLICKHOUSE_PASSWORD
    plausible/networks/ip: "::/0"
  clusters:
    - name: plausible
      layout:
        shardsCount: 1
        replicasCount: 1
templates:
  volumeClaimTemplates:
    - name: data
      spec:
        storageClassName: hcloud-volumes
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 10Gi

One shard, one replica, 10GB of storage. The key line is plausible/k8s_secret_password: plausible/plausible-credentials/CLICKHOUSE_PASSWORD — this tells the operator to read the password from a Kubernetes secret rather than embedding it in the CR. The format is namespace/secret-name/key. This took me (+ my rubber ducky Claude Code) three attempts to get right.

Secrets management

I generate three passwords from the outside (via terraform) for Plausible and stores them in a Kubernetes secret:

resource "random_password" "plausible_clickhouse" {
length = 24
special = false
}

resource "random_password" "plausible_secret_key" {
length = 64
special = false
}

resource "random_password" "plausible_totp_vault" {
length = 32
special = false
}

resource "kubernetes_secret" "plausible_credentials" {
metadata {
  name     = "plausible-credentials"
  namespace = "plausible"
}
data = {
  SECRET_KEY_BASE     = base64encode(random_password.plausible_secret_key.result)
  TOTP_VAULT_KEY     = base64encode(random_password.plausible_totp_vault.result)
  CLICKHOUSE_PASSWORD = random_password.plausible_clickhouse.result
}
}

SECRET_KEY_BASE is the Phoenix framework encryption key.
TOTP_VAULT_KEY encrypts two-factor authentication secrets.
CLICKHOUSE_PASSWORD authenticates the Plausible app against ClickHouse.

All generated with special = false because special characters in database connection URLs are a recipe for URL-encoding bugs (which I ran into).

The PostgreSQL password is handled differently — the Zalando operator generates it automatically and stores it in its own secret. Plausible reads it via valueFrom.secretKeyRef and interpolates it into the connection URL using Kubernetes variable substitution:

env:
- name: PG_PASSWORD
  valueFrom:
    secretKeyRef:
      name: plausible.plausible-postgres.credentials.postgresql.acid.zalan.do
      key: password
- name: DATABASE_URL
  value: postgres://plausible:$(PG_PASSWORD)@plausible-postgres:5432/plausible

The $(PG_PASSWORD) syntax is Kubernetes-native env var substitution. Kubernetes resolves it at pod creation time from the previously defined env var. Same pattern for the ClickHouse URL.

ArgoCD integration

Plausible is split into two ArgoCD Applications to handle the dependency ordering:

  1. plausible-db (sync wave 3): Deploys the ClickHouseInstallation and PostgreSQL CRs from gitops/apps/plausible/. This waits for the operators (sync wave 1) to be ready.
  2. plausible (sync wave 4): Deploys the Plausible app via the pascaliske Helm chart (v2.0.0). This waits for the databases to be ready.

The wave ordering is critical. If Plausible starts before ClickHouse or Postgres are accepting connections, the init container crashes and the pod enters a CrashLoopBackOff. ArgoCD’s retry policy (5 retries with exponential backoff) usually recovers from timing issues, but getting the waves right avoids unnecessary churn.

Scaling

I investigated whether Plausible CE can auto-scale under high event load. The short answer: it can’t, and it’s not worth trying.

Plausible stores session state in-memory with no external session store. Running multiple replicas requires sticky sessions at the ingress level, and scaling events risk losing in-memory state. The pascaliske Helm chart supports a static controller.replicas count but has no HPA template. You could create one manually, but the session affinity requirement makes it a poor fit for dynamic scaling.

ClickHouse can be replicated via the Altinity operator, but Plausible doesn’t support HA ClickHouse natively — you’d need to convert table engines to Replicated* variants and run ZooKeeper for coordination. That’s a lot of machinery for a personal analytics setup.

For a personal website with a few hundred visitors a day, a single replica of each component is more than sufficient….and if you need more horsepower, you still can scale horizontally…OR pay plausible manged service.

Bonus: Removing footer with CSS injection

Plausible CE shows a footer on every page with branding. I wanted a cleaner look, so I used a Traefik middleware to inject CSS that hides it:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: inject-css
namespace: plausible
spec:
plugin:
  rewritebody:
    rewrites:
      - regex: "</head>"
        replacement: "<style>main + div{display:none;}</style></head>"

This uses the traefik-plugin-rewritebody plugin to rewrite the HTML response on the fly. It finds the closing </head> tag and injects a <style> block before it. The CSS selector main + div targets the div element immediately after the <main> element, which is where Plausible renders its footer. The element gets display:none and disappears.

It’s hacky — a CSS selector that could break with any Plausible update that changes the DOM structure. But it’s six lines of YAML, doesn’t require forking Plausible, and has survived a version update so far.

Bonus: HTTPS-only

A traefik HTTPS redirect middleware ensures all traffic uses TLS:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
spec:
redirectScheme:
  scheme: https
  permanent: true

Both middlewares are referenced in the Plausible IngressRoute, so every request passes through HTTPS redirect first, then CSS injection.

To never miss an article subscribe to my newsletter
No ads. One click unsubscribe.