DEV Community

우병수
우병수

Posted on • Originally published at techdigestor.com

Self-Hosted Analytics Without Google: Plausible, Umami, and Matomo on Your Own Hardware

TL;DR: GA4's event model is the first thing that breaks trust. The old Universal Analytics had one job: tell you how many people read a page.

📖 Reading time: ~22 min

What's in this article

  1. Why Self-Hosted Operators Stop Trusting Google Analytics
  2. The Three Contenders: What Each Tool Actually Is
  3. Comparison Table: Hardware Requirements and Operational Reality
  4. Setup to Production: Docker Compose Configs for Each
  5. Non-Obvious Behaviors That Cost Time in Production
  6. When to Pick What: Matching the Tool to the Actual Situation
  7. Ongoing Ops: Backups, Updates, and Staying Out of Trouble

Why Self-Hosted Operators Stop Trusting Google Analytics

GA4's event model is the first thing that breaks trust. The old Universal Analytics had one job: tell you how many people read a page. GA4 replaced that with a flexible event schema that's genuinely powerful for e-commerce funnels — and genuinely annoying if you just want a pageview count you can trust. Getting that number now means navigating the Explorations interface, picking the right event, filtering by page_location, and hoping you didn't hit the sampling threshold that kicks in on date ranges longer than a few weeks. A self-hosted blog operator shouldn't need to reverse-engineer a BI tool to answer "did anyone read Tuesday's post?"

The data residency problem is more fundamental than the UX complaints. If you're already running your own Nginx, your own Postgres, your own monitoring stack — routing your visitors' behavior through Google's infrastructure is architecturally incoherent. It's not paranoia; it's consistency. GDPR makes this concrete: any EU visitor whose data hits Google's servers requires a consent mechanism, which in practice means a cookie banner, a Consent Management Platform, or both. The CMP itself adds another 20–80 KB of JavaScript depending on vendor, it fires before your content loads, and it tanks your Lighthouse scores on mobile. You're paying a performance penalty to collect data you don't own.

The real cost accounting looks like this: the GA4 loader script ships around 45 KB minified (closer to 17 KB gzipped, but it also triggers additional async fetches). That's not catastrophic in isolation, but stack it against a consent banner blocking render, the CMP payload, and the fact that GA4 stores aggregated data with no raw event export on the free tier, and the "free" label stops being accurate. You don't get a SQL table. You get a dashboard that Google controls, with sampling on queries that touch more than a few months of history, and no way to backfill if you change your event schema. Compare that to running Plausible or Umami in Docker: a single container, raw data in Postgres or ClickHouse, zero third-party calls, and no consent banner required under most GDPR guidance because no personal data leaves your server.

The build-vs-rent tension here is identical to what comes up with AI tooling — you can rent Google's analytics infrastructure the same way you can rent a cloud copilot, and both choices come with the same hidden costs: rate limits you don't control, data you can't fully export, and a pricing/feature surface that can change under you. For operators already thinking through that tradeoff in other contexts, see our breakdown of AI Coding Tools in 2026: Cloud Copilots vs Local Models — the reasoning transfers directly. Once you've run your own LLM inference or your own monitoring stack, the question isn't whether to self-host analytics; it's which tool fits your traffic volume and query patterns.

The Three Contenders: What Each Tool Actually Is

Plausible v2.x surprises most people with its stack choice: it's written in Go with an Elixir layer (the Elixir/Phoenix frontend, Go for the ingestion path), backed by ClickHouse for event storage rather than PostgreSQL — which is where its query speed comes from on high-traffic sites. The ~1 KB script isn't marketing copy; load it and inspect it yourself in DevTools. There's no cookie set, no fingerprinting, no personal data written to disk. That's not just a privacy stance — it means you genuinely don't need a consent banner under GDPR or ePrivacy, which removes an entire category of compliance work. The trade-off is that Plausible's data model is intentionally coarse: sessions are approximated, not tracked across requests, so you won't get per-user journey data. That's a feature if you don't want it, a blocker if you do.

