Simple Self-Hosted Analytics With GoatCounter
Recently I saw some interesting traffic in some of my engineering posts. Since I’m not using any analytics, besides my Nginx-Based Web Analytics script, I was wondering if the traffic reading was actually working well and if I could get some more data on the visits. Still, I didn’t want to invade user privacy or profile users, and I wanted to keep this very minimalistic. I thought about writing some custom JavaScript and building an endpoint to process data, but before going that way I decided to look around and see what is currently out there.
Besides wanting something privacy-respecting, I also wanted to own the data, so I was looking for something self-hosted. I was surprised to find plenty of analytics tools out there that respect this philosophy. Some of the best I found were Matomo, Plausible, Umami, and Rybbit. These all seem pretty cool, powerful, and very aligned with what I would want in an analytics tool. However, for this particular blog, I wanted something simple and minimalistic that could easily run in the same container as the blog. Out of everything I saw, I went with GoatCounter.
GoatCounter is a privacy-focused analytics platform that runs as a single binary It is extremely lightweight, so I decided to give it a try.
Setup
I host my Jekyll blog in an Ubuntu Server 24 container running on my server cluster. Below are the steps I used to install GoatCounter in the same container, running in parallel as a systemd service.
Since my blog has low traffic, I went with a simple SQLite database. For higher-traffic blogs, PostgreSQL would be recommended, and GoatCounter does support it.
Installing GoatCounter
GoatCounter is distributed as a single static binary, which simplifies deployment significantly.
Download the latest release:
1
wget https://github.com/arp242/goatcounter/releases/download/v2.7.0/goatcounter-v2.7.0-linux-amd64.gz
Unpack it:
1
gunzip goatcounter-v2.7.0-linux-amd64.gz
Install it to a standard location:
1
sudo install -m 0755 goatcounter-v2.7.0-linux-amd64 /opt/goatcounter/goatcounter
At this point, GoatCounter is ready to run.
Creating a Service User
Running services as root is unnecessary and unsafe. Create a dedicated system user:
1
sudo adduser --system --group --home /var/lib/goatcounter goatcounter
Prepare required directories:
1
2
3
sudo mkdir -p /opt/goatcounter
sudo mkdir -p /etc/goatcounter
sudo mkdir -p /var/lib/goatcounter
Assign ownership of the data directory:
1
sudo chown -R goatcounter:goatcounter /var/lib/goatcounter
This directory will hold the SQLite database.
Running GoatCounter with systemd
To ensure GoatCounter starts automatically and restarts on failure, create a systemd unit file:
/etc/systemd/system/goatcounter.service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[Unit]
Description=GoatCounter Analytics
After=network.target
[Service]
Type=simple
User=goatcounter
Group=goatcounter
WorkingDirectory=/var/lib/goatcounter
ExecStart=/opt/goatcounter/goatcounter serve \
-listen 127.0.0.1:8081 \
-db sqlite+/var/lib/goatcounter/db.sqlite3 \
-automigrate
Restart=on-failure
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
LimitNOFILE=4096
[Install]
WantedBy=multi-user.target
Key design choices:
- Bind to
127.0.0.1→ not publicly exposed - nginx acts as reverse proxy
- SQLite avoids external database dependencies
-automigratehandles schema updates automatically
Enable and start the service:
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now goatcounter
Verify:
1
sudo systemctl status goatcounter
Initializing the Analytics Site
Create the database, register the website domain, and create an admin user:
1
2
3
4
sudo -u goatcounter /opt/goatcounter/goatcounter db create site \
-db sqlite+/var/lib/goatcounter/db.sqlite3 \
-vhost stats.example.com \
-user.email admin@example.com
Exposing GoatCounter via nginx
Since GoatCounter runs locally, nginx needs to proxy requests.
Create a dedicated subdomain:
1
stats.example.com
Example basic nginx configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
server_name stats.example.com;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
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;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/stats.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stats.example.com/privkey.pem;
}
Obtain TLS certificates:
1
sudo certbot --nginx -d stats.example.com
Avoiding Ad-Blockers
I saw something mentioning that many ad-block lists block common analytics paths like:
1
/count.js
To reduce blocking, expose alternative public endpoints and proxy internally:
1
2
3
4
5
6
7
location = /metrics.js {
proxy_pass http://127.0.0.1:8081/count.js;
}
location = /metrics {
proxy_pass http://127.0.0.1:8081/count;
}
This keeps GoatCounter unchanged while making tracking less obvious, although technically you may be bypassing some explicit user choices.
Restricting Dashboard Access
The tracking endpoint must remain public, but the admin interface doesn’t need to be. Depending on your choice, you can keep it public or restrict access with some basic nginx configurations:
1
2
3
4
5
6
7
8
location / {
allow 127.0.0.1;
allow 10.1.0.0/16; # Example network range
deny all;
proxy_pass http://127.0.0.1:8081;
...
}
Personally, I chose to restrict access to mine. The example above allows:
- local access
- WireGuard clients
Everyone else receives a 403 Forbidden.
Note that this needs to be on a separate server {} block dedicated to internal traffic, and you need to deny access on the external traffic while opening exceptions to the public endpoints. Example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# External Traffic
server {
listen PUBLIC_IP:443 ssl http2;
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;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Cheap hardening
server_tokens off;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Public tracking endpoint
location = /metrics {
# Optional hygiene filter: only accept requests referred by your own site(s)
valid_referers none blocked server_names
example.com
*.example.com;
if ($invalid_referer) {
return 403;
}
proxy_pass http://127.0.0.1:8081/count;
proxy_http_version 1.1;
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;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# Public tracking script
location = /metrics.js {
valid_referers none blocked server_names
example.com
*.example.com;
if ($invalid_referer) {
return 403;
}
proxy_pass http://127.0.0.1:8081/count.js;
proxy_http_version 1.1;
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;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
location / {
return 403;
}
}
# Internal Traffic
server {
listen INTERNAL_IP:443 ssl http2;
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;
server_tokens off;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location / {
allow 10.1.0.0/16;
allow 127.0.0.1;
deny all;
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
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;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
}
Split DNS with OPNsense
Because the dashboard is restricted, internal users need proper DNS resolution. Since I use OPNsense, I can configure split DNS like this, but the same idea applies to any internal DNS resolver.
Configure a host override in OPNsense (Unbound):
- Host:
stats - Domain:
example.com - IP: internal nginx VM
Result:
- internal clients → internal IP
- external clients → public IP
This avoids hairpin NAT issues and ensures WireGuard users can access the dashboard cleanly.
Of course, you can always skip this step entirely and just use the IP address directly.
Integrating with Jekyll
Integrating with Jekyll is super simple. Just add the GoatCounter tracking script to your site layout:
1
2
3
4
5
<script
data-goatcounter="https://stats.example.com/metrics"
async
src="https://stats.example.com/metrics.js">
</script>
After redeploying your site, page views will start appearing in the dashboard.
Final Architecture
The final stack is intentionally minimal:
- nginx
- GoatCounter
- SQLite
No containers, no external services, and almost no operational overhead.
Conclusion
GoatCounter is probably the most minimal of the analytics solutions I found, but it does exactly what I needed. In about 30 minutes, I get a very lightweight system that can track page views, unique visitors, locations, languages, browser, screen dimensions, and referrals. All self-hosted, with zero data sent to third parties, and minimal profiling without cross-site tracking. A sweet spot between privacy, simplicity, and usefulness.
While I don’t intend to use it outside of my Jekyll blog, the setup is so simple that it could easily be compiled into a small Ansible script for replication.
And one thing that really stood out to me is how many strong analytics tools now exist that allow you to have self-hosted, privacy-respecting analytics. I will definitely explore some of them for use in my web applications. GoatCounter is very basic, but some of the others, like Matomo and Umami, offer more powerful tracking options and are definitely worth exploring.