SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-hosted web analytics: the VPS options

Plausible, Umami, Matomo, GoatCounter and GoAccess on a small VPS: RAM, database choice, disk growth, reverse proxy setup and the ad blocker gap.

Which self-hosted web analytics tool should you run on a VPS?

Self-hosted web analytics comes in two families, and picking the wrong family costs you more than picking the wrong product. One family runs a small script in the visitor's browser and stores what that script reports. The other family reads the access log your web server already writes. Everything after that, including the database and the memory it needs, follows from that one choice.

The short answer for a small box. GoatCounter and Medama fit on 1 GB, because each is one process over one file. Umami adds a Postgres container and gives you a dashboard a non technical person can read. Plausible Community Edition and Rybbit both run ClickHouse, so plan for 2 GB of RAM or more. Matomo is the full product and asks for a server sized to your traffic. GoAccess adds nothing to the page at all, because it reads a log that already exists.

Script tag or server log: what each one can see

A script tag measures browsers. The page loads, the script runs, and it sends one request to your collector. Anything that breaks that chain is invisible to you: JavaScript turned off, a filter list that blocks the request, a failed request to the collector, or a crawler that never runs scripts.

A log parser measures requests. Your web server writes one line per request whether you install anything or not, so the data is already on disk. It sees every crawler and every hit on a file that carries no script tag. It cannot see what happened inside the browser, and it cannot see a page served from the browser cache or from a CDN (content delivery network) in front of your box, because that request never reached your server.

The two numbers will not match, and neither one is wrong. Matomo can do both, and it documents what the log import gives up next to its JavaScript tracker: screen resolution and page titles, events, content tracking, heatmaps, session recordings and form analytics. That list is the price of counting requests instead of browsers.

Bot traffic is the other half of the gap. Log-based counts include crawlers unless you filter them, and on an ordinary site the crawler share is large enough to change your conclusions. GoAccess and Matomo's log import both filter known bots. Neither one can filter a crawler that lies about its user agent, which is a good reason to pair any log-based counting with blocking AI crawlers at the server and to read the log after the block rather than before it.

GoAccess: analytics from the log you already have

Install it from the project's own Debian and Ubuntu repository, because distribution packages lag behind releases.

wget -O - https://deb.goaccess.io/gnugpg.key | gpg --dearmor | sudo tee /usr/share/keyrings/goaccess.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/goaccess.gpg arch=$(dpkg --print-architecture)] https://deb.goaccess.io/ $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/goaccess.list
sudo apt-get update
sudo apt-get install goaccess

Then point it at the log and write a static report.

goaccess /var/log/nginx/access.log -o ~/report.html --log-format=COMBINED

That command fails with Permission denied for an ordinary user, because on Ubuntu the nginx log is owned by root with group adm. Add yourself to that group with sudo usermod -aG adm $USER, then log out and back in, since group membership is read at login. Run id and check adm appears in the list before you try again.

A report on the live log only covers what logrotate has not moved yet. Yesterday's requests sit in access.log.1 and older ones are compressed, so a weekly view has to read the rotated files as well.

zcat /var/log/nginx/access.log.*.gz | goaccess - --log-format=COMBINED -o ~/last-week.html

There is also a live mode, --real-time-html, which updates the page over a WebSocket. That needs a second port and its own proxy rule. For most sites an hourly report written by cron is enough and there is less to secure.

GoatCounter: one Go binary and one SQLite file

GoatCounter ships as a statically compiled binary, so there is no runtime to install. Take a build from the release page and run it, or use the image.

docker run -p 8080:8080 -v goatcounter-data:/home/goatcounter/goatcounter-data arp242/goatcounter

Run as a binary, goatcounter serve listens on port 8080 and creates SQLite at ./goatcounter-data/db.sqlite3. Create the first site from the command line rather than the web wizard when the instance is already behind a proxy.

goatcounter db create site -vhost=stats.example.com -user.email=me@example.com

It can handle its own certificate with goatcounter serve -listen=:443 -tls=tls,rdr,acme, using ACME (automatic certificate management environment), which is useful on a box that runs nothing else. Where nginx or Caddy already owns port 443, leave GoatCounter on 8080 and proxy to it instead. The tracking script is around 3.5K by the project's own figure, and there is a tracking pixel for pages that carry no JavaScript. If SQLite becomes the limit on a busy site, the same binary takes Postgres with goatcounter serve -db 'postgresql+dbname=goatcounter'. Backups are a file copy, which is the real argument for this shape of tool.

