Build a Simple Analytics Platform

Build and deploy a privacy-friendly web analytics platform with Go, PostgreSQL and a tiny tracking pixel: page views, referrers, unique visitors and a JSON dashboard, with no third-party cookies.

Build a small Plausible-style analytics service: a tracking script, an ingestion endpoint that writes to PostgreSQL, and a dashboard API for page views, referrers and unique visitors. No third-party cookies, no GDPR consent banner required. Verified 2026-08-31.

What we’re building

The finished MVP has:

  • A tiny first-party tracking script (script.js) served from your own domain.
  • A /event ingestion endpoint that records page views from a POST with origin, path, referrer and a daily visitor hash.
  • A per-site API key so multiple sites can share one instance.
  • Aggregation endpoints for total views, unique visitors, top pages and top referrers over a date range.
  • A JSON dashboard API suitable for a small admin UI or CLI.
  • Docker Compose deployment with Caddy for HTTPS.

This is not a replacement for PostHog. It is a self-hostable analytics backend for a handful of sites where you want to own the data and avoid the consent banner that third-party cookies trigger.

flowchart LR
    Visitor -->|script.js| Site[Customer website]
    Visitor -->|POST /event| API[Go API]
    API --> DB[(PostgreSQL)]
    Owner -->|GET /api/stats| API

What you’ll learn

  • How to build a privacy-friendly tracker that uses no cookies and no fingerprinting.
  • How to hash a daily visitor identifier so you can count uniques without storing a user ID.
  • How to model rollups in PostgreSQL without a separate analytics warehouse.
  • How to validate and rate-limit an ingestion endpoint that is exposed to the public internet.
  • How to deploy the service with Docker Compose and Caddy on a small VPS.

Prerequisites

  • Go 1.24.
  • Docker Engine and Compose v2.
  • A PostgreSQL 17 instance, supplied locally by Compose or remotely.
  • A VPS with 1 GB RAM and a public DNS record for production.

Project overview

The tracker is a ~1 KB script that runs on the customer’s site. On each page load it sends a POST /event to your API with the page path, referrer and a visitor hash derived from IP + User-Agent + date. The API authenticates the site by API key, validates the payload, and inserts a row into page_views. The dashboard endpoints aggregate rows with SQL GROUP BY and return JSON. There is no background worker: all reads and writes are synchronous against PostgreSQL.

Project structure

analytics/
├── cmd/server/main.go
├── internal/api/api.go
├── internal/db/db.go
├── internal/tracker/script.js
├── migrations/0001_init.sql
├── Dockerfile
├── Caddyfile
├── docker-compose.yml
├── go.mod
└── .env.example

1. Create the project

mkdir analytics && cd analytics
go mod init example.com/analytics
go get github.com/jackc/pgx/v5
mkdir -p cmd/server internal/api internal/db internal/tracker migrations

Replace go.mod with:

module example.com/analytics

go 1.24

require github.com/jackc/pgx/v5 v5.7.4

2. Create the database schema

Add migrations/0001_init.sql:

CREATE TABLE IF NOT EXISTS sites (
    id BIGSERIAL PRIMARY KEY,
    api_key TEXT NOT NULL UNIQUE CHECK (length(api_key) = 32),
    domain TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS page_views (
    id BIGSERIAL PRIMARY KEY,
    site_id BIGINT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
    viewed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    path TEXT NOT NULL CHECK (length(path) BETWEEN 1 AND 2000),
    referrer TEXT NOT NULL DEFAULT '',
    visitor_hash TEXT NOT NULL CHECK (length(visitor_hash) = 64)
);

CREATE INDEX IF NOT EXISTS page_views_site_time_idx ON page_views (site_id, viewed_at DESC);
CREATE INDEX IF NOT EXISTS page_views_site_path_idx ON page_views (site_id, path);
CREATE INDEX IF NOT EXISTS page_views_site_referrer_idx ON page_views (site_id, referrer);

Apply it locally:

psql "$DATABASE_URL" -f migrations/0001_init.sql

Production migrations should run as a reviewed release step, before the new application starts accepting traffic. Never reset the production database to apply this migration.

3. Configure the service

Add .env.example:

DATABASE_URL=postgres://analytics:change_me@db:5432/analytics?sslmode=disable
PORT=8080
INGEST_RATE_LIMIT_PER_MINUTE=120

Copy it for local development and replace change_me:

cp .env.example .env

INGEST_RATE_LIMIT_PER_MINUTE caps how many events a single IP can send per minute. The default of 120 is generous for a real visitor and tight enough to make a simple flooding attack expensive.

4. Connect to PostgreSQL

Add internal/db/db.go:

package db

import (
    "context"
    "fmt"
    "os"

    "github.com/jackc/pgx/v5/pgxpool"
)

func Open(ctx context.Context) (*pgxpool.Pool, error) {
    url := os.Getenv("DATABASE_URL")
    if url == "" {
        return nil, fmt.Errorf("DATABASE_URL is required")
    }
    pool, err := pgxpool.New(ctx, url)
    if err != nil {
        return nil, fmt.Errorf("connect: %w", err)
    }
    if err := pool.Ping(ctx); err != nil {
        return nil, fmt.Errorf("ping: %w", err)
    }
    return pool, nil
}

5. Implement the ingestion endpoint

The ingestion path is the security-critical surface. It must validate the site, the payload, and the rate limit before touching the database.

Add internal/api/api.go:

package api

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "net/http"
    "strings"
    "sync"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

type Server struct {
    DB        *pgxpool.Pool
    RateLimit int
    Hits      map[string][]time.Time
    mu        sync.Mutex
}

type ingestRequest struct {
    Path     string `json:"path"`
    Referrer string `json:"referrer"`
}

type ingestResponse struct {
    OK bool `json:"ok"`
}

func (s *Server) Ingest(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        writeError(w, http.StatusMethodNotAllowed, "method not allowed")
        return
    }
    apiKey := strings.TrimSpace(r.Header.Get("X-Api-Key"))
    if len(apiKey) != 32 {
        writeError(w, http.StatusUnauthorized, "missing or invalid api key")
        return
    }
    if !s.allow(r.RemoteAddr) {
        writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
        return
    }

    var body ingestRequest
    if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
        writeError(w, http.StatusBadRequest, "invalid json")
        return
    }
    body.Path = strings.TrimSpace(body.Path)
    if body.Path == "" || len(body.Path) > 2000 {
        writeError(w, http.StatusBadRequest, "path is required and must be <= 2000 chars")
        return
    }
    if len(body.Referrer) > 2000 {
        writeError(w, http.StatusBadRequest, "referrer too long")
        return
    }

    visitor := visitorHash(r)
    siteID, err := s.siteIDForKey(r.Context(), apiKey)
    if err != nil {
        writeError(w, http.StatusUnauthorized, "unknown site")
        return
    }

    _, err = s.DB.Exec(r.Context(),
        `INSERT INTO page_views (site_id, path, referrer, visitor_hash)
         VALUES ($1, $2, $3, $4)`,
        siteID, body.Path, body.Referrer, visitor)
    if err != nil {
        writeError(w, http.StatusInternalServerError, "insert failed")
        return
    }
    writeJSON(w, http.StatusOK, ingestResponse{OK: true})
}

func (s *Server) siteIDForKey(ctx context.Context, apiKey string) (int64, error) {
    var id int64
    err := s.DB.QueryRow(ctx,
        `SELECT id FROM sites WHERE api_key = $1`, apiKey).Scan(&id)
    return id, err
}

func visitorHash(r *http.Request) string {
    raw := fmt.Sprintf("%s|%s|%s",
        r.Header.Get("X-Forwarded-For"),
        r.Header.Get("User-Agent"),
        time.Now().UTC().Format("2006-01-02"))
    sum := sha256.Sum256([]byte(raw))
    return hex.EncodeToString(sum[:])
}

