Summary
Simple self-hosted analytics for personal websites.
Motivation
When setting up TurtleNet back in 2023 I spun up a Matomo instance to try out some form of analytics for my sites. I quickly found it to be too feature-heavy and complex for my needs; if all I have is a basic counter for how many people visit each page, I’ll be happy!
GoatCounter seems to tick all the boxes for a long-term Matomo replacement: it respects user privacy, can be self-hosted, and is super lightweight.
Backend Configuration
Docker Compose
Here’s my docker-compose.yml
. Note that I explicitly pass in parameters to disable TLS, since I have Goatcounter running behind Caddy.
The port 9880
is arbitrary; I chose it to not collide with any of my other services running on this VM.
GoatCounter has an official Docker image at arp242/goatcounter. It also includes a compose.yaml which you could choose to use instead of writing your own.
version: '3'
services:
goatcounter:
image: arp242/goatcounter:2.6 # Replace 2.6 with your desired version
restart: always
ports:
- 9880:80 # 9880 on my local VM, 80 inside the Docker container
volumes:
- ./goatcounter-data:/home/goatcounter/goatcounter-data
entrypoint: ["/home/goatcounter", "serve", "-listen", ":80", "-tls", "http"]
DNS
My services run behind a reverse proxy using Caddy. This ensures requests coming into my server gets routed to the right location based on the requested subdomain.
I host Goatcounter on zulu
, my VM for everything external-facing, and set the DNS entry goatcounter.bencuan.me
to point to my Caddy instance.
Then, I added this to my Caddyfile
:
goatcounter.bencuan.me {
reverse_proxy zulu.bencuan.me:9880
}
(If you run Caddy on the same VM as Goatcounter, which makes sense for most simple setups, you can replace zulu.bencuan.me
with localhost
.)
Multi-site config
One shortcoming of Goatcounter is that it doesn’t distinguish different top-level domains from one another. So, if I visit bencuan.me/about
once and garden.bencuan.me/about
once, and they both use the same Goatcounter instance, it’ll register as me having visited /about
twice!
To work around this, I host multiple Goatcounter sites on a single instance. This has two steps:
- Go to the Goatcounter control panel, navigate to Settings → Sites, and add a new subdomain (like
gardencounter.bencuan.me
). - Update the DNS to point to the same Goatcounter deployment. For me this looks like adding a second block to my Caddyfile, and updating the DNS record in Cloudflare:
gardencounter.bencuan.me {
reverse_proxy zulu.bencuan.me:9880
}
Adding Goatcounter to your website
To bencuan.me
or any other website: it’s pretty easy, just include the following in your <footer>
. Don’t forget to replace goatcounter.bencuan.me
with your domain!
<script
data-goatcounter="https://goatcounter.bencuan.me/count"
async
src="//goatcounter.bencuan.me/count.js"
></script>
Yay, it works!
To Quartz
Edit quartz.config.ts
to include the following:
analytics: {
provider: "goatcounter",
websiteId: "gardencounter", // your goatcounter subdomain
host: "bencuan.me", // replace with your host
scriptSrc: "https://gardencounter.bencuan.me/count.js", // replace with your host
},
Outdated
Custom docker container
GoatCounter now has their own official Docker image, so none of this is needed anymore. Thanks Martin :D
Despite GoatCounter officially referring us to Hitler Uses Docker and recommending bare-metal, I chose to Dockerize this since the rest of my setup runs on Docker Compose (refer to TurtleNet ep4).
At time of writing all of the existing Dockerfiles were several years old, so I made my own (built on version 2.5). I’ve copied it below, or you can pull it from the Docker repository (64bitpandas/goatcounter).
# Multi-stage build for GoatCounter v2.5
# Build stage
FROM golang:1.21-alpine AS builder
# Install necessary build dependencies
RUN apk add --no-cache git gcc musl-dev
# Set working directory
WORKDIR /build
# Clone the specific release branch
RUN git clone --branch=release-2.5 https://github.com/arp242/goatcounter.git .
# Build a statically linked binary. Tweak the flags a bit to build on alpine.
# https://github.com/mattn/go-sqlite3/issues/1164
RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" \
go build -tags "osusergo,netgo,sqlite_omit_load_extension,linux,musl" \
-ldflags="-extldflags '-static'" \
./cmd/goatcounter
# Final stage
FROM alpine:latest
# Install CA certificates for HTTPS connections
RUN apk add --no-cache ca-certificates sqlite
# Create non-root user for running the application
RUN addgroup -S goatcounter && adduser -S goatcounter -G goatcounter
# Create directory for database files
RUN mkdir -p /data/db && \
chown -R goatcounter:goatcounter /data
# Copy the binary from the builder stage
COPY --from=builder /build/goatcounter /usr/local/bin/
# Set necessary capabilities to bind to privileged ports
RUN apk add --no-cache libcap && \
setcap 'cap_net_bind_service=+ep' /usr/local/bin/goatcounter && \
apk del libcap
# Set up volume for persistent data
VOLUME ["/data"]
# Switch to non-root user
USER goatcounter
# Set working directory
WORKDIR /data
# Expose the default HTTP and HTTPS ports
EXPOSE 80 443
# Run GoatCounter server. The DB lives in /data/db/goatcounter.sqlite3 by default.
ENTRYPOINT ["/usr/local/bin/goatcounter", "serve"]
Troubleshooting
This should be fixed in the latest Goatcounter version.
https://askubuntu.com/questions/1408523/zdb-connect-zdb-requires-sqlite-3-35-0-or-newer
goatcounter: zdb.Connect: zdb requires SQLite 3.35.0 or newer; have ""
This can be misleading error; if sqlite is already installed, it really means a permission issue occurred! Make sure Docker has permission to create the db inside of the mounted volume (may need some combination of chmod -R
and chown
).