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

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
/rootpermissions: If you're running GoAccess files under/root/goaccess/, Nginx (which runs as usernginx) can't traverse/rootby default. The minimal fix without moving everything:This gives Nginx just enough execute permission to reach the files without exposing the rest ofchmod o+x /root chmod o+x /root/goaccess chmod -R o+rx /root/goaccess/output/root. Alternatively, move yourgoaccess/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 -fmay throw a warning about the postrotate script if Nginx can't immediately reopen its log file. This is usually harmless, verify withls -la /var/log/nginx/thataccess.logis 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!)