Medama: a single container that claims 256 MB

Medama is the newest single binary option here. It is cookie free by design and the project states a tracker under 1 KB, plus small sites running on virtual machines with 256 MB of memory. Those are the project's published claims, not numbers measured for this guide.

docker volume create medama-data
docker run -d -p 127.0.0.1:8080:8080 -v medama-data:/app/data ghcr.io/medama-io/medama:latest

The official command publishes the port as 8080:8080. The loopback prefix above is deliberate and the reverse proxy section explains why. First login is admin with the password CHANGE_ME_ON_FIRST_LOGIN, and the name of that password is the instruction.

One documented failure mode will catch you. Login works only over HTTPS or on localhost, so if you set up the proxy before the certificate, the form rejects a correct password and the reason is not printed. Finish the TLS (transport layer security) setup first, then log in.

Umami: Postgres, and a dashboard people recognise

git clone https://github.com/umami-software/umami.git
cd umami
docker compose up -d

That starts the application on port 3000 with a PostgreSQL container beside it. The docs give PostgreSQL v12.14 as the minimum, and Node.js 18.18 or newer if you build from source instead. There is a prebuilt image, docker.umami.is/umami-software/umami:postgresql-latest, which needs DATABASE_URL pointing at a database you already run.

The first login is admin with the password umami. Change it before you point DNS at the box, because the instance is reachable from the internet the moment the record resolves and the proxy answers. For the Compose details, environment files and restart policy, see a Docker Compose stack on a VPS rather than copying a stack you have not read.

The footprint is a Node process plus Postgres. That is heavier than a single binary and much lighter than anything running ClickHouse.

Plausible Community Edition: ClickHouse sets the RAM floor

git clone -b v3.2.1 --single-branch https://github.com/plausible/community-edition plausible-ce
cd plausible-ce
touch .env
echo "BASE_URL=https://stats.example.com" >> .env
echo "SECRET_KEY_BASE=$(openssl rand -base64 48)" >> .env
docker compose up -d

Version v3.2.1 is current as of August 2026, and the clone command pins it on purpose. The stack is three parts: the application, Postgres for accounts and settings, and ClickHouse for the event data. SECRET_KEY_BASE has to be at least a 64 byte string, which is what the openssl call produces.

Plausible's own requirements ask for at least 2 GB of RAM so that ClickHouse and the application do not meet the out of memory killer, and a CPU that supports SSE 4.2 or NEON, which ClickHouse needs. That second requirement is worth checking before you buy, and it is one of the practical differences in choosing between an ARM and an x86 VPS. ClickHouse will also take as much memory as it thinks is available, so on a shared box set a ceiling as described in capping container memory in Compose.

BASE_URL must equal the public URL exactly. When it does not, you log in, the app redirects to the wrong host, and the session cookie is written for a domain your browser is not on, so you land back at the login form with no error message.

The shipped compose file does not publish a port, since the expectation is a proxy in front. Add an override that publishes the default application port on loopback only.

cat > compose.override.yml << EOF
services:
    plausible:
        ports:
            - 127.0.0.1:8000:8000
EOF

Matomo: the full product, and the server it asks for

Matomo runs on PHP with MySQL or MariaDB, which means it fits the classic web stack rather than a container stack. It is also the only tool here that publishes hardware guidance by traffic volume.

ChartMatomo sizing guidance by monthly pageviews
The data behind this chart
[
  {
    "label": "100K/month",
    "cpu_cores": 2,
    "ram_gb": 2,
    "disk_gb": 50
  },
  {
    "label": "1M/month",
    "cpu_cores": 4,
    "ram_gb": 8,
    "disk_gb": 250
  },
  {
    "label": "10M/month",
    "cpu_cores": 8,
    "ram_gb": 16,
    "disk_gb": 400
  }
]

Those are Matomo's published minimums as of August 2026, not measurements taken for this guide. Up to 100,000 pageviews a month it asks for 2 CPU cores, 2 GB of RAM and 50 GB of SSD, and one server holds both the application and the database. At 1M/month that becomes 8 GB of RAM and 250 GB of disk. At 10M/month Matomo recommends two servers, and the last row shows the database server: 16 GB of RAM and 400 GB of disk. Read those disk figures next to the single binary options, where the whole dataset is one SQLite file.

