DEV Community

우병수
우병수

Posted on • Originally published at techdigestor.com

5 Self-Hosted Analytics Tools You Can Actually Run on Your Own Server

TL;DR: GA4's data sampling kicks in at surprisingly low traffic levels — and the threshold isn't based on what would be statistically useful for you, it's based on what's computationally cheap for Google. A small e-commerce site running a conversion funnel report over a 90-day window w

📖 Reading time: ~20 min

What's in this article

  1. The Problem With Sending Your Traffic Data Somewhere Else
  2. How to Read This Comparison
  3. The Five Tools: Setup Snapshots and Real Trade-offs
  4. Deployment Configs Worth Copying
  5. Comparison Table: What Matters at the Operator Level
  6. When to Pick Which Tool
  7. Keeping These Running: Backup and Upgrade Notes

The Problem With Sending Your Traffic Data Somewhere Else

GA4's data sampling kicks in at surprisingly low traffic levels — and the threshold isn't based on what would be statistically useful for you, it's based on what's computationally cheap for Google. A small e-commerce site running a conversion funnel report over a 90-day window will hit sampled results long before a high-traffic publisher does, which means the sites with the fewest data points to begin with are the ones getting their data thinned out further. The 14-month retention limit compounds this: any cohort or funnel analysis that crosses that window is just gone, not archived somewhere you could pay to access — gone.

The tracker-blocking problem is worse than most analytics dashboards will tell you. uBlock Origin, Brave's built-in shields, Firefox's Enhanced Tracking Protection, and DNS-level blockers like Pi-hole all drop google-analytics.com and gtag calls by default. What you see in GA4 is an undercount, and the undercount is skewed: technical audiences, privacy-conscious users, and anyone on a managed corporate network are disproportionately invisible. Beyond the measurement gap, every pageview that does get through is a data point handed to an advertising platform with its own interests in how that data gets used and retained.

Running your own analytics instance changes the data ownership model entirely. Your events land in a Postgres or ClickHouse table that you control — no sampling algorithm between collection and query, no retention policy you didn't write, and in many jurisdictions no cookie consent banner required at all when visitor data never leaves your infrastructure. The EU's GDPR guidance, and similar frameworks, generally treat server-side collection that doesn't involve cross-site tracking or external processors differently from GA4, which explicitly routes data through Google's servers. That's not legal advice, but it's a real architectural difference worth understanding before you dismiss self-hosted analytics as overkill.

What follows covers five tools that actually run in Docker without heroic effort: Plausible, Umami, Matomo, PostHog, and Fathom Lite. For each one, you'll get an honest read on RAM and disk growth over time — because "lightweight" means different things at 10k monthly visits versus 500k — along with the specific use case it fits best and where it breaks down. Some of these are genuinely impressive. Some have sharp edges that only show up after a few weeks of real traffic. The goal is to give you enough to make the right call for your setup, not to sell you on self-hosting as a philosophy.

How to Read This Comparison

Every comparison like this has hidden assumptions baked in. Here they're explicit: all five tools were evaluated under operator constraints that match a typical solo or small-team deployment — a single VPS or home-lab host, Docker Compose for orchestration, and a reverse proxy handling TLS (either Caddy with automatic certs or Nginx with manual config). No managed RDS, no cloud-hosted Postgres, no autoscaling. If a tool's architecture assumes it can phone home to a managed service or spin up additional nodes, that counts against it here.

The resource numbers you'll see reflect steady-state behavior at moderate traffic — roughly 50k pageviews per month. That's not a high-traffic site, but it's enough to expose tools that bloat their write queues or hold too much in memory between flushes. Where cold-start behavior is meaningfully different (some tools take 30–90 seconds before they'll accept ingestion), or where a traffic spike causes visible degradation, those are called out explicitly rather than buried in a footnote.