func (s *Server) allow(remote string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    now := time.Now()
    cutoff := now.Add(-time.Minute)
    hits := s.Hits[remote]
    kept := hits[:0]
    for _, h := range hits {
        if h.After(cutoff) {
            kept = append(kept, h)
        }
    }
    if len(kept) >= s.RateLimit {
        s.Hits[remote] = kept
        return false
    }
    kept = append(kept, now)
    s.Hits[remote] = kept
    return true
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, map[string]string{"error": msg})
}

visitorHash deliberately folds the date into the hash so the same visitor counts as a different unique visitor the next day. That is the privacy tradeoff: you can measure daily uniques and returning visitors by hash prefix matching, but you cannot reconstruct a stable user ID. If you need longer uniques, hash with a coarser date grain (2006-01) and store the hash for at most 30 days.

6. Implement the dashboard endpoints

The dashboard reads from page_views with simple GROUP BY queries. Each endpoint takes from and to as YYYY-MM-DD query parameters and returns JSON.

Append to internal/api/api.go:

type statsOverview struct {
    Views   int64 `json:"views"`
    Uniques int64 `json:"uniques"`
}

func (s *Server) Overview(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        writeError(w, http.StatusMethodNotAllowed, "method not allowed")
        return
    }
    siteID, ok := s.dashboardAuth(r)
    if !ok {
        writeError(w, http.StatusUnauthorized, "unauthorized")
        return
    }
    from, to, ok := parseRange(r)
    if !ok {
        writeError(w, http.StatusBadRequest, "invalid date range")
        return
    }
    var stats statsOverview
    err := s.DB.QueryRow(r.Context(),
        `SELECT COUNT(*), COUNT(DISTINCT visitor_hash)
         FROM page_views
         WHERE site_id = $1 AND viewed_at >= $2 AND viewed_at < $3`,
        siteID, from, to).Scan(&stats.Views, &stats.Uniques)
    if err != nil {
        writeError(w, http.StatusInternalServerError, "query failed")
        return
    }
    writeJSON(w, http.StatusOK, stats)
}

func (s *Server) TopPages(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        writeError(w, http.StatusMethodNotAllowed, "method not allowed")
        return
    }
    siteID, ok := s.dashboardAuth(r)
    if !ok {
        writeError(w, http.StatusUnauthorized, "unauthorized")
        return
    }
    from, to, ok := parseRange(r)
    if !ok {
        writeError(w, http.StatusBadRequest, "invalid date range")
        return
    }
    rows, err := s.DB.Query(r.Context(),
        `SELECT path, COUNT(*) AS views
         FROM page_views
         WHERE site_id = $1 AND viewed_at >= $2 AND viewed_at < $3
         GROUP BY path
         ORDER BY views DESC
         LIMIT 20`,
        siteID, from, to)
    if err != nil {
        writeError(w, http.StatusInternalServerError, "query failed")
        return
    }
    defer rows.Close()
    out := []map[string]any{}
    for rows.Next() {
        var path string
        var views int64
        if err := rows.Scan(&path, &views); err != nil {
            writeError(w, http.StatusInternalServerError, "scan failed")
            return
        }
        out = append(out, map[string]any{"path": path, "views": views})
    }
    writeJSON(w, http.StatusOK, out)
}

func (s *Server) TopReferrers(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        writeError(w, http.StatusMethodNotAllowed, "method not allowed")
        return
    }
    siteID, ok := s.dashboardAuth(r)
    if !ok {
        writeError(w, http.StatusUnauthorized, "unauthorized")
        return
    }
    from, to, ok := parseRange(r)
    if !ok {
        writeError(w, http.StatusBadRequest, "invalid date range")
        return
    }
    rows, err := s.DB.Query(r.Context(),
        `SELECT referrer, COUNT(*) AS views
         FROM page_views
         WHERE site_id = $1 AND referrer <> '' AND viewed_at >= $2 AND viewed_at < $3
         GROUP BY referrer
         ORDER BY views DESC
         LIMIT 20`,
        siteID, from, to)
    if err != nil {
        writeError(w, http.StatusInternalServerError, "query failed")
        return
    }
    defer rows.Close()
    out := []map[string]any{}
    for rows.Next() {
        var ref string
        var views int64
        if err := rows.Scan(&ref, &views); err != nil {
            writeError(w, http.StatusInternalServerError, "scan failed")
            return
        }
        out = append(out, map[string]any{"referrer": ref, "views": views})
    }
    writeJSON(w, http.StatusOK, out)
}