Archiving is what surprises people. By default Matomo builds its reports when somebody opens the dashboard, so as the data grows the dashboard gets slower and eventually times out. The documented fix is to turn off browser triggered archiving in the general settings and run the archiver from cron instead, as the user that owns the Matomo files, from the Matomo directory.

php console core:archive --url=https://analytics.example.com

Matomo also keeps raw log tables next to its processed report tables, and it can delete old raw data and old reports on a schedule. Turn that on when you install, not when the disk is full. Matomo can import server access logs as well, which makes it the one product here that covers both families at once.

Rybbit and the newer stacks

git clone https://github.com/rybbit-io/rybbit.git
cd rybbit
chmod +x *.sh
./setup.sh your.domain.name

Rybbit is a recent arrival with a modern dashboard. The setup script writes the environment file and brings up the stack with Docker Compose. It runs ClickHouse, and it ships Caddy as its own web server, which takes port 443 and requests a certificate for the domain you passed in. On a box where nginx already owns 443, the script will not be able to bind, so use the project's manual Compose route and put it behind your existing proxy. The docs state at least 2 GB of RAM, testing on Ubuntu 24 LTS, and ARMv8.2-A or newer on ARM because of ClickHouse.

The honest caveat for any young project: features land quickly and so do breaking changes. Pin a tag, read the release notes before you pull, and take a database backup first.

Retention and disk growth: measure it on your own box

Disk growth depends on what the tool stores per event. GoatCounter aggregates hits into counters, so its file grows with distinct pages and days more than with raw volume. Umami and Matomo store rows per event, and Matomo stores processed report tables on top of the raw ones. ClickHouse stores events in columns and compresses them hard, which is why Plausible copes with volume that would strain a row store.

This guide does not publish a megabytes per million pageviews figure, because it did not measure one on your traffic. Take the reading yourself. Adjust the service and user names to match your own Compose file.

du -h goatcounter-data/db.sqlite3
docker compose exec db psql -U umami -d umami -c "SELECT pg_size_pretty(pg_database_size('umami'));"
docker compose exec plausible_events_db clickhouse-client -q "SELECT formatReadableSize(sum(bytes_on_disk)) FROM system.parts WHERE active"

Record the number, wait a week, record it again, and divide the difference by the pageviews the dashboard reports for that week. That figure is about your site and your bot filtering, so it is worth more than any published average. Then set a retention limit while the number is still small. A full disk takes down every service on the VPS, not only the analytics, which is the strongest argument for keeping the database volume somewhere df -h will warn you about.

How it behaves behind a reverse proxy on a subdomain

Put the collector on a subdomain of the site it measures, such as stats.example.com. That makes the collector request first party, so it is not touched by the browser rules that block third party requests.

Bind the application to loopback when you publish the container port. Docker writes its own firewall rules ahead of ufw, so a container published as -p 3000:3000 is reachable from the internet even while ufw status says the port is denied. Test it from another machine with curl http://SERVER_IP:3000 and you will get the dashboard. Published as -p 127.0.0.1:3000:3000, the same test gives Connection refused and only the proxy can reach it.