The comparison table uses five columns that actually matter for operational decisions:

  • Minimum RAM — what the container actually consumes under the load profile above, not the optimistic number from the README
  • Database backend — because your backup strategy, query performance, and migration path all depend on this
  • Data export format — CSV, JSON, and Parquet are not equivalent; one of these tools gives you neither CSV nor JSON by default
  • Multi-site support — whether you can track multiple domains under one install, or need separate deployments
  • Single biggest operational gotcha — the thing that isn't in the README but will find you after two weeks of running it in production

One framing note: this comparison covers pure analytics tools — event tracking, pageviews, funnels, session data. It doesn't cover self-hosted experimentation platforms, full observability stacks, or AI-assisted content pipelines. If you're thinking about how local-model tooling fits into the broader self-hosted picture, the AI Coding Tools in 2026: Cloud Copilots vs Local Models guide covers how those pieces connect in a real stack.

The Five Tools: Setup Snapshots and Real Trade-offs

The most common mistake when evaluating these tools is treating them as interchangeable. They aren't. The gap between Ackee and PostHog isn't just features — it's three orders of magnitude difference in infrastructure complexity. Pick based on what you're actually tracking, not what sounds impressive.

Plausible Analytics (Community Edition)

The Docker Compose setup is genuinely one command, and the plausible/analytics image bundles everything including the ClickHouse dependency. The gotcha that isn't in the README: ClickHouse has no memory cap by default and will cheerfully consume available RAM on a shared VPS as your event volume grows. You need to drop a config file into the container before this bites you:

# clickhouse-config.xml — mount this at /etc/clickhouse-server/config.d/
<yandex>
  <max_memory_usage>512000000</max_memory_usage>          <!-- 512MB hard cap -->
  <max_memory_usage_for_all_queries>800000000</max_memory_usage_for_all_queries>
</yandex>
Enter fullscreen mode Exit fullscreen mode

Without that, a 2GB droplet will get into OOM territory after a few weeks of normal traffic. The floor is officially ~1GB RAM, but plan for 1.5GB minimum if ClickHouse is sharing the host with anything else. The feature ceiling is also real: no funnels, no session replay, no custom event properties beyond a single goal URL. That's not a bug — Plausible is deliberately a pageview counter with a clean UI. Marketers who need conversion path analysis will hit that wall within a week.

Umami

The lightest option in this list by a significant margin. The Node.js app image sits under 200MB pulled, and at idle with a Postgres 16 backend you're looking at roughly 256MB RSS. That makes it viable on the smallest cloud instances or alongside other services on a 1GB box. The v2.x release added custom event properties, which matters — v1.x custom events were just named pings with no payload, which is nearly useless for anything beyond "button clicked". Make sure you're actually on v2 before assuming properties work.

The silent failure mode that will waste your afternoon: the DATABASE_URL environment variable must be properly URL-encoded. If your Postgres password contains @, #, or !, the container starts, logs nothing obviously wrong, and then fails auth on every request. Encode the password component with encodeURIComponent() in Node or just use a password without special characters in dev. The error surface is bad enough that it reads like a network issue, not a config issue.

# Wrong — will silently fail auth if password contains special chars
DATABASE_URL=postgresql://umami:p@ss#word@localhost:5432/umami

# Correct
DATABASE_URL=postgresql://umami:p%40ss%23word@localhost:5432/umami
Enter fullscreen mode Exit fullscreen mode

Matomo

The most feature-complete tool here, and it earns that reputation: funnels, heatmaps, A/B testing, and GDPR consent tooling are all included without a SaaS upsell. The catch is operational complexity. The recommended split-container setup — php-fpm for processing, Nginx as the reverse proxy, MySQL or MariaDB for storage — has more moving parts than the others, and the Nginx config needs to correctly proxy to the PHP socket or you get a blank screen with no useful error.

The single most common operational failure on Matomo is skipping the archiving cron. Reports don't generate in real-time — they're computed from raw logs on a schedule. If you don't set this up, your dashboard freezes at the last archived date and looks like tracking broke:

# Add to crontab — run as the web server user, not root
*/5 * * * * /usr/bin/php /var/www/html/console core:archive --url=https://your-matomo-domain.com > /dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode

The --url flag is required and must match the Matomo install URL exactly, including protocol. Get it wrong and the archiver exits silently. Matomo is the right call when you need a full analytics suite that a non-technical marketer can use without training — it's the closest self-hosted equivalent to Google Analytics in terms of UI familiarity.

PostHog (Open-Source)

The docker-compose.hobby.yml that PostHog ships for self-hosters is not a lightweight deployment. It pulls in ClickHouse, Kafka, Redis, a plugin server, a Celery worker, and the main Django app. On a fresh 4GB RAM machine, the stack consumes roughly 3–3.5GB at idle before you've tracked a single event. That's not a complaint — PostHog is solving a fundamentally different problem than pageview counting. Feature flags, session replay, funnel analysis, and cohort tracking in one open-source stack is genuinely remarkable. But running it on a $6/month VPS to replace Google Analytics for a brochure site is the wrong trade-off. Use PostHog when you're tracking logged-in user behavior in a SaaS product and need the full product analytics loop. Use anything else on this list for marketing site traffic.

Ackee

Ackee's privacy model is different from the others: no cookies, no fingerprinting, and unique visitor counts are derived by hashing IP + User-Agent once per day and discarding the raw values. You cannot reconstruct individual sessions from what Ackee stores. The architecture is Node.js talking to MongoDB, and the footprint is minimal. The sharp edge: Ackee doesn't have a marketer-facing dashboard worth showing to a client. The UI is sparse, and the primary interface for anything beyond the basic view is a GraphQL API. That's actually the point — if you want to pipe raw visit counts into your own reporting layer, Notion embed, or n8n workflow, the GraphQL endpoint is clean and easy to work with. If a non-technical user needs to check traffic independently, send them to Plausible or Umami instead.

Deployment Configs Worth Copying

The most expensive lesson with self-hosted analytics isn't picking the wrong tool — it's losing months of data because a named volume wasn't declared before running docker compose down. Every config below is structured around the failure modes I've actually hit, not the happy-path examples in the official docs.

Plausible

Plausible's compose setup has two non-negotiable variables. BASE_URL must be the exact public URL your tracking script will report to — get this wrong and events silently 404. SECRET_KEY_BASE must be a real random value, not a placeholder:

# generate once, paste into .env, never rotate without migrating sessions
openssl rand -base64 64
Enter fullscreen mode Exit fullscreen mode
version: "3.8"
services:
  plausible:
    image: ghcr.io/plausible/community-edition:v2.1.0
    env_file: .env
    depends_on:
      - db
      - clickhouse
    ports:
      - "8000:8000"

  clickhouse:
    image: clickhouse/clickhouse-server:23.3-alpine
    volumes:
      - clickhouse_data:/var/lib/clickhouse  # omit this and a compose down wipes all event history

volumes:
  clickhouse_data:  # named volume — required, not optional
Enter fullscreen mode Exit fullscreen mode

After you've created the first admin account, add DISABLE_REGISTRATION=true to your .env and restart. Without it, anyone who finds your hostname can register. There's no rate limiting on the signup endpoint.

Umami

Umami's silent failure mode: if you skip the Prisma migration on first boot, the database schema is never initialized, but the container starts cleanly with no errors in logs. The UI just… doesn't work, and you'll spend time checking DNS before realizing the schema is empty.

# Run this once after first boot — not in an entrypoint, do it manually
docker compose exec umami npx prisma migrate deploy
Enter fullscreen mode Exit fullscreen mode
# Minimal .env for Umami
DATABASE_URL=postgresql://umami:password@db:5432/umami
HASH_SALT=any-long-random-string-you-generate  # salts session fingerprints for privacy
Enter fullscreen mode Exit fullscreen mode

HASH_SALT isn't just a security formality — it's what prevents Umami from storing raw IP-derived identifiers. Change it after data is collected and all historical session groupings break. Set it once and treat it like a private key.

Matomo

Matomo's archiving step is the single most common reason people think their tracking is broken when it isn't. Raw visits land in the database fine, but the report UI queries pre-aggregated archive tables. Without a cron job running the archiver, every date range shows "No data for this period" — even while events are being recorded in real time.