Umami v2.x runs on Next.js 14+ with either PostgreSQL 15+ or MySQL 8+, and it's the most developer-friendly of the three to modify. The dashboard is a React app you can fork, restyle, or embed. Event tracking is a dead-simple API call:

// Send a custom event from any JS context
fetch('https://your-umami-host/api/send', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    payload: {
      hostname: window.location.hostname,
      language: navigator.language,
      referrer: document.referrer,
      screen: `${window.screen.width}x${window.screen.height}`,
      title: "document.title,"
      url: window.location.pathname,
      website: 'YOUR_WEBSITE_UUID',
      name: 'button_click',      // event name
      data: { plan: 'pro' }      // arbitrary JSON payload
    },
    type: 'event'
  })
});
Enter fullscreen mode Exit fullscreen mode

Cookies are off by default in recent versions, though you can enable session persistence if you need it. The honest limitation: Umami's query layer is straightforward SQL — fine for most sites, but if you're pushing millions of events per day, you'll feel the difference versus ClickHouse-backed tools. It also doesn't ship with funnel analysis, heatmaps, or e-commerce tracking out of the box.

Matomo v5.x is the only one of these three that can genuinely replace Google Analytics feature-for-feature. PHP 8.2+ and MySQL 8+ (or MariaDB 10.6+) are the current requirements. You get funnels, goal tracking, e-commerce revenue attribution, campaign tagging, and raw SQL access to your own database — no data locked in a vendor's warehouse. Heatmaps and session recordings exist but are paid plugins through the Matomo Marketplace, which is a real cost to factor in. The operational weight is also real: Matomo runs a cron job for log processing, the archive process hammers your database on high-traffic sites, and the plugin ecosystem means you're managing PHP dependencies. Expect to allocate at least 2 GB RAM to a Matomo instance under any meaningful load, versus Plausible and Umami both running comfortably under 512 MB for most self-hosted scenarios.

The practical split: Plausible if you want the minimum viable analytics footprint with zero compliance friction. Umami if you're already in a Next.js ecosystem or want to customize the tool itself. Matomo if you're replacing GA3 and need feature parity — funnels, e-commerce, or a client who expects a specific report that the lighter tools simply can't produce.

Comparison Table: Hardware Requirements and Operational Reality

Hardware Requirements and Operational Reality

The gap between "runs on my machine" and "runs reliably at 3am when nobody's watching" is mostly a resource-sizing problem. These three tools have genuinely different operational profiles — not just different RAM numbers, but different failure modes when those numbers get tight. The worst surprises come from components that aren't the main application: ClickHouse for Plausible, the archiving queue for Matomo, MySQL tuning for Umami under write pressure.

Dimension

Plausible CE

Umami

Matomo

Minimum RAM (container stack)

~512 MB app + ~1 GB ClickHouse = ~1.5 GB realistic floor

~256 MB app + whatever your existing Postgres/MySQL uses

~1 GB PHP-FPM + MySQL under any real traffic

Idle disk write rate

ClickHouse merges parts in background; expect non-trivial I/O even at idle — not friendly to NVMe write-endurance budgets on small VPS

Low — standard DB writes, no background compaction engine; quiet on low-traffic sites

Archiving cron writes aggregates to MySQL; can spike I/O heavily during catch-up runs on busy sites

Tracker script payload

~1 KB

~2 KB

~22 KB default (configurable; a minimal build gets it lower, but requires deliberate effort)

Database engine support

ClickHouse only (no swapping it out)

PostgreSQL 12+ or MySQL 5.7+ — reuse existing infrastructure

MySQL / MariaDB (primary); experimental Postgres support exists but isn't production-recommended

Single biggest operational dealbreaker

ClickHouse overhead on a low-RAM VPS. On a 1 GB or 2 GB node, ClickHouse competes with the Plausible app itself and will OOM under aggregation pressure. The community edition moved aggregations to ClickHouse — you can't opt out.

Reporting depth. Umami gives you pageviews, referrers, devices, and custom events — but no funnel analysis, no goal tracking UI, no segmentation engine. If you outgrow the basics, you're either querying the DB directly or moving tools.