func (s *Server) dashboardAuth(r *http.Request) (int64, bool) {
    apiKey := strings.TrimSpace(r.Header.Get("X-Api-Key"))
    if len(apiKey) != 32 {
        return 0, false
    }
    id, err := s.siteIDForKey(r.Context(), apiKey)
    if err != nil {
        return 0, false
    }
    return id, true
}

func parseRange(r *http.Request) (time.Time, time.Time, bool) {
    from, err := time.Parse("2006-01-02", r.URL.Query().Get("from"))
    if err != nil {
        return time.Time{}, time.Time{}, false
    }
    to, err := time.Parse("2006-01-02", r.URL.Query().Get("to"))
    if err != nil {
        return time.Time{}, time.Time{}, false
    }
    if !to.After(from) {
        return time.Time{}, time.Time{}, false
    }
    return from, to, true
}

7. Wire up the main binary

Add cmd/server/main.go:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "strconv"
    "syscall"
    "time"

    "example.com/analytics/internal/api"
    "example.com/analytics/internal/db"
)

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    pool, err := db.Open(ctx)
    if err != nil {
        log.Fatalf("db: %v", err)
    }
    defer pool.Close()

    rateLimit, _ := strconv.Atoi(os.Getenv("INGEST_RATE_LIMIT_PER_MINUTE"))
    if rateLimit <= 0 {
        rateLimit = 120
    }

    srv := &api.Server{DB: pool, RateLimit: rateLimit, Hits: map[string][]time.Time{}}

    mux := http.NewServeMux()
    mux.HandleFunc("/event", srv.Ingest)
    mux.HandleFunc("/api/stats/overview", srv.Overview)
    mux.HandleFunc("/api/stats/top-pages", srv.TopPages)
    mux.HandleFunc("/api/stats/top-referrers", srv.TopReferrers)
    mux.Handle("/script.js", http.FileServer(http.Dir("internal/tracker")))

    addr := ":" + os.Getenv("PORT")
    if addr == ":" {
        addr = ":8080"
    }
    log.Printf("analytics listening on %s", addr)
    server := &http.Server{Addr: addr, Handler: mux}
    go func() {
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()
    <-ctx.Done()
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    _ = server.Shutdown(shutdownCtx)
}

8. Serve the tracking script

Add internal/tracker/script.js:

(function () {
  "use strict";
  var endpoint = "{{ORIGIN}}/event";
  var apiKey = "{{API_KEY}}";

  function send() {
    var data = {
      path: location.pathname,
      referrer: document.referrer || ""
    };
    var body = JSON.stringify(data);
    if (navigator.sendBeacon) {
      var blob = new Blob([body], { type: "application/json" });
      navigator.sendBeacon(endpoint, blob);
      return;
    }
    var xhr = new XMLHttpRequest();
    xhr.open("POST", endpoint, true);
    xhr.setRequestHeader("Content-Type", "application/json");
    xhr.setRequestHeader("X-Api-Key", apiKey);
    xhr.send(body);
  }

  if (document.readyState === "complete") {
    send();
  } else {
    window.addEventListener("load", send);
  }
})();

When you onboard a site, you replace {{ORIGIN}} with the analytics host and {{API_KEY}} with the site’s key, then serve the script from the same origin as your dashboard. sendBeacon is used when available because it survives page unload. The fallback XMLHttpRequest keeps the script working on browsers that disable sendBeacon.

9. Create a site

Generate a 32-character API key with a CSPRNG, not math/rand:

openssl rand -hex 16

Insert the site:

psql "$DATABASE_URL" -c \
  "INSERT INTO sites (api_key, domain) VALUES ('<KEY>', 'example.com')"