version: "3.8"
services:
  matomo:
    image: matomo:5.0-apache

  cron:
    image: matomo:5.0-apache
    # same image, different entrypoint — shares the same mounted config
    volumes:
      - matomo_data:/var/www/html
    entrypoint: /bin/sh -c "echo '0 * * * * www-data php /var/www/html/console core:archive --url=https://your-domain.tld >> /var/log/matomo-archive.log 2>&1' | crontab - && cron -f"
Enter fullscreen mode Exit fullscreen mode

The --url flag must match the Matomo general settings URL exactly, including scheme. A mismatch causes the archiver to authenticate against the wrong host and exit silently. Pipe output to a log file you actually check — the default is to discard it.

PostHog

PostHog's hobby compose stack bundles MinIO for object storage because session recordings need somewhere to put binary blobs. The gotcha: if you set OBJECT_STORAGE_ENABLED=false to reclaim the ~300–400 MB of RAM MinIO holds, session recordings don't throw an error. They just silently drop. The UI shows the recording list, users appear in it, but playback is a spinner.

# Before assuming recordings work, check the worker logs
docker compose logs worker --tail=100 | grep -i "recording\|object_storage\|minio"
Enter fullscreen mode Exit fullscreen mode

If you're RAM-constrained enough to disable MinIO, turn off session recording entirely in PostHog's project settings — that way you're intentionally missing data, not accidentally missing it. The worker log is the canonical truth here; the frontend gives you no signal either way.

Comparison Table: What Matters at the Operator Level

The gap between "it boots" and "it runs reliably for six months" is where most self-hosted analytics projects fall apart. These numbers and gotchas come from documented behavior, official resource requirements, and the kinds of failure modes that show up in GitHub issues after week two of production use.

Memory Footprint at Idle

Idle RAM is what determines whether this fits on a $6/month VPS or needs its own box. Ackee is the clear winner here — roughly 128MB at idle, because it's a Node process backed by MongoDB with almost no in-process caching. Umami sits around 256MB idle, which is reasonable for a Next.js app with a Postgres connection pool. Matomo at ~512MB is acceptable if you're already running a LAMP-adjacent stack. Then the cliff: Plausible's ClickHouse requirement means you're committing roughly 1GB before a single pageview lands, because ClickHouse doesn't release its memory reservation between queries. PostHog at ~4GB idle isn't a typo — that's Postgres, ClickHouse, Kafka, Redis, and the Django app server all running simultaneously. Deploying PostHog on anything under 8GB RAM is an exercise in OOM frustration.

# PostHog's own docker-compose uses these service minimums:
# postgres:      512MB
# clickhouse:    1GB+
# kafka:         512MB
# redis:         128MB
# web/worker:    1-2GB
# Total floor:   ~4GB — and that's before actual traffic load
Enter fullscreen mode Exit fullscreen mode

Database Backends and What They Actually Mean

Every extra database in the stack is another thing to back up, tune, and potentially watch die at 2am. Ackee's MongoDB dependency is its most controversial design choice — you're adding a document store just to track page views, which feels heavy for what it does. Umami's Postgres-or-MySQL flexibility is genuinely useful; if you already have a managed Postgres instance, Umami slots in cleanly. Plausible's dual-database architecture (Postgres for account data, ClickHouse for event storage) is the right call at scale, but ClickHouse has a known memory creep behavior: without explicit max_memory_usage and max_memory_usage_for_user limits set in clickhouse-server/config.d/, it will gradually consume available RAM over days or weeks, especially with frequent aggregation queries. PostHog adds Kafka to that Postgres + ClickHouse combination, which means you now have a message queue to manage — useful for buffering ingestion spikes, but a source of delayed data when Kafka consumer lag builds up under traffic bursts.

# Plausible ClickHouse memory cap — add to your config.d/override.xml:



2000000000 <!-- 2GB hard cap -->
1500000000


The Gotcha That Will Actually Burn You