The archiving cron job. Matomo doesn't aggregate in real-time — it batches via core:archive. If the cron falls behind (high traffic, slow disk, misconfigured PHP memory limit), your dashboards show stale data and the unprocessed queue grows until you manually intervene or the next cron window opens.

The archiving problem with Matomo deserves more attention than it usually gets. The cron command is:

# run as www-data or the PHP user, not root
/usr/bin/php /var/www/matomo/console core:archive \
  --url=https://your-matomo-instance.example.com \
  --php-cli-options="-d memory_limit=2048M"
Enter fullscreen mode Exit fullscreen mode

If memory_limit is too low in the CLI context (separate from the web php.ini), the archiver silently exits mid-run. Dashboards look fine until you notice the "last processed" timestamp hasn't moved in six hours. The fix is trivial once you know it, but the failure is quiet — no alert, no error page, just stale numbers. Set up an external check on that timestamp or you will miss it.

Umami's low RAM floor is the real differentiator for shared or budget hardware. If you're already running Postgres 15 or MySQL 8 for something else on the same host, Umami adds essentially nothing to your resource budget — just another schema in an existing instance. Plausible's ClickHouse dependency is non-negotiable in the current self-hosted CE, and ClickHouse behaves badly under memory pressure in ways that are hard to tune without deep ClickHouse knowledge. For anything under 4 GB total host RAM, Plausible CE becomes a risky choice unless you dedicate the box to it.

Setup to Production: Docker Compose Configs for Each

The gap between "it runs" and "it runs correctly" is almost entirely in the config details the official docs bury or skip. Here's what each stack actually needs.

Plausible CE: Three Services, One Footgun

The official docker-compose.yml spins up three containers — plausible, plausible_db (Postgres 16), and clickhouse — and the ClickHouse one will silently OOM on a 1 GB VPS if you don't constrain it. The fix lives in a mounted XML config, not in compose env vars.

# clickhouse-config.xml — mount this into the clickhouse container
<?xml version="1.0"?>
<clickhouse>
  <max_server_memory_usage_to_ram_ratio>0.4</max_server_memory_usage_to_ram_ratio>
</clickhouse>
Enter fullscreen mode Exit fullscreen mode
# docker-compose.yml (Plausible CE — trimmed to the non-obvious bits)
version: "3.8"
services:
  plausible:
    image: ghcr.io/plausible/community-edition:v2.1.1
    restart: unless-stopped
    depends_on:
      - plausible_db
      - clickhouse
    environment:
      BASE_URL: "https://stats.yourdomain.com"
      SECRET_KEY_BASE: "REPLACE_WITH_64_CHAR_HEX"   # openssl rand -hex 64
      TOTP_VAULT_KEY: "REPLACE_WITH_BASE64_KEY"      # openssl rand -base64 32
    ports:
      - "127.0.0.1:8000:8000"

  plausible_db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: plausible_db
      POSTGRES_USER: plausible
      POSTGRES_PASSWORD: REPLACE_ME
    volumes:
      - pg_data:/var/lib/postgresql/data

  clickhouse:
    image: clickhouse/clickhouse-server:24.3-alpine
    restart: unless-stopped
    volumes:
      - ./clickhouse-config.xml:/etc/clickhouse-server/config.d/memory.xml:ro
      - ch_data:/var/lib/clickhouse

volumes:
  pg_data:
  ch_data:
Enter fullscreen mode Exit fullscreen mode

SECRET_KEY_BASE and TOTP_VAULT_KEY are both required on first run — omitting TOTP_VAULT_KEY doesn't throw an obvious error, it just breaks TOTP enrollment silently. Generate them before you start the stack, not after. The 0.4 ratio on ClickHouse caps it at roughly 400 MB on a 1 GB host; tune it up on bigger boxes but never skip it.

Umami: The Lean Two-Service Option

Umami's compose is genuinely simple — one app container, one database. The entire database config lives in a single DATABASE_URL env var, which makes environment swaps trivial. It works with either Postgres or MySQL; Postgres is the better call if you're already running it for other services.