In production, expose a /api/sites POST endpoint owned by an admin token instead of inserting rows manually. The MVP keeps it in SQL because the dashboard has no auth of its own.

10. Run everything locally

Add docker-compose.yml:

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: analytics
      POSTGRES_PASSWORD: change_me
      POSTGRES_DB: analytics
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "analytics"]
      interval: 5s
      timeout: 3s
      retries: 10

  app:
    build: .
    environment:
      DATABASE_URL: postgres://analytics:change_me@db:5432/analytics?sslmode=disable
      PORT: 8080
      INGEST_RATE_LIMIT_PER_MINUTE: 120
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8080:8080"

volumes:
  pgdata:

Add Dockerfile:

FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod .
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/analytics ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/analytics /analytics
COPY internal/tracker/script.js /internal/tracker/script.js
EXPOSE 8080
ENTRYPOINT ["/analytics"]

Start the stack:

docker compose up -d --build
psql "$DATABASE_URL" -f migrations/0001_init.sql
psql "$DATABASE_URL" -c "INSERT INTO sites (api_key, domain) VALUES ('$(openssl rand -hex 16)', 'localhost')"

Send a test event:

curl -X POST http://localhost:8080/event \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: <KEY>" \
  -d '{"path":"/","referrer":""}'

Read the dashboard:

curl "http://localhost:8080/api/stats/overview?from=2026-08-31&to=2026-09-01" \
  -H "X-Api-Key: <KEY>"

11. Test the privacy contract

The whole point of the architecture is that no personal data is stored. Verify it before you ship:

# Same visitor, two page views, two distinct visitor hashes:
curl -X POST http://localhost:8080/event \
  -H "Content-Type: application/json" -H "X-Api-Key: <KEY>" \
  -d '{"path":"/a"}'
curl -X POST http://localhost:8080/event \
  -H "Content-Type: application/json" -H "X-Api-Key: <KEY>" \
  -d '{"path":"/b"}'

psql "$DATABASE_URL" -c "SELECT path, visitor_hash FROM page_views ORDER BY id DESC LIMIT 2;"

If two rows share the same hash, the visitor is one daily unique. Across midnight UTC they become two uniques, by design. The stored value is irreversible: even if the database leaks, no one can recover an IP from it.

Production deployment

Production adds Caddy for TLS and removes the public DB port.

Add Caddyfile:

analytics.example.com {
    reverse_proxy app:8080
    encode zstd gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options nosniff
        Referrer-Policy no-referrer
    }
}

Replace docker-compose.yml for production:

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: analytics
      POSTGRES_PASSWORD: change_me
      POSTGRES_DB: analytics
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "analytics"]
      interval: 5s
      timeout: 3s
      retries: 10

  app:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://analytics:change_me@db:5432/analytics?sslmode=disable
      PORT: 8080
      INGEST_RATE_LIMIT_PER_MINUTE: 120
    depends_on:
      db:
        condition: service_healthy

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app

volumes:
  pgdata:
  caddy_data:
  caddy_config:

Deploy:

docker compose up -d --build
docker compose exec db psql -U analytics -d analytics -f migrations/0001_init.sql

Security considerations

  • No cookies. The platform does not set any. No consent banner is required under the GDPR ePrivacy cookie rules for first-party non-cookie analytics. Confirm with counsel before claiming compliance in your region.
  • Visitor hashing is not anonymity. A hash is pseudonymous data. Combined with another dataset that maps IPs to hashes on the same date, a sophisticated attacker could re-identify. Mitigations: rotate the hash grain daily, retain rows for at most 12 months, and document the retention policy.
  • Ingestion is the public surface. Every unauthenticated write path is a DoS vector. The in-memory rate limit is per-instance. For multiple replicas, move it to a shared store (Redis) or to the reverse proxy with caddy-ratelimit.
  • API keys are secrets. They appear in customer HTML. They cannot be assumed private. Their only real role is to scope a site’s data and prevent cross-site write abuse, not to authenticate the visitor.
  • SQL injection. Every query uses parameterized pgx. There is no string interpolation of user input anywhere. Keep it that way when extending.
  • Referrer header. document.referrer may leak cross-origin URLs. The script forwards it as-is; if your customers care, document the opt-out header Referrer-Policy: no-referrer on their side.