Umami's silent auth failure on a malformed database URL is probably the most dangerous because it's invisible — the container starts, health checks pass, but no events are being stored. The log output doesn't always surface a clear connection error; you find out when your dashboard shows a flatline two days later. Matomo's stale reports issue is well-known but still catches people: without the archiving cron running on schedule, the UI will serve cached aggregate data that's hours or days old while appearing current. The fix is straightforward but not obvious from the default install docs:

# Matomo archiving cron — add to crontab on the host running PHP
*/5 * * * * www-data /usr/bin/php /var/www/html/matomo/console core:archive \
  --url=https://your-matomo-domain.com > /dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode

PostHog's Kafka lag under traffic spikes is an operational reality rather than a bug. Events get buffered in Kafka, the consumer workers process them asynchronously, and during a spike you can have a 10–30 minute delay between a user action and that event appearing in the PostHog UI. For most small business use cases this is fine — but if you're watching a product launch in real-time, it will feel broken when it isn't.

Multi-Site Support: Free vs. Gated

All five tools support tracking multiple domains, but the licensing situation on Matomo deserves a callout. The multi-site feature is completely free and well-implemented in self-hosted Matomo — you manage a global view across all properties from one interface. The cloud version charges for it. If you're evaluating Matomo cloud pricing and wondering why multi-site is a paid add-on while the self-hosted docs treat it as a default feature, that's intentional product segmentation. PostHog's approach is structurally different: sites are "projects" under an organization, each with their own API key and isolated event stream, but sharing a single Kafka/ClickHouse backend. That means one PostHog deployment handles multi-tenant traffic efficiently, but schema changes or ClickHouse maintenance affect all your projects simultaneously.

When to Pick Which Tool

The fastest way to make the wrong choice here is to pick the tool with the most GitHub stars or the prettiest landing page. The right call depends almost entirely on your existing infrastructure, your ops tolerance, and whether you're tracking sessions or events — those are genuinely different problems with different right answers.

Pick Umami if you run a personal site or small blog and already have a Postgres instance sitting around. The migration path is a single docker-compose up, the schema is dead simple, and the dashboard gives you what 90% of non-technical stakeholders actually want to see. I've had Umami running for months without touching it — no log rotation surprises, no memory creep, no ClickHouse vacuum jobs. It's the only tool on this list where "near-zero ops" is actually true rather than aspirational marketing copy.

Pick Plausible Community Edition if you're migrating a client or marketing team off GA4 and need the UI to sell itself without a 20-minute onboarding call. The catch nobody mentions upfront: ClickHouse is not optional, and if you skip the memory config before your first traffic spike, you will hit OOM kills. Add this to your ClickHouse config.xml from day one:

<profiles>
  <default>
    <max_memory_usage>1073741824</max_memory_usage>
  </default>
</profiles>
Enter fullscreen mode Exit fullscreen mode

Budget 1–2GB RAM above what you'd normally allocate just for the ClickHouse process, and don't run this on a 1GB VPS. Plausible is polished enough that non-technical stakeholders will actually trust the numbers, which is worth the extra infra overhead in client-facing contexts.

Pick Matomo if your use case is GDPR compliance documentation, funnel analysis, or heatmaps — and you're genuinely comfortable running a PHP application backed by MySQL long-term. The feature depth is real: Matomo's funnel and goal tracking is legitimately GA360-tier, and the consent management tooling is the most defensible of anything on this list for regulated industries. But the ops surface is the highest here. You're maintaining PHP runtime versions, MySQL slow query logs, and a plugin ecosystem that varies wildly in quality. Go in with eyes open.

Pick PostHog if you need to track product behavior — button clicks, form completions, feature flag exposure, session replays — alongside pageviews. The pageview tracking is almost incidental to what PostHog is actually built for. Using it purely as a hit counter is genuinely wasteful: it requires at least 4GB RAM to run the full stack without constant swap pressure, and that overhead only makes sense when you're using the event pipeline, cohort analysis, or the feature flag system. Pick Ackee at the opposite end: no sessions, no funnels, no retention graphs — just a clean GraphQL API that returns raw aggregated numbers. If you're piping analytics data into a custom dashboard or an n8n workflow that does its own aggregation, Ackee's minimalism is a deliberate architectural choice, not a missing feature. On my n8n setup I can query Ackee's API directly in an HTTP Request node and reshape the data however the downstream step needs it, without fighting an opinionated data model.