server {
    listen 443 ssl;
    server_name stats.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The forwarding headers are not optional here. Without X-Forwarded-For, every visit arrives from 127.0.0.1, so the country report is empty and unique visitors collapse toward one. Each project decides which header it trusts and under what setting, so check its proxy documentation once rather than assuming. Caddy sets those headers itself, and a Caddyfile for the same job is two lines.

stats.example.com {
    reverse_proxy 127.0.0.1:3000
}

If you have not chosen a proxy yet, the comparison of nginx, Caddy and Traefik covers which one suits a single box with a few subdomains.

Self-hosting changes who holds the data. It does not change what the law says about the data. Keep two rules apart. The ePrivacy consent rule is about storing or reading anything on the visitor's device, so a tool that sets no cookie and writes nothing to local storage sits outside that specific requirement. GDPR is about processing personal data, and an IP address counts as personal data, so you still need a lawful basis, a retention limit and an answer when somebody asks what you hold about them.

Plausible, Umami, GoatCounter and Medama set no cookie by default. What each one derives instead differs per project and changes between versions, so read the project's own privacy documentation rather than a summary. Matomo ships IP anonymisation and an opt out endpoint you enable in the admin interface.

Regulators reach different conclusions in different countries. France's CNIL, for example, publishes conditions under which audience measurement can be exempt from consent. This section is a factual summary and not legal advice. For a real site with real users, ask a lawyer in your jurisdiction.

One point that people miss: an access log is personal data too. GoAccess adds no script to the page and still processes IP addresses, so log-based analytics is not automatically outside the rules.

Ad blockers, and why your numbers will drop

Filter lists match on hostname and on URL pattern. A hosted analytics product is easy to match, because everybody loads it from the same well known hostname. Moving the collector to your own subdomain removes that hostname from the request, and serving the script from a path you chose removes the well known filename. Both change what a list has to match on.

This post does not assert a hit rate, because it did not measure one. The share of visitors who block any given setup depends on your audience, and a developer audience blocks far more than a general one. Measure your own gap instead. Over the same week, count requests for HTML pages in the access log with GoAccess, and compare that with the pageviews your script based tool reports. The difference is blocked visits plus pages served from cache, on your site.

Expect the totals to move on the day you switch from a hosted product, and expect part of that move to have nothing to do with blocking. Products disagree about what a pageview is, whether a route change inside a single page application counts as one, and when a session ends. Compare trends across weeks before you conclude that traffic fell.

Which one for which site

  • A personal site or a blog under roughly 50,000 pageviews a month: GoatCounter or Medama, on a 1 GB VPS, with backups that are a file copy.
  • A site where you cannot add a script, or an audience that blocks heavily: GoAccess over the existing log, on a schedule.
  • A small business site where somebody else reads the dashboard: Umami, with its Postgres container.
  • A site where you want goals and funnels, on a box with 2 GB of RAM or more: Plausible Community Edition, or Rybbit if you want the newer dashboard and accept a younger project.
  • Many sites, many user accounts, or a requirement to keep raw data under your own retention policy: Matomo, sized from its published guidance above.

Start with the smallest tool that answers your actual question. Moving from GoatCounter to Plausible later costs you a subdomain and some history. Moving from Matomo to anything else costs you a migration you will not enjoy. If you are still deciding what else belongs on the same box, the wider self-hosting roundup covers what fits next to it, and if what you really want is request level tracing of an application rather than visitor counts, a self-hosted observability service is the tool for that job instead.

FAQ

No, and the two questions are separate. The consent rule under ePrivacy covers storing or reading something on the visitor's device, so a tool that sets no cookie and writes nothing to local storage falls outside that particular requirement. GDPR is a different rule and covers processing personal data, and an IP address is personal data, so you still need a lawful basis and a retention limit even with no cookie. Self-hosting moves the data onto your server and makes you the party responsible for it. Check your own regulator's guidance and ask a lawyer for your case.

How much RAM does self-hosted analytics need on a VPS?

The datastore decides, not the dashboard. GoatCounter and Medama run as one process over one file, and Medama's documentation says small sites run on machines with 256 MB. Umami adds a Postgres container next to a Node application. Plausible Community Edition and Rybbit both run ClickHouse, and both projects state at least 2 GB. Matomo's own guidance starts at 2 CPU cores and 2 GB of RAM for up to 100,000 pageviews a month.

Why are my self-hosted numbers lower than the analytics I replaced?

Two causes, and both are real. Filter lists block some collector requests, so every script based tool loses those visits. The products also count differently, since what counts as a pageview and when a session ends vary between them. Compare one week of HTML page requests from your access log against the same week of script based pageviews. That gap is blocked visits plus cached pages, measured on your own site rather than borrowed from somebody's published rate.

Can I run Plausible or Rybbit on an ARM VPS?

Both run ClickHouse, and ClickHouse needs SSE 4.2 on x86 or NEON on ARM. Plausible's requirements say exactly that, and Rybbit's docs say ARM systems need ARMv8.2-A or newer. Current ARM server cores meet that bar and older ones do not, and the failure appears as ClickHouse refusing to start with an instruction set error rather than as anything in the application log. On a small ARM box, the single file tools avoid the question, because none of them run ClickHouse.

Should I parse server logs instead of using a tracking script?

Use log parsing when you cannot add a script, when your audience blocks heavily, or when you want a count that includes crawlers. GoAccess reads a log your server already writes, so it adds no page weight and no database. You give up everything that happens inside the browser, and you miss any page served from a CDN or from browser cache, because that request never reached your server. Plenty of sites run both and treat them as two different measurements.