Post

Simple Self-Hosted Analytics With GoatCounter

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
  • -automigrate handles 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.