Skip to content

How I got GoAccess to actually remember things; A self-hosted traffic dashboard that survives reboots

Image

The pain of pipe problems, empty databases, and the stubborn joy of owning your own data.

Why I even bothered

I run a personal tech blog, built with MkDocs Material and hosted on a TransIP VPS running Alpine Linux (not officially supported by them but that's another story). Like most self-hosters, I've got a small army of Docker containers doing their thing: Redmine, InvoicePlane, Uptime Kuma, Flatnotes, you name it.

At some point I started wondering: does anyone actually read this thing?

Not just for vanity reasons, but because measuring is knowing. If nobody's reading a certain post, maybe I should write differently. If a particular page has a spike in traffic, maybe something got shared somewhere. Basic stuff; but you can't improve what you don't measure.

Now, I had a few options:

  • Google Analytics — works, but Google gets your data, your visitors' data, and probably your coffee preferences. Hard pass.
  • Cloudflare Analytics — zero server load, but your data lives at Cloudflare (a US company). Not bad, but not mine.
  • GoatCounter — lovely little Go binary, super light. Worth considering.
  • Umami — nice UI, Node.js + Postgres. ~250 MB RAM idle. Fine, but heavier than I wanted.
  • GoAccess — C program, parses your existing Nginx logs, nearly zero extra CPU/RAM, fully server-side, no tracking script, no cookie consent banner needed. This was the one.

The philosophy here is simple: my VPS already writes access logs. Why involve a third party, paid or otherwise, when I can just... read those logs myself? GoAccess does exactly that. It's like having a very fast, very quiet accountant who works for free and never calls your data home.

So I went with GoAccess in Docker, with a real-time HTML dashboard over WebSocket, served at https://stats.example.com. What followed was a debugging journey that I'm now writing up so you don't have to suffer the same way.


The setup

My environment:

  • VPS: TransIP, Alpine Linux
  • Nginx: reverse proxy for everything, logs at /var/log/nginx/access.log
  • Docker Compose: all services containerized
  • GoAccess image: allinurl/goaccess:latest
  • Goal: persistent, real-time dashboard that survives reboots

The directory structure I used:

~/goaccess/
├── docker-compose.yml
├── config/
│   └── goaccess.conf
├── output/          # report.html lands here
└── db/              # GoAccess persistence database

Part 1: Installing GoAccess on Alpine Linux

Before containerizing anything, it's useful to verify GoAccess works at all on your system. On Alpine Linux, the package is available directly from the apk repository.

Install GoAccess

apk update
apk add goaccess

Verify the install:

goaccess --version

You should see something like GoAccess - 1.x.x. If you need a newer version than what apk provides, you can compile from source but the apk version is more than sufficient for our purposes.

Quick sanity check

Before touching Docker, verify that GoAccess can actually parse your logs:

goaccess /var/log/nginx/access.log \
  --log-format=COMBINED \
  --output=/tmp/test.html

If this generates a non-empty test.html, your logs are readable. If it outputs 0 requests, there's a log format mismatch which we'll address in detail below.


Part 2: DNS and SSL setup

We want GoAccess accessible at https://stats.example.com. Before configuring Nginx or GoAccess, we need a DNS record and a valid SSL certificate.

Step 1: Create the DNS record

In your DNS provider's control panel, add an A record:

Type:  A
Name:  stats
Value: <your VPS IP address>
TTL:   300

Wait for propagation (usually a few minutes, up to an hour). Verify with:

dig stats.example.com +short
# Should return your VPS IP

Step 2: Create the HTTP (port 80) vhost

Certbot needs a running Nginx with a server block for your domain before it can validate ownership. Create a minimal vhost first:

/etc/nginx/conf.d/stats.example.com.conf

server {
    listen 80;
    server_name stats.example.com;
}

Reload Nginx:

nginx -t && nginx -s reload

Verify the vhost is reachable:

curl -I http://stats.example.com
# Should return HTTP/1.1 200

Step 3: Request the SSL certificate with Certbot

Install Certbot if you haven't already:

apk add certbot certbot-nginx

Request the certificate using the --nginx authenticator; no webroot directory needed. Certbot temporarily adjusts the Nginx config to handle the ACME challenge and cleans up after itself:

certbot certonly --nginx \
  -d stats.example.com \
  --email your@email.com \
  --agree-tos \
  --non-interactive

If successful, your certificates will be at:

/etc/letsencrypt/live/stats.example.com/fullchain.pem
/etc/letsencrypt/live/stats.example.com/privkey.pem

Step 4: Upgrade the vhost to HTTPS

Now replace the port 80 vhost with the full SSL configuration:

/etc/nginx/conf.d/stats.example.com.conf

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name stats.example.com;
    return 301 https://$host$request_uri;
}

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

    ssl_certificate     /etc/letsencrypt/live/stats.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/stats.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    # Optional: restrict to your own IPs only
    # allow 1.2.3.4;
    # deny all;

    root /root/goaccess/output;
    index report.html;

    location / {
        try_files $uri $uri/ /report.html;
    }

    # WebSocket proxy to GoAccess
    location /ws {
        proxy_pass http://127.0.0.1:7890;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
    }
}

