{"id":1326,"date":"2026-03-19T10:12:25","date_gmt":"2026-03-19T09:12:25","guid":{"rendered":"https:\/\/simon-frey.com\/blog\/?p=1326"},"modified":"2026-03-23T11:31:22","modified_gmt":"2026-03-23T10:31:22","slug":"self-hosted-plausible-analytics-on-kubernetes","status":"publish","type":"post","link":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/","title":{"rendered":"Self-hosted Plausible Analytics on Kubernetes (with db operators and ArgoCD)"},"content":{"rendered":"\n<p>I&#8217;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&#8230;but I have a <a href=\"https:\/\/simon-frey.com\/blog\/kubernetes-on-hetzner-cloud\/\">Kubernetes cluster on Hetzner Cloud<\/a> running all my other infrastructure. So the natural question came up: Would it work and be reliable at all? So far I can say <em>yes<\/em> to both of these questions.<\/p>\n\n\n\n<p>If you just want to see the k8s YAMLs, checkout <a href=\"https:\/\/github.com\/simonfrey\/hetzner_k8s\/tree\/main\/gitops\/apps\/plausible \">https:\/\/github.com\/simonfrey\/hetzner_k8s\/tree\/main\/gitops\/apps\/plausible <\/a><\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>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&#8230;).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Architecture<\/h2>\n\n\n\n<p>Plausible CE needs three things: the application itself (an Elixir\/Phoenix app I think &#8211; but as we load it as docker container anyways&#8230;who cares), PostgreSQL for user accounts and site configuration, and ClickHouse for the actual analytics event data. On my Kubernetes, this translates to:<\/p>\n\n\n\n<div class=\"wp-block-merpress-mermaidjs diagram-source-mermaid\"><pre class=\"mermaid\">flowchart TD\n    %% External \n    Visitor([Visitor]) --> HLB[Hetzner LB]\n\n    %% Kubernetes Cluster\n    subgraph K8s [Kubernetes Cluster]\n        Traefik{Traefik Ingress}\n        Plausible[Plausible CE App&lt;br\/>Elixir\/Phoenix]\n        \n        subgraph Databases [Data Storage]\n            Postgres[(PostgreSQL&lt;br\/>Zalando Op.)]\n            ClickHouse[(ClickHouse&lt;br\/>Altinity Op.)]\n        end\n\n        Traefik --> Plausible\n        Plausible -->|Accounts &amp; Config| Postgres\n        Plausible -->|Analytics Event Data| ClickHouse\n    end\n\n    %% Routing into cluster\n    HLB --> Traefik<\/pre><\/div>\n\n\n\n<p>Everything runs in a single <code>plausible<\/code> namespace. The databases are managed by Kubernetes operators rather than plain StatefulSets \u2014 operators handle password rotation, connection pooling, backup coordination, and failure recovery. Trust me: You don&#8217;t want to write liveness probes for database processes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Database operators<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">PostgreSQL: Zalando Postgres Operator<\/h3>\n\n\n\n<p>The <a href=\"https:\/\/github.com\/zalando\/postgres-operator\">Zalando Postgres Operator<\/a> (v1.15.1) manages PostgreSQL clusters as custom resources. My Plausible PostgreSQL instance is defined in about 30 lines:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>apiVersion: acid.zalan.do\/v1<br>kind: postgresql<br>metadata:<br>  name: plausible-postgres<br>spec:<br>  teamId: plausible<br>  volume:<br> &nbsp;  size: 5Gi<br> &nbsp;  storageClass: hcloud-volumes<br>  numberOfInstances: 1<br>  users:<br> &nbsp;  plausible: &#91;]<br>  databases:<br> &nbsp;  plausible: plausible<br>  postgresql:<br> &nbsp;  version: \"16\"<br>  resources:<br> &nbsp;  requests:<br> &nbsp; &nbsp;  cpu: 100m<br> &nbsp; &nbsp;  memory: 256Mi<\/code><\/pre>\n\n\n\n<p>The operator auto-generates credentials and stores them in a secret with a predictable name (<code>plausible.plausible-postgres.credentials.postgresql.acid.zalan.do<\/code>). Plausible references this secret to build its <code>DATABASE_URL<\/code>. No passwords in git, no manual secret creation.<\/p>\n\n\n\n<p>The <code>pg_hba<\/code> config allows both SSL and non-SSL connections. This matters because the operator itself needs SSL to create roles and databases during initialization.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">ClickHouse: Altinity Operator<\/h3>\n\n\n\n<p>ClickHouse handles all the event data, page views, and aggregation queries. The <a href=\"https:\/\/github.com\/Altinity\/clickhouse-operator\">Altinity ClickHouse Operator<\/a> manages it via a <code>ClickHouseInstallation<\/code> custom resource:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>apiVersion: clickhouse.altinity.com\/v1<br>kind: ClickHouseInstallation<br>metadata:<br>  name: plausible-clickhouse<br>spec:<br>  configuration:<br> &nbsp;  users:<br> &nbsp; &nbsp;  plausible\/k8s_secret_password: plausible\/plausible-credentials\/CLICKHOUSE_PASSWORD<br> &nbsp; &nbsp;  plausible\/networks\/ip: \"::\/0\"<br> &nbsp;  clusters:<br> &nbsp; &nbsp;  - name: plausible<br> &nbsp; &nbsp; &nbsp;  layout:<br> &nbsp; &nbsp; &nbsp; &nbsp;  shardsCount: 1<br> &nbsp; &nbsp; &nbsp; &nbsp;  replicasCount: 1<br>  templates:<br> &nbsp;  volumeClaimTemplates:<br> &nbsp; &nbsp;  - name: data<br> &nbsp; &nbsp; &nbsp;  spec:<br> &nbsp; &nbsp; &nbsp; &nbsp;  storageClassName: hcloud-volumes<br> &nbsp; &nbsp; &nbsp; &nbsp;  accessModes: &#91;ReadWriteOnce]<br> &nbsp; &nbsp; &nbsp; &nbsp;  resources:<br> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;  requests:<br> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;  storage: 10Gi<\/code><\/pre>\n\n\n\n<p>One shard, one replica, 10GB of storage. The key line is <code>plausible\/k8s_secret_password: plausible\/plausible-credentials\/CLICKHOUSE_PASSWORD<\/code> \u2014 this tells the operator to read the password from a Kubernetes secret rather than embedding it in the CR. The format is <code>namespace\/secret-name\/key<\/code>. This took me (+ my rubber ducky Claude Code) three attempts to get right.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Secrets management<\/h2>\n\n\n\n<p>I generate three passwords from the outside (via terraform) for Plausible and stores them in a Kubernetes secret:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"random_password\" \"plausible_clickhouse\" {<br>  length  = 24<br>  special = false<br>}<br>\u200b<br>resource \"random_password\" \"plausible_secret_key\" {<br>  length  = 64<br>  special = false<br>}<br>\u200b<br>resource \"random_password\" \"plausible_totp_vault\" {<br>  length  = 32<br>  special = false<br>}<br>\u200b<br>resource \"kubernetes_secret\" \"plausible_credentials\" {<br>  metadata {<br> &nbsp;  name &nbsp; &nbsp;  = \"plausible-credentials\"<br> &nbsp;  namespace = \"plausible\"<br>  }<br>  data = {<br> &nbsp;  SECRET_KEY_BASE &nbsp; &nbsp; = base64encode(random_password.plausible_secret_key.result)<br> &nbsp;  TOTP_VAULT_KEY &nbsp; &nbsp;  = base64encode(random_password.plausible_totp_vault.result)<br> &nbsp;  CLICKHOUSE_PASSWORD = random_password.plausible_clickhouse.result<br>  }<br>}<\/code><\/pre>\n\n\n\n<p><code>SECRET_KEY_BASE<\/code> is the Phoenix framework encryption key. <br><code>TOTP_VAULT_KEY<\/code> encrypts two-factor authentication secrets.<br><code>CLICKHOUSE_PASSWORD<\/code> authenticates the Plausible app against ClickHouse. <\/p>\n\n\n\n<p>All generated with <code>special = false<\/code> because special characters in database connection URLs are a recipe for URL-encoding bugs (which I ran into).<\/p>\n\n\n\n<p>The PostgreSQL password is handled differently \u2014 the Zalando operator generates it automatically and stores it in its own secret. Plausible reads it via <code>valueFrom.secretKeyRef<\/code> and interpolates it into the connection URL using Kubernetes variable substitution:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>env:<br>  - name: PG_PASSWORD<br> &nbsp;  valueFrom:<br> &nbsp; &nbsp;  secretKeyRef:<br> &nbsp; &nbsp; &nbsp;  name: plausible.plausible-postgres.credentials.postgresql.acid.zalan.do<br> &nbsp; &nbsp; &nbsp;  key: password<br>  - name: DATABASE_URL<br> &nbsp;  value: postgres:\/\/plausible:$(PG_PASSWORD)@plausible-postgres:5432\/plausible<\/code><\/pre>\n\n\n\n<p>The <code>$(PG_PASSWORD)<\/code> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">ArgoCD integration<\/h2>\n\n\n\n<p>Plausible is split into two ArgoCD Applications to handle the dependency ordering:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>plausible-db<\/strong> (sync wave 3): Deploys the ClickHouseInstallation and PostgreSQL CRs from <code>gitops\/apps\/plausible\/<\/code>. This waits for the operators (sync wave 1) to be ready.<\/li>\n\n\n\n<li><strong>plausible<\/strong> (sync wave 4): Deploys the Plausible app via the <a href=\"https:\/\/github.com\/pascaliske\/helm-charts\/tree\/main\/charts\/plausible\">pascaliske Helm chart<\/a> (v2.0.0). This waits for the databases to be ready.<\/li>\n<\/ol>\n\n\n\n<p>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&#8217;s retry policy (5 retries with exponential backoff) usually recovers from timing issues, but getting the waves right avoids unnecessary churn.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Scaling<\/h2>\n\n\n\n<p>I investigated whether Plausible CE can auto-scale under high event load. The short answer: it can&#8217;t, and it&#8217;s not worth trying.<\/p>\n\n\n\n<p>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 <a href=\"https:\/\/github.com\/pascaliske\/helm-charts\/tree\/main\/charts\/plausible\">pascaliske Helm chart<\/a> supports a static <code>controller.replicas<\/code> count but has no HPA template. You could create one manually, but the session affinity requirement makes it a poor fit for dynamic scaling.<\/p>\n\n\n\n<p>ClickHouse can be replicated via the Altinity operator, but Plausible doesn&#8217;t support HA ClickHouse natively \u2014 you&#8217;d need to convert table engines to <code>Replicated*<\/code> variants and run ZooKeeper for coordination. That&#8217;s a lot of machinery for a personal analytics setup.<\/p>\n\n\n\n<p>For a personal website with a few hundred visitors a day, a single replica of each component is more than sufficient&#8230;.and if you need more horsepower, you still can scale horizontally&#8230;OR pay plausible manged service. <\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Bonus: Removing footer with CSS injection<\/h2>\n\n\n\n<p>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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>apiVersion: traefik.io\/v1alpha1<br>kind: Middleware<br>metadata:<br>  name: inject-css<br>  namespace: plausible<br>spec:<br>  plugin:<br> &nbsp;  rewritebody:<br> &nbsp; &nbsp;  rewrites:<br> &nbsp; &nbsp; &nbsp;  - regex: \"&lt;\/head&gt;\"<br> &nbsp; &nbsp; &nbsp; &nbsp;  replacement: \"&lt;style&gt;main + div{display:none;}&lt;\/style&gt;&lt;\/head&gt;\"<\/code><\/pre>\n\n\n\n<p>This uses the <a href=\"https:\/\/github.com\/traefik\/plugin-rewritebody\">traefik-plugin-rewritebody<\/a> plugin to rewrite the HTML response on the fly. It finds the closing <code>&lt;\/head&gt;<\/code> tag and injects a <code>&lt;style&gt;<\/code> block before it. The CSS selector <code>main + div<\/code> targets the div element immediately after the <code>&lt;main&gt;<\/code> element, which is where Plausible renders its footer. The element gets <code>display:none<\/code> and disappears.<\/p>\n\n\n\n<p>It&#8217;s hacky \u2014 a CSS selector that could break with any Plausible update that changes the DOM structure. But it&#8217;s six lines of YAML, doesn&#8217;t require forking Plausible, and has survived a version update so far.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Bonus: HTTPS-only<\/h2>\n\n\n\n<p>A traefik HTTPS redirect middleware ensures all traffic uses TLS:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>apiVersion: traefik.io\/v1alpha1<br>kind: Middleware<br>metadata:<br>  name: redirect-https<br>spec:<br>  redirectScheme:<br> &nbsp;  scheme: https<br> &nbsp;  permanent: true<\/code><\/pre>\n\n\n\n<p>Both middlewares are referenced in the Plausible IngressRoute, so every request passes through HTTPS redirect first, then CSS injection.<\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;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&#8230;but I have a Kubernetes cluster on Hetzner Cloud&hellip;<\/p>\n<p><a href=\"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/\" class=\"more-link\">Read more<span class=\"screen-reader-text\"> of Self-hosted Plausible Analytics on Kubernetes (with db operators and ArgoCD)<\/span><span aria-hidden=\"true\"> &rarr;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","_links_to":"","_links_to_target":""},"categories":[232,375,374],"tags":[],"class_list":["post-1326","post","type-post","status-publish","format-standard","hentry","category-devops","category-homelab","category-kubernetes-k8s"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v27.5 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Self-hosted Plausible Analytics on Kubernetes (with db operators and ArgoCD) - Blog by Simon Frey<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Simon Frey\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"4 minutes\" \/>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Self-hosted Plausible Analytics on Kubernetes (with db operators and ArgoCD) - Blog by Simon Frey","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/","twitter_misc":{"Written by":"Simon Frey","Est. reading time":"4 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/#article","isPartOf":{"@id":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/"},"author":{"name":"Simon Frey","@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/34753982b648415636ee7a079f3e19a3"},"headline":"Self-hosted Plausible Analytics on Kubernetes (with db operators and ArgoCD)","datePublished":"2026-03-19T09:12:25+00:00","dateModified":"2026-03-23T10:31:22+00:00","mainEntityOfPage":{"@id":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/"},"wordCount":939,"publisher":{"@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/34753982b648415636ee7a079f3e19a3"},"articleSection":["DevOps","Homelab","Kubernetes (k8s)"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/","url":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/","name":"Self-hosted Plausible Analytics on Kubernetes (with db operators and ArgoCD) - Blog by Simon Frey","isPartOf":{"@id":"https:\/\/simon-frey.com\/blog\/#website"},"datePublished":"2026-03-19T09:12:25+00:00","dateModified":"2026-03-23T10:31:22+00:00","breadcrumb":{"@id":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/simon-frey.com\/blog\/self-hosted-plausible-analytics-on-kubernetes\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/simon-frey.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Self-hosted Plausible Analytics on Kubernetes (with db operators and ArgoCD)"}]},{"@type":"WebSite","@id":"https:\/\/simon-frey.com\/blog\/#website","url":"https:\/\/simon-frey.com\/blog\/","name":"Blog by Simon Frey","description":"","publisher":{"@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/34753982b648415636ee7a079f3e19a3"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/simon-frey.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/34753982b648415636ee7a079f3e19a3","name":"Simon Frey","logo":{"@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/image\/"},"sameAs":["https:\/\/simon-frey.com","https:\/\/www.linkedin.com\/in\/simonfrey\/","https:\/\/x.com\/eu_frey"]}]}},"_links":{"self":[{"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/posts\/1326","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/comments?post=1326"}],"version-history":[{"count":1,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/posts\/1326\/revisions"}],"predecessor-version":[{"id":1327,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/posts\/1326\/revisions\/1327"}],"wp:attachment":[{"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/media?parent=1326"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/categories?post=1326"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/tags?post=1326"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}