version: "3.8"
services:
  umami:
    image: ghcr.io/umami-software/umami:postgresql-v2.13.2
    restart: unless-stopped
    depends_on:
      - umami_db
    environment:
      DATABASE_URL: "postgresql://umami:REPLACE_ME@umami_db:5432/umami"
      APP_SECRET: "REPLACE_WITH_RANDOM_STRING"
    ports:
      - "127.0.0.1:3000:3000"

  umami_db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: REPLACE_ME
    volumes:
      - umami_pg:/var/lib/postgresql/data

volumes:
  umami_pg:
Enter fullscreen mode Exit fullscreen mode

The tracking script endpoint is /script.js and the event collection endpoint is /api/send. That second one is useful: you can POST custom events directly from an n8n HTTP Request node without touching the browser SDK at all. The payload is straightforward JSON — {"type":"event","payload":{"website":"YOUR_WEBSITE_ID","url":"/","name":"custom_event"}} — which means any workflow that reaches an HTTP node can push telemetry into Umami. On my n8n flows I use this to track non-browser actions like newsletter sends or RSS fetch completions.

Matomo: The Cron Container You Can't Skip

Matomo's official images are matomo:5-apache (simpler) and matomo:5-fpm (pairs with a separate nginx container, slightly lower memory per request). The less-documented requirement is the archiver — without it, your reports are raw unprocessed logs and the UI shows stale or empty data. The exact command is:

# Run this every hour via cron or a dedicated compose service
php /var/www/html/console core:archive --url=https://your-matomo-domain
Enter fullscreen mode Exit fullscreen mode
version: "3.8"
services:
  matomo:
    image: matomo:5-apache
    restart: unless-stopped
    depends_on:
      - matomo_db
    environment:
      MATOMO_DATABASE_HOST: matomo_db
      MATOMO_DATABASE_DBNAME: matomo
      MATOMO_DATABASE_USERNAME: matomo
      MATOMO_DATABASE_PASSWORD: REPLACE_ME
    volumes:
      - matomo_html:/var/www/html
    ports:
      - "127.0.0.1:8080:80"

  matomo_cron:
    image: matomo:5-apache
    restart: unless-stopped
    depends_on:
      - matomo
    volumes:
      - matomo_html:/var/www/html   # shared volume so cron sees same config
    entrypoint: |
      sh -c "while true; do
        php /var/www/html/console core:archive --url=https://your-matomo-domain;
        sleep 3600;
      done"

  matomo_db:
    image: mariadb:11.4
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: matomo
      MYSQL_USER: matomo
      MYSQL_PASSWORD: REPLACE_ME
      MYSQL_ROOT_PASSWORD: REPLACE_ROOT_ME
    volumes:
      - matomo_db_data:/var/lib/mysql

volumes:
  matomo_html:
  matomo_db_data:
Enter fullscreen mode Exit fullscreen mode

The shared matomo_html volume between the main container and the cron container is the key detail here — the archiver needs access to config/config.ini.php which is written on first-run setup. If the cron container mounts a separate or empty volume, it finds no config and exits silently with a non-zero code that's easy to miss. MariaDB 11.4 is the current LTS and what Matomo's installer validates against; Postgres support in Matomo is officially experimental as of v5.

Reverse Proxy: Where Each Tool Has Its Own Quirk

All three sit cleanly behind Nginx or Caddy on 127.0.0.1 ports, but each has a config requirement that trips people up in production. Plausible needs accurate X-Forwarded-For headers or every visitor logs as your proxy's IP. Matomo has the same problem but fixes it in PHP config rather than relying purely on the proxy headers.

# Nginx — Plausible block (X-Forwarded-For is the critical line)
location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Host $host;
}
Enter fullscreen mode Exit fullscreen mode
# Matomo: config/config.ini.php — add under [General]
# Without this, Matomo logs 127.0.0.1 as every visitor's IP
[General]
trusted_proxies[] = "127.0.0.1"
proxy_client_headers[] = "HTTP_X_FORWARDED_FOR"
proxy_host_headers[] = "HTTP_X_FORWARDED_HOST"
Enter fullscreen mode Exit fullscreen mode