Note on /root permissions: If you're running GoAccess files under /root/goaccess/, Nginx (which runs as user nginx) can't traverse /root by default. The minimal fix without moving everything:

chmod o+x /root
chmod o+x /root/goaccess
chmod -R o+rx /root/goaccess/output
This gives Nginx just enough execute permission to reach the files without exposing the rest of /root. Alternatively, move your goaccess/ directory to /home/user/goaccess/ to avoid this entirely.

Reload Nginx:

nginx -t && nginx -s reload

Test HTTPS:

curl -I https://stats.example.com
# Should return HTTP/2 200

Part 3: The GoAccess Config

~/goaccess/config/goaccess.conf

# Log format for standard Nginx combined log
log-format %h %^ %^ [%x] "%r" %s %b "%R" "%u" "%^"

# Combined date-time parsing including timezone
datetime-format %d/%b/%Y:%H:%M:%S %z

# Real-time HTML dashboard
real-time-html true
output /output/report.html
ws-url wss://stats.example.com/ws
port 443
addr 0.0.0.0

# Persistence
persist true
restore true
db-path /goaccess/db/

# Timezone
tz Europe/Amsterdam

ws-auth false
ignore-crawlers true

Why %^ instead of %u or %l?

This tripped me up for a while. The textbook Nginx combined format is:

$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"

But my Nginx was logging an extra field at the end — $proxy_add_x_forwarded_for — giving me log lines like this:

203.0.113.42 - - [18/Feb/2026:14:05:48 +0100] "GET / HTTP/2.0" 404 1140 "-" "Mozilla/5.0" "-"

That trailing "-" isn't in the standard format. GoAccess couldn't match the line and silently discarded every single request. Zero hits. Empty database. The kind of failure that makes you question your life choices.

In GoAccess config files, %^ means "skip this field — I don't care about it." So the correct format for my logs became:

log-format %h %^ %^ [%x] "%r" %s %b "%R" "%u" "%^"

Fields breakdown: - %h — client IP - %^ — skip the - (ident) - %^ — skip the - (auth user) - [%x] — full datetime - "%r" — request line - %s — HTTP status - %b — bytes sent - "%R" — referer - "%u" — user agent - "%^" — skip the extra forwarded-for field

Important: in a config file, the quotes around format specifiers are literal characters — no backslashes needed. "%r" is correct; \"%r\" is not.

To check what your own logs look like before configuring anything:

tail -3 /var/log/nginx/access.log

Count the fields and adjust %^ accordingly.


Part 4: The Docker Compose

~/goaccess/docker-compose.yml