Backups and reliability

  • pg_dump daily, compressed, off-box:
docker compose exec -T db pg_dump -U analytics analytics | zstd > backups/analytics-$(date +%F).sql.zst
  • Restore test monthly on a throwaway container. A backup you have never restored is not a backup.
  • WAL archiving is optional at this scale. The daily dump is enough for a low-traffic analytics product.
  • Retention: delete rows older than 12 months with a nightly cron:
DELETE FROM page_views WHERE viewed_at < now() - INTERVAL '12 months';

Vacuum full is not required; autovacuum reclaims the space.

Monitoring

  • A /health endpoint is the cheapest uptime signal:
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    if err := pool.Ping(r.Context()); err != nil {
        writeError(w, http.StatusServiceUnavailable, "db unavailable")
        return
    }
    writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})

Point Uptime Kuma or any external prober at https://analytics.example.com/health. Alert on non-200.

  • Watch pg_stat_statements for slow aggregation queries. If TopPages starts taking more than 200 ms, add a path summary rollup table refreshed hourly.

Cost

Assumptions:

  • 1 site tracked
  • 10,000 page views / month
  • 30 GB database volume
  • 50 GB outbound bandwidth
  • Single VPS in Europe
Service Monthly cost
Hetzner CX22 (2 vCPU / 4 GB / 40 GB) $4.49
IPv4 $0.48
Domain (.com / .dev) $0.08–2
Cloudflare Free (DNS, TLS) $0
Backblaze B2 backups (~10 GB) $0.06
Total ≈ $5–7/month

At 100,000 page views / month you are still on the same CX22. At 1 million, move to CX32 and add a rollup table. The bottleneck is the COUNT(DISTINCT visitor_hash) query, not ingestion.

Production checklist

  • HTTPS enforced by Caddy
  • Database not exposed publicly
  • Backups running daily, restore tested
  • Retention cron enabled
  • /health probed externally
  • API keys generated with openssl rand
  • Rate limit set on ingestion
  • Referrer-Policy: no-referrer on the tracker origin
  • HSTS header present
  • pg_stat_statements enabled

Common problems

visitor_hash length mismatch on insert. The CHECK constraint expects exactly 64 hex chars. If you change the hash algorithm, also change the constraint and run a migration.

script.js returns 404 in production. The Dockerfile copies it to /internal/tracker/script.js; the FileServer uses internal/tracker as the root. Keep both consistent or the path resolves wrong.

Ingestion returns 401 with a valid key. The key length must be 32 characters. openssl rand -hex 16 produces 32 hex chars. Trailing whitespace in the header will fail the length check; TrimSpace handles it but double-check the customer’s script template.

Dashboard returns empty ranges. parseRange uses UTC dates. A to value of the current day excludes today’s events. Use to = tomorrow for “today inclusive”.

Rate limit trips for legitimate traffic. The default 120/min/IP is generous. If you run behind Cloudflare, r.RemoteAddr is the proxy; switch to X-Forwarded-For and validate it against Cloudflare’s published ranges before trusting it.

Improvements

  • Rollup tables. Pre-aggregate daily per path and per referrer into a daily_path_stats table. Dashboard reads become constant-time.
  • Multi-tenant auth. Replace the shared API key with per-user admin tokens and a sites_users table.
  • Bot filtering. Drop requests from common crawler user agents at ingestion time, before the insert.
  • Custom events. Extend the payload with an event_name and a small JSON props column, validated with a JSON schema.
  • Realtime. Stream inserts to a PostgreSQL NOTIFY channel and push counts to the dashboard over SSE.

Conclusion

You now have a working privacy-friendly analytics backend: one Go binary, PostgreSQL, a 1 KB tracker, and a JSON dashboard. It costs less than $7/month to run, owns its data, and never asks the visitor for consent because it never sets a cookie. Add a small admin UI on top of the dashboard endpoints and you have a product.

Related guides