Keeping These Running: Backup and Upgrade Notes

The ClickHouse gotcha will ruin your week if you're not ready for it. Plausible and PostHog both use ClickHouse under the hood, and ClickHouse does not support naive major-version upgrades — pulling a new image tag and running docker compose up will either refuse to start or silently corrupt your data depending on the version gap. The fix is simple but easy to skip: pin your image tags explicitly in your compose file and read the ClickHouse changelog before you touch anything. A latest tag in production is how you find this out the hard way.

# docker-compose.yml — pin everything, never use :latest for stateful services
services:
  clickhouse:
    image: clickhouse/clickhouse-server:24.3.3.102  # pin to exact patch
    # before upgrading: read https://clickhouse.com/docs/en/whats-new/changelog
    # major version jumps require running the migration step first:
    # docker exec -it clickhouse clickhouse-client --query "SELECT version()"
    # then follow the upgrade guide for your specific version delta
  plausible:
    image: ghcr.io/plausible/community-edition:v2.1.1  # match to ClickHouse compat matrix
Enter fullscreen mode Exit fullscreen mode

Matomo's backup requirements are slightly non-obvious. The MySQL database is the obvious thing to dump, but the file that actually controls your instance state is config/config.ini.php. That file holds your salted password hash configuration, your plugin enable/disable state, and your database credentials. If you restore the DB without it, Matomo either refuses to run or walks you through a fresh install as if no data exists. Back up both, keep them together, and test the restore. A minimal working backup script:

#!/bin/bash
# matomo-backup.sh — run nightly via cron
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/backups/matomo"

# DB dump
mysqldump -u matomo_user -p"$MATOMO_DB_PASS" matomo_db | \
  gzip > "$BACKUP_DIR/db_$TIMESTAMP.sql.gz"

# Config file — small but critical
cp /opt/matomo/config/config.ini.php \
  "$BACKUP_DIR/config_$TIMESTAMP.ini.php"

# Prune backups older than 14 days
find "$BACKUP_DIR" -mtime +14 -delete
Enter fullscreen mode Exit fullscreen mode

Umami's backup story is simpler because it's pure Postgres — no auxiliary config files, no proprietary state. A nightly pg_dump piped to Backblaze B2 via rclone covers the entire restore path. Backblaze B2's S3-compatible API means no custom tooling required, and the restore is a single psql command. On my setup this runs as a cron job on the host, not inside the container, so a crashed container doesn't also kill the backup process.

#!/bin/bash
# umami-backup.sh
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DUMP_FILE="/tmp/umami_$TIMESTAMP.sql.gz"

# pg_dump directly from host; adjust connection string to match your compose env
PGPASSWORD="$UMAMI_DB_PASS" pg_dump \
  -h localhost -p 5432 -U umami_user umami_db | \
  gzip > "$DUMP_FILE"

# rclone must be configured with your B2 credentials: rclone config
rclone copy "$DUMP_FILE" b2:your-bucket-name/umami/
rm "$DUMP_FILE"
Enter fullscreen mode Exit fullscreen mode

Every tool covered here exposes some form of health endpoint — Plausible at /api/health, PostHog at /health, Umami at /api/health, Matomo via its status page. Wire all of them to Uptime Kuma, which runs cleanly in the same Docker network and can hit internal hostnames directly without exposing anything to the internet. The one alert that actually matters beyond basic uptime: Matomo's archiving cron. When it fails, the dashboard shows stale aggregated data with no visible error — users just notice that today's numbers look wrong. Add a cron job that writes a heartbeat file on success, and monitor that file's mtime from Uptime Kuma using its "Keyword" check type or a simple shell monitor.


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)