services:
  goaccess:
    image: allinurl/goaccess:latest
    container_name: goaccess
    restart: unless-stopped
    entrypoint: >
      goaccess /var/log/nginx/access.log
      -p /etc/goaccess/goaccess.conf
      --persist
      --restore
    ports:
      - "127.0.0.1:7890:443"
    volumes:
      - /var/log/nginx:/var/log/nginx:ro
      - ./output:/output:rw
      - ./db:/goaccess/db:rw
      - ./config/goaccess.conf:/etc/goaccess/goaccess.conf:ro
    networks:
      - backend
      - host-bridge

networks:
  backend:
    driver: bridge
    internal: true
  host-bridge:
    driver: bridge

Create the required directories before first start:

mkdir -p ~/goaccess/config ~/goaccess/output ~/goaccess/db

Start the container:

cd ~/goaccess
docker compose up -d

Verify it's running:

docker ps | grep goaccess
docker logs goaccess 2>&1 | tail -10

You should see WebSocket server ready to accept new client connections in the logs.


Part 5: The pipe problem (the real story)

This is the part that cost me the most time, and the main reason I'm writing this post.

When I first set up GoAccess in Docker, I used the classic pipe approach you'll find in most tutorials:

entrypoint: /bin/sh -c "tail -F /var/log/nginx/access.log* | grep -v '^==>' | exec goaccess -p /etc/goaccess/goaccess.conf -"

The logic seemed reasonable: tail -F follows the log file as it grows, grep removes the ==> filename <== headers that tail injects when following multiple files, and GoAccess reads from stdin.

The problem? GoAccess only writes to the persistence database when it reads directly from a file, not from stdin.

When reading from stdin (pipe mode), GoAccess processes data in memory and never touches the database. So even with persist true and a correctly mounted ./db volume, the database directory stayed completely empty after every run:

core:~/goaccess# ls -la ~/goaccess/db/
total 8
drwxr-xr-x    2 root     root          4096 Feb 18 11:44 .
drwx-----x    5 root     root          4096 Feb 18 11:45 ..

Two directories, zero files. Every restart was a fresh start. Like a goldfish with a VPS.

How did I find this? By testing GoAccess directly inside the running container, bypassing the pipe entirely:

docker exec goaccess sh -c "goaccess /var/log/nginx/access.log \
  -p /tmp/test.conf \
  --no-global-config \
  --output=/tmp/test4.html 2>&1"

Result:

access.log          11892   11892/s  -
access.log          67810   22603/s  \Cleaning up resources...

67,810 requests processed. No invalid lines. Database files appeared immediately.

The pipe was the culprit. Switching to direct file input fixed everything.


Part 6: Verifying persistence

After fixing the entrypoint, the db/ directory filled up with GoAccess database files:

-rw-r--r--    1 root     root         53330 Feb 18 14:21 SI32_UNIQUE_KEYS.db
-rw-r--r--    1 root     root          4156 Feb 18 14:21 IGLP_LAST_PARSE.db
-rw-r--r--    1 root     root         80993 Feb 18 14:21 IS32_REQUESTS_MTRC_DATAMAP.db
... (170+ files total)

After a docker compose restart, the timestamps on all files updated and GoAccess started up with historical data intact. Persistence confirmed.


Part 7: Starting fresh (wiping the database)

At some point I wanted to reset everything and start counting from zero, the existing access.log contained weeks of data from before the site was properly set up, and the statistics were noisy.

The naive approach, just deleting the database, doesn't work, because GoAccess will simply re-parse the existing access.log and rebuild the same statistics from scratch. The log file is the source of truth.

The correct procedure to get a genuinely clean start:

# 1. Stop GoAccess
docker compose down

# 2. Wipe the database
rm -rf ~/goaccess/db/*

# 3. Rotate the Nginx log (creates a fresh empty access.log)
logrotate -f /etc/logrotate.d/nginx

# 4. Start GoAccess again
docker compose up -d

Steps 2 and 3 must both happen. Skip step 3 and GoAccess will just re-read the old log. Skip step 2 and the old database gets merged with new data. Both gone, both fresh; that's the only way to start clean.

Note: logrotate -f may throw a warning about the postrotate script if Nginx can't immediately reopen its log file. This is usually harmless, verify with ls -la /var/log/nginx/ that access.log is now small or empty before starting GoAccess.


What I learned (summary)

Problem Root cause Fix
Empty database after every restart Pipe/stdin mode doesn't write to DB Switch to direct file input
Zero requests parsed Extra field in Nginx log format Add "%^" at end of log-format
persist/restore not working Config alone wasn't enough Add --persist --restore to entrypoint CLI flags
Stats not resetting properly Old access.log gets re-parsed Rotate logs AND wipe DB together
WebSocket not connecting Missing Nginx upgrade headers Add Upgrade + Connection headers to proxy block
Nginx permission denied on /root Nginx can't traverse /root by default chmod o+x /root /root/goaccess
Problem Root cause Fix

Why self-hosted analytics?

Because measuring is knowing, and I want to be the one doing the measuring.

Google Analytics gives you excellent data; and takes your visitors' data in return. Cloudflare Analytics is privacy-friendly but your data lives on someone else's servers, subject to their terms, their uptime, and their business decisions. Even "free" services have a price; you just don't always see the invoice.

GoAccess reads logs that Nginx is already writing. There's no new service to maintain beyond a lightweight container, no JavaScript on the client, no cookies, no consent banner needed, and the data never leaves your server. It's the analytics equivalent of reading your own mail instead of having someone else summarize it for you, and then sell the summary.

The resource footprint is minimal: GoAccess uses roughly 10–50 MB of RAM while running, processes large log files in seconds thanks to its C-based in-memory hash tables, and the database on disk stays in the low megabytes for typical personal site traffic.

For anyone running a self-hosted site: if your web server is already logging requests, you already have everything you need. You're just not reading it yet.


Final working configuration

~/goaccess/config/goaccess.conf

log-format %h %^ %^ [%x] "%r" %s %b "%R" "%u" "%^"
datetime-format %d/%b/%Y:%H:%M:%S %z
real-time-html true
output /output/report.html
ws-url wss://stats.example.com/ws
port 443
addr 0.0.0.0
persist true
restore true
db-path /goaccess/db/
tz Europe/Amsterdam
ws-auth false
ignore-crawlers true

~/goaccess/docker-compose.yml

services:
  goaccess:
    image: allinurl/goaccess:latest
    container_name: goaccess
    restart: unless-stopped
    entrypoint: >
      goaccess /var/log/nginx/access.log
      -p /etc/goaccess/goaccess.conf
      --persist
      --restore
    ports:
      - "127.0.0.1:7890:443"
    volumes:
      - /var/log/nginx:/var/log/nginx:ro
      - ./output:/output:rw
      - ./db:/goaccess/db:rw
      - ./config/goaccess.conf:/etc/goaccess/goaccess.conf:ro
    networks:
      - backend
      - host-bridge

networks:
  backend:
    driver: bridge
    internal: true
  host-bridge:
    driver: bridge

/etc/nginx/conf.d/stats.example.com.conf

server {
    listen 80;
    server_name stats.example.com;
    return 301 https://$host$request_uri;
}

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

    ssl_certificate     /etc/letsencrypt/live/stats.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/stats.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    root /root/goaccess/output;
    index report.html;

    location / {
        try_files $uri $uri/ /report.html;
    }

    location /ws {
        proxy_pass http://127.0.0.1:7890;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
    }
}

One last thing

If you've made it this far: the dashboard works, the data persists, and every time the server restarts the history is still there. It took longer than it should have, involved more docker compose down && docker compose up -d cycles than I care to admit, and at one point I was writing test configs directly into a running container just to isolate a shell escaping issue.

But it's mine. Every request, every visitor, every 404 from a bot trying to find a WordPress install that doesn't exist; all of it, sitting quietly in a database on my own disk.

If you're running a self-hosted site and want real analytics without handing your data to anyone, I hope this saves you a few hours. Or at least makes you feel better about your own debugging sessions.

Happy self-hosting (eventually!)