Caddy handles X-Forwarded-For automatically when you use the reverse_proxy directive — it's one of the reasons I prefer Caddy for new self-hosted stacks. With Nginx you have to be explicit every time. For Umami there's no special proxy config required; it reads client IPs from the forwarded headers without extra configuration as long as the header reaches the app container. One Nginx-specific gotcha with Plausible: if you're behind a CDN that also adds X-Forwarded-For, the header can contain a comma-separated chain of IPs — Plausible reads the leftmost one, which is correct behavior, but confirm your CDN isn't mangling the header order before blaming geolocation accuracy.

Non-Obvious Behaviors That Cost Time in Production

The one that catches the most operators off guard with Plausible: ClickHouse will silently accept write requests and return HTTP 200s while Plausible is mid-write — but if the ClickHouse container restarts at that moment, you end up with an inconsistent state where Plausible's dashboard returns empty charts for that period. No error in the Plausible logs. No obvious failure. Just a gap. The fix is straightforward but non-obvious if you haven't been burned by it: set restart: always on both the Plausible and ClickHouse containers in your Compose file, and add a real health check to ClickHouse that uses clickhouse-client rather than a generic TCP ping:

services:
  clickhouse:
    image: clickhouse/clickhouse-server:24.3
    restart: always
    healthcheck:
      test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  plausible:
    image: ghcr.io/plausible/community-edition:v2.1.4
    restart: always
    depends_on:
      clickhouse:
        condition: service_healthy
Enter fullscreen mode Exit fullscreen mode

The depends_on with condition: service_healthy is the part most Docker Compose tutorials skip. Without it, Plausible starts, tries to connect before ClickHouse is actually ready to serve queries, and you get a subtler version of the same gap problem — especially after host reboots.

Umami's event tracking API is deceptively permissive. You can POST arbitrary JSON to the /api/send endpoint and the server will accept it without complaint. The trap is that the default Umami dashboard UI only surfaces string and number property values — anything else either gets coerced silently or ignored at render time. If you're trying to replicate GA's custom dimensions behavior and you want to filter or segment by those properties, you'll hit a wall in the UI almost immediately. The actual data is in the PostgreSQL website_event and event_data tables, and querying it directly is the only real path to GA-style segmentation. A query that surfaces custom string properties for a given event name looks like this:

SELECT
  we.created_at,
  we.event_name,
  ed.string_value,
  ed.number_value
FROM website_event we
JOIN event_data ed ON ed.website_event_id = we.id
WHERE we.website_id = 'your-website-uuid'
  AND we.event_name = 'purchase'
ORDER BY we.created_at DESC
LIMIT 100;
Enter fullscreen mode Exit fullscreen mode

Matomo's archiving cron is the most production-hostile behavior of the three. The archiving process is stateful — Matomo tracks which periods have been archived in the database, and if a cron run fails partway through (the most common cause being PHP hitting its default memory_limit of 128M on sites with any real traffic), the next scheduled run re-attempts the same period from scratch. Two back-to-back failures on a busy day means the third run tries to process three periods simultaneously. The load compounds fast. The fix requires two things: bump memory_limit = 256M in your php.ini (512M if you have months of historical data being re-archived), and route cron output to a file you can actually grep:

# in crontab -e
*/15 * * * * www-data /usr/bin/php /var/www/matomo/console core:archive \
  --url=https://matomo.yourdomain.com \
  >> /var/log/matomo/archive.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Without that log file, you're flying blind. Matomo's UI will show "archiving in progress" indefinitely if the cron process dies mid-run, and there's no dashboard alert for it. Tail that log during the first week after any Matomo upgrade — the memory pressure almost always spikes after schema migrations.

All three tools share one unavoidable problem: Firefox with Enhanced Tracking Protection enabled and Brave with default shield settings will block the tracking scripts before they reach the browser. This isn't a minor edge case — on a developer or privacy-conscious audience, a meaningful chunk of your actual visitors simply won't register. Plausible's official docs include a working Nginx proxy snippet that routes the script and event endpoint through your own domain:

location = /js/script.js {
    proxy_pass https://plausible.io/js/script.js;
    proxy_set_header Host plausible.io;
}

location = /api/event {
    proxy_pass https://plausible.io/api/event;
    proxy_set_header Host plausible.io;
    proxy_buffering on;
    proxy_http_version 1.1;
}
Enter fullscreen mode Exit fullscreen mode

Umami sidesteps the proxy complexity entirely if you're self-hosting: since Umami runs on your own infrastructure, you can mount it at a path on your primary domain (e.g., yourdomain.com/stats/) and serve the tracking script from there. Browsers treating first-party paths as tracking domains are rare enough that this approach works in practice. Matomo also supports first-party cookie mode and a self-hosted script path, but the configuration is buried in the Tag Manager settings and the documentation assumes you already know where to look.

When to Pick What: Matching the Tool to the Actual Situation

The actual decision between these tools comes down to operational cost and data model requirements, not feature checklists. Running any of these for a few days reveals the real constraints faster than any comparison table.

Pick Plausible when you want the shortest path from "deployed" to "trustworthy data" — no cookie banner, no consent dialog, no GDPR checkbox engineering. The ClickHouse container is the honest constraint: expect the pair (Plausible + ClickHouse) to sit comfortably inside 2 GB RAM once warmed up, but plan for spiky memory during ClickHouse compaction if you're on a shared VPS. Once stable, it's the least operationally demanding of the three — ClickHouse rarely needs tuning at typical blog-scale traffic and the Plausible UI has no configuration surface to break. If your audience is technical or privacy-aware enough that they run uBlock or Brave, you're also going to see fewer gaps in your data than with anything cookie-dependent.

Pick Umami if you already have a running Postgres 14+ or MySQL 8 instance and don't want to add another database engine to your stack. The setup is genuinely fast — single Docker image, one DATABASE_URL env var, done. The bigger reason to pick Umami over Plausible is the /api/send endpoint: it accepts a plain POST with a JSON payload, which means your n8n flows or any server-side automation can push custom events without a browser involved. The footgun is real though — by default that endpoint has no authentication, so anyone who finds your tracker URL can inject events. Set DISABLE_TELEMETRY=1 and put a reverse-proxy auth layer in front before you expose this publicly:

# nginx snippet — drop requests to /api/send that don't carry your shared secret
location /api/send {
    # only allow your own automation; block browsers you don't control
    if ($http_x_tracker_secret != "your-secret-here") {
        return 403;
    }
    proxy_pass http://umami:3000;
}
Enter fullscreen mode Exit fullscreen mode

Pick Matomo when the data you need simply doesn't fit into a page-view counter — goal funnels, e-commerce revenue rows, session recordings, or raw SQL access to the log_visit table for custom reporting. The honest trade-off: you're not running a container, you're running a PHP application with a MySQL/MariaDB backend, a cron job for archiving, and a plugin ecosystem that can drift. Migrating from Universal Analytics is the strongest case for Matomo — the goal and funnel data model maps closely enough that you don't have to redesign your measurement strategy from scratch. Accept the operational weight and you get the most complete local data store of the three.

Skip all three and route to GoAccess on your Nginx access logs if your situation is any of: you have zero JavaScript budget (AMP pages, email-linked landing pages, API-only services), you're trying to measure traffic that a JS snippet would miss entirely (RSS fetches, direct API consumers, bot traffic you actually want to count), or you just need referrer and page-view counts without standing up a database. GoAccess parses compressed log archives, outputs a self-contained HTML report, and adds nothing to your request path:

# generate a real-time HTML report from rotated Nginx logs
zcat /var/log/nginx/access.log.*.gz | \
  goacccess /var/log/nginx/access.log - \
  --log-format=COMBINED \
  --output=/var/www/html/stats/report.html
Enter fullscreen mode Exit fullscreen mode

Run that in a PM2-managed cron and you have a zero-dependency analytics page that survives anything the other three tools won't — database crashes, container OOM kills, schema migrations that break on PHP version bumps. The data is coarser, but for a mostly-server-side or API workload it's more accurate than anything that relies on a browser firing a JavaScript beacon.

Ongoing Ops: Backups, Updates, and Staying Out of Trouble

The part that catches most people is assuming analytics data is recoverable after a bad upgrade. It usually isn't — not cleanly. By the time you notice the graphs stopped updating, the window for a clean rollback is already closed.

Plausible: Two Databases, Two Failure Modes

Plausible splits its state across Postgres and ClickHouse, and losing either one breaks the stack in completely different ways. Postgres holds user accounts, site configs, and API keys — lose it and the app won't boot cleanly. ClickHouse holds every pageview event — lose it and your dashboards are blank forever. Back up both, separately, on different schedules if you have to.

For Postgres, standard pg_dump is fine:

# Run from cron or an n8n schedule node
pg_dump -U plausible -d plausible_db | gzip > /backups/plausible-pg-$(date +%F).sql.gz
Enter fullscreen mode Exit fullscreen mode

For ClickHouse, the cleanest option if you're running it in Docker is a volume snapshot at the host level, or clickhouse-backup if you want something scriptable:

# clickhouse-backup create — backs up to /var/lib/clickhouse/backup/
docker exec clickhouse clickhouse-backup create plausible-$(date +%F)
# Then rsync or rclone that directory off the host
Enter fullscreen mode Exit fullscreen mode

The clickhouse-backup tool requires a config file at /etc/clickhouse-backup/config.yml inside the container. The docs skip that step and the binary just silently exits if the config is missing — check docker logs before trusting that the backup ran.

Umami: Simpler Stack, But Pin Your Image Tag

Umami is fully stateless on the app side — everything lives in Postgres, so a daily pg_dump is genuinely sufficient. What will burn you isn't the backup strategy, it's the image tag. The latest tag on ghcr.io/umami-software/umami has shipped breaking schema changes on minor bumps without warning. Pin to a specific release in your docker-compose.yml:

services:
  umami:
    image: ghcr.io/umami-software/umami:postgresql-v2.12.0
    # Do NOT use :latest in production
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

When you want to upgrade, pull the changelog manually, bump the tag explicitly, and let Umami's built-in migration run on first boot with the new image. Rolling back means just reverting the tag and restoring the Postgres dump — the stateless app layer makes this fast.

Matomo: The Silent Migration Corruption Problem

Matomo is the most operationally fragile of the three on upgrades. Its database migration on version bumps touches archive tables, and if the migration fails partway through — which happens more often than the docs admit — it won't tell you loudly. The dashboards will load, the data will look present, and two weeks later you'll notice your historical reports are returning zeros or throwing SQL errors on aggregation queries.

Before any Matomo version upgrade, two things are non-negotiable. First, dump both the database and the config directory:

# MariaDB/MySQL dump
mysqldump -u matomo -p matomo_db | gzip > /backups/matomo-db-$(date +%F).sql.gz

# Config dir — holds local.php with DB credentials and installed plugin state
tar czf /backups/matomo-config-$(date +%F).tar.gz /var/www/html/config/
Enter fullscreen mode Exit fullscreen mode

Second, hit the pre-update check before touching the image tag:

https://your-matomo-host/index.php?module=CoreUpdater
Enter fullscreen mode Exit fullscreen mode

That endpoint will tell you if the pending migration has any prerequisites it can't meet. Skip it and you're gambling. The Matomo upgrade docs mention it in passing; treat it as mandatory.

All Three: You Need an Uptime Monitor on the Analytics Endpoint

Analytics data gaps are invisible until they're not. A container that OOMed at 3am, a ClickHouse writer that deadlocked and stopped accepting events, a Matomo cron that quietly stopped archiving — none of these will throw an obvious error. Your site keeps loading, users keep visiting, and the data just stops. You'll notice days later when a graph goes flat.

Uptime Kuma runs well in the same Docker network and adds negligible overhead. Point an HTTP monitor at each analytics ingestion endpoint, not just the dashboard UI:

  • Plausible: GET /api/event returns a 202 on a valid payload — monitor that, not the homepage
  • Umami: /api/send endpoint; a 400 on a malformed payload still confirms the app is alive
  • Matomo: /matomo.php?idsite=1&rec=1 — a 204 response means the tracker is accepting hits

Set the alert interval tight — five minutes is reasonable. A 24-hour gap in analytics data is already painful to explain; a week-long gap because nobody checked is a data loss event.


Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.


Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.

Top comments (0)