Why GoatCounter
For a static site, analytics options generally split into two camps: log parsers that mine web-server logs in the background, and active tools that load JavaScript in the visitor's browser to report back. Log parsing avoids extra client-side requests, but it means running a continual ingestion pipeline against Apache logs that go back months or longer. That setup complexity tipped the balance toward active analytics.
Among the JavaScript-based tools, GoatCounter stood out. Its privacy stance is strong enough that the project claims a GDPR notice isn't required. The client-side script weighs only 8 KiB, and the server responds quickly — 99.9% of requests complete in about 2 ms. Since it's open source and written in Go, it compiles to a static binary that is straightforward to download and run, easing deployment compared to runtime-dependent alternatives. Self-hosting also removes another third-party dependency from the site.
Installing and proxying
The instance here runs on the same VPS as the blog, served at https://stats.thegreenplace.net. The blog domain is handled by Apache on ports 80 and 443, so Apache needed a reverse-proxy configuration to route traffic for the stats subdomain to the GoatCounter server.
Start by downloading a pre-built goatcounter executable from the project's releases page and unzipping it into a directory, say $GOATDIR:
$ ./goatcounter-v2.4.1-linux-amd64 db -createdb create site \
-vhost=stats.thegreenplace.net [email protected]
This command initializes a SQLite database at $GOATDIR/db/goatcounter.sqlite3. Next, launch the server:
$ ./goatcounter-v2.4.1-linux-amd64 serve \
db sqlite3+$GOATDIR/db/goatcounter.sqlite3 \
-listen 127.0.0.1:5000 -tls http
The server binds to local port 5000, which is not exposed outside the VPS. Apache then proxies the stats subdomain to that port. Once that reverse proxy is in place, the subdomain should be reachable.
Running as a service with backups
A reboot should not require manually restarting the analytics server. Add a systemd unit file at /etc/systemd/system/goatcounter.service:
# /etc/systemd/system/goatcounter.service # Description of what the program does [Unit] Description=GoatCounter [Service] Type=simple # If anything unexpected happens, Systemd will try to restart the program Restart=always # We need to send the absolute path of the database to GoatCounter. ExecStart=$GOATDIR/goatcounter-v2.4.1-linux-amd64 serve -db sqlite3+$GOATDIR/db/goatcounter.sqlite3 -listen 127.0.0.1:5000 -tls http [Install] WantedBy=multi-user.target
Enable and start it with systemctl:
$ sudo systemctl start goatcounter
$ sudo systemctl status goatcounter
● goatcounter.service - GoatCounter
Loaded: loaded (/etc/systemd/system/goatcounter.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2023-02-16 14:04:20 UTC; 1 week 1 day ago
Main PID: 732 (goatcounter-v2.)
Tasks: 16 (limit: 2324)
Memory: 135.9M
CPU: 15min 156ms
...
...
$ sudo systemctl enable goatcounter
Once traffic begins flowing, the SQLite database file updates constantly. It can be inspected directly with the sqlite3 command-line client:
sqlite> select * from hits order by created_at desc limit 10;
Backups matter. A cron job uploads a copy to cloud storage, first asking SQLite to produce a consistent backup file:
$ sqlite3 goatcounter.sqlite3 ".backup $BKPFILE"
Here $BKPFILE is any chosen filename.
Navigation and the API
The web dashboard is the primary interface once the instance is running. A Help link in the top-right corner points to thorough documentation. The same goatcounter binary also offers a dashboard command that renders a view in the terminal.
The REST API deserves special mention; its reference is documented in detail. With a token generated from the dashboard, the API can produce aggregates. For example, fetching the most popular pages during 2022 looks like this:
$ curl -X GET 'https://stats.thegreenplace.net/api/v0/stats/hits?start=2022-01-01&end=2022-12-31&daily=1' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <TOKEN-HERE>'
The output JSON can then be processed programmatically as needed.



