Michifumi's Blog

This blog is entirely produced by silicon-based slaves. I only issue commands and wield the whip.

Nov 1, 2025

Self‑Hosted Lightweight Analytics for This Blog (Step‑by‑Step)

How I added privacy‑friendly visitor statistics to a static Astro site using a tiny Node.js endpoint, SQLite, PM2, and Caddy.

#IT #Linux #Caddy #Cloudflare

Instead of using third‑party analytics like Cloudflare, I’m running a tiny self‑hosted tracker so you can also learn how it works and replicate it.

What I’ve built

A tiny analytics API that:

  • Accepts page‑view pings from this blog (/track)
  • Summarises counts per path (/stats)
  • Lets me export raw visits as CSV (/export)
  • Stores data in a single SQLite file for easy backup/migration
  • Runs forever with pm2, served over HTTPS with Caddy

You can adapt this for any static site (Astro, Hugo, etc.).


0) Prerequisites

  • Ubuntu VM with a public IP
  • Shell access with sudo
  • A subdomain for the analytics endpoint (I use stats.zaku.eu.org)
  • Basic DNS access (Cloudflare in my case)
  • Node.js 18+ and npm
sudo apt update && sudo apt upgrade -y
sudo apt install -y nodejs npm git

1) Create the analytics service

Create a new folder and initialise a Node project:

mkdir ~/page-stats && cd ~/page-stats
npm init -y
npm install express sqlite3 cors

Create server.js:

const express = require("express");
const sqlite3 = require("sqlite3").verbose();
const cors = require("cors");
const app = express();
app.set("trust proxy", 1);
app.set("json spaces", 2);
const db = new sqlite3.Database("stats.db");

const EXPORT_PASSWORD = "secretkey";

app.use(express.json({ limit: "2kb" }));
// Accept plain text for no-cors fetch (simple request)
app.use(express.text({ type: "text/plain", limit: "2kb" }));

db.serialize(() => {
  db.exec(`
    PRAGMA journal_mode = WAL;
    PRAGMA synchronous = NORMAL;
    PRAGMA busy_timeout = 5000;
  `);

  db.run(`CREATE TABLE IF NOT EXISTS visits (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    path TEXT,
    referrer TEXT,
    ua TEXT,
    ip TEXT,
    ts DATETIME DEFAULT CURRENT_TIMESTAMP
  )`);

  db.run(`CREATE INDEX IF NOT EXISTS idx_visits_path ON visits(path)`);
  db.run(`CREATE INDEX IF NOT EXISTS idx_visits_ts ON visits(ts)`);
});

app.post("/track", (req, res) => {
  let body = req.body;

  // Handle both text/plain and JSON input
  if (typeof body === "string") {
    try {
      body = JSON.parse(body);
    } catch {
      body = {};
    }
  }

  const { path: rawPath, referrer: rawReferrer, ua } = body || {};
  const path = typeof rawPath === "string" ? rawPath : "";
  const referrer = typeof rawReferrer === "string" ? rawReferrer : "";
  const userAgent = ua || req.headers["user-agent"] || "";
  const ip = req.ip || "";

  if (!path) {
    console.warn("[analytics] Missing path field in request body");
    return res.sendStatus(400);
  }

  db.run(
    `INSERT INTO visits (path, referrer, ua, ip) VALUES (?, ?, ?, ?)`,
    [path, referrer, userAgent, ip],
    (err) => {
      if (err) {
        console.error("DB insert error:", err);
        return res.sendStatus(500);
      }
      res.sendStatus(204);
    }
  );
});

// Basic visualisation endpoint (JSON)
app.get("/stats", (req, res) => {
  db.all(`SELECT path, COUNT(*) as views FROM visits GROUP BY path ORDER BY views DESC`, (err, rows) => {
    if (err) return res.status(500).json({ error: err.message });
    res.json(rows);
  });
});

// CSV export
app.get("/export", (req, res) => {
  const token = req.query.token;
  if (token !== EXPORT_PASSWORD) {
    return res.status(403).send("Forbidden: Invalid export token");
  }

  db.all(`SELECT * FROM visits ORDER BY ts DESC`, (err, rows) => {
    if (err) return res.status(500).send(err.message);
    const csv = [
      "id,path,referrer,ua,ip,ts",
      ...rows.map(r => `${r.id},"${r.path}","${r.referrer}","${r.ua}","${r.ip}","${r.ts}"`)
    ].join("\n");
    res.setHeader("Content-Disposition", "attachment; filename=stats.csv");
    res.type("text/csv").send(csv);
  });
});

// --- Daily summary (views per day and path, last 30 days) ---
app.get("/daily", (req, res) => {
  db.all(
    `SELECT DATE(ts) AS day, path, COUNT(*) AS views
     FROM visits
     WHERE ts >= DATE('now', '-30 days')
     GROUP BY day, path
     ORDER BY day DESC`,
    (err, rows) => {
      if (err) return res.status(500).json({ error: err.message });
      res.json(rows);
    }
  );
});

// --- Totals summary (overall + by path + by day, last 30 days) ---
app.get("/summary", (req, res) => {
  const result = {};

  db.get(
    `SELECT
       COUNT(*) AS total_views,
       COUNT(DISTINCT path) AS unique_paths,
       MIN(ts) AS first_visit,
       MAX(ts) AS last_visit
     FROM visits`,
    (err, totalRow) => {
      if (err) return res.status(500).json({ error: err.message });
      result.total_views = totalRow.total_views;
      result.unique_paths = totalRow.unique_paths;
      result.first_visit = totalRow.first_visit;
      result.last_visit = totalRow.last_visit;

      db.all(
        `SELECT path, COUNT(*) AS views
         FROM visits
         GROUP BY path
         ORDER BY views DESC`,
        (err, pathRows) => {
          if (err) return res.status(500).json({ error: err.message });
          result.by_path = pathRows;

          db.all(
            `SELECT DATE(ts) AS day, COUNT(*) AS views
             FROM visits
             WHERE ts >= DATE('now', '-30 days')
             GROUP BY day
             ORDER BY day DESC`,
            (err, dayRows) => {
              if (err) return res.status(500).json({ error: err.message });
              result.by_day = dayRows;
              res.json(result);
            }
          );
        }
      );
    }
  );
});

app.listen(8080, () => console.log("Analytics server running on port 8080"));

Quick test:

node server.js
# In another shell:
curl -X POST http://localhost:8080/track \
  -H "Content-Type: text/plain" \
  -d '{"path":"/hello","referrer":""}'
curl http://localhost:8080/stats

You should see a JSON array with counts.


2) Keep it running with pm2

sudo npm install -g pm2
pm2 start server.js --name stats
pm2 startup
# Run the one-line command pm2 prints for systemd, then:
pm2 save

Check status:

pm2 ls

3) Obtain HTTPS with Caddy (reverse proxy)

Install Caddy: Caddy install guide

Then configure /etc/caddy/Caddyfile:

stats.zaku.eu.org {

    reverse_proxy localhost:8080

    root * /usr/share/caddy
    file_server

    header {
        Access-Control-Allow-Origin "https://zaku.eu.org"
        Access-Control-Allow-Methods "GET, POST, OPTIONS"
        Access-Control-Allow-Headers "Content-Type"
        Access-Control-Allow-Credentials true
        Access-Control-Max-Age "86400"
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "no-referrer-when-downgrade"
    }

    @options method OPTIONS
    respond @options 204

    log {
        output file /var/log/caddy/stats-access.log {
            roll_size 10MB
            roll_keep 10
            roll_keep_for 720h
        }
    }
}

If ports 80/443 are already in use, you can run Caddy on alternate ports (the URL must include the port):

{
  http_port 8081
  https_port 8443
}

https://stats.zaku.eu.org:8443/stats {
  reverse_proxy localhost:8080
}

Note: for a publicly trusted TLS cert on non-443, you typically need DNS-01 validation (see below).

Remember to change the domain name to yours.
Reload and tail logs:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl enable --now caddy
# Use reload after the service is running and you make future changes
sudo systemctl reload caddy
sudo journalctl -u caddy -f

Optional: DNS-01 with Cloudflare (when 80/443 are busy)

If you cannot free ports 80/443, use DNS-01 so Let’s Encrypt validates via DNS. This requires a Caddy build with the Cloudflare DNS module.

  1. Install Go 1.25.x (official):
sudo apt remove -y golang-go golang || true
cd /tmp
curl -LO https://go.dev/dl/go1.25.5.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.25.5.linux-amd64.tar.gz
echo 'export PATH=/usr/local/go/bin:$PATH' | sudo tee /etc/profile.d/go.sh >/dev/null
source /etc/profile.d/go.sh

Verify:

go version
  1. Lock Go to the local toolchain:
go env -w GOTOOLCHAIN=local
go env -w GOPROXY=https://proxy.golang.org,direct
  1. Install xcaddy:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
export PATH="$PATH:$HOME/go/bin"
  1. Build Caddy with the Cloudflare DNS module:
xcaddy build --with github.com/caddy-dns/cloudflare
  1. Replace the system Caddy binary:
sudo systemctl stop caddy
sudo install -m 0755 ./caddy /usr/bin/caddy
sudo systemctl start caddy
  1. Verify the module exists:
caddy list-modules | grep cloudflare
  1. Create a Cloudflare API token with:
  • Zone.Zone:Read
  • Zone.DNS:Edit

Scope it to your zone.

  1. Add the token to the Caddy systemd service:
sudo systemctl edit caddy
[Service]
Environment=CLOUDFLARE_API_TOKEN=YOUR_TOKEN_HERE
sudo systemctl daemon-reload
  1. Update the Caddyfile:
{
  http_port 8081
  https_port 8443
}

stats.zaku.eu.org {
  tls {
    dns cloudflare {env.CLOUDFLARE_API_TOKEN}
  }

  reverse_proxy localhost:8080

  root * /usr/share/caddy
  file_server

  header {
    Access-Control-Allow-Origin "https://zaku.eu.org"
    Access-Control-Allow-Methods "GET, POST, OPTIONS"
    Access-Control-Allow-Headers "Content-Type"
    Access-Control-Allow-Credentials true
    Access-Control-Max-Age "86400"
    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    X-Content-Type-Options "nosniff"
    X-Frame-Options "DENY"
    Referrer-Policy "no-referrer-when-downgrade"
  }

  @options method OPTIONS
  respond @options 204

  log {
    output file /var/log/caddy/stats-access.log {
      roll_size 10MB
      roll_keep 10
      roll_keep_for 720h
    }
  }
}

With https_port 8443 set, access the API at https://stats.zaku.eu.org:8443 and update your tracking endpoint to include :8443.

  1. Validate and reload:
sudo caddy validate --config /etc/caddy/Caddyfile
# Format the file if you want
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo systemctl reload caddy
  1. Confirm issuance:
sudo journalctl -u caddy -f

4) DNS (Cloudflare)

Add an A record:

  • Name: stats
  • Target: your VM public IP
  • Proxy status: DNS only (gray cloud)

Caddy will fetch a Let’s Encrypt certificate automatically.
After issuance, HTTPS works at https://stats.zaku.eu.org.


5) Add the tracking snippet to the blog (Astro)

Place this near the bottom of your frontend code, such as BaseLayout.astro (before </body>):

<script is:inline>
  (() => {
    if (typeof window === 'undefined' || typeof navigator === 'undefined') return;

    const endpoint = 'https://stats.zaku.eu.org/track'; // You need to replace it with you own domain!
    const payload = JSON.stringify({
      path: window.location.pathname,
      referrer: document.referrer || ''
    });

    try {
      if (navigator.sendBeacon) {
        const blob = new Blob([payload], { type: 'text/plain' });
        const ok = navigator.sendBeacon(endpoint, blob);
        if (ok) return;
      }
      fetch(endpoint, {
        method: 'POST',
        body: payload,
        keepalive: true,
        mode: 'no-cors',
        headers: { 'Content-Type': 'text/plain' }
      }).catch(err => console.warn('[analytics] fetch failed', err));
    } catch (err) {
      console.warn('[analytics] unexpected error', err);
    }
  })();
</script>

The server reads the User-Agent from headers, so the client payload stays small.

This avoids mixed content and should be works across modern browsers without AdBlocker extensions.


6) Verify end‑to‑end

From the browser:

  • Visit the blog.
  • Check JSON summary:
curl https://stats.zaku.eu.org/stats

Manual POST test over HTTPS:

curl -X POST https://stats.zaku.eu.org/track \
  -H "Content-Type: text/plain" \
  -d '{"path":"/test","referrer":""}'

CSV export:

curl -L -o stats.csv "https://stats.zaku.eu.org/export?token=secretkey"

6.1) Visualise daily and total stats with /daily and /summary endpoints

The analytics API includes two useful endpoints for viewing detailed statistics:

  • /daily: Returns daily visit counts per path for the last 30 days. Useful for tracking trends over time.
  • /summary: Returns totals (all-time) plus breakdowns by path and by day (last 30 days), suitable for quick dashboard overviews.

These endpoints make it easy to visualise daily activity or build a simple dashboard.

/daily endpoint

Returns a JSON array with daily stats for each path:

[
  {
    "day": "2025-10-30",
    "path": "/",
    "views": 12
  },
  {
    "day": "2025-10-30",
    "path": "/about",
    "views": 3
  },
  {
    "day": "2025-10-31",
    "path": "/",
    "views": 15
  }
]

Query it with:

curl https://stats.zaku.eu.org/daily

/summary endpoint

Returns top-level summary stats plus breakdowns:

{
  "total_views": 1234,
  "unique_paths": 8,
  "first_visit": "2025-10-01 09:00:00",
  "last_visit": "2025-10-31 11:45:12",
  "by_path": [
    { "path": "/", "views": 800 },
    { "path": "/about", "views": 120 }
  ],
  "by_day": [
    { "day": "2025-10-31", "views": 45 },
    { "day": "2025-10-30", "views": 38 }
  ]
}

Query it with:

curl https://stats.zaku.eu.org/summary

You can use these endpoints to build graphs, daily charts, or even a mini dashboard.

Tip: For a more visual experience, you can optionally build a simple dashboard page (using e.g. Svelte, React, or plain HTML) that fetches from /daily and /summary to display your stats.

7) Backup & migrate (one‑file move)

All analytics live in stats.db. To migrate to a new VM:

# On old VM
sudo systemctl stop caddy
pm2 stop stats
scp ~/page-stats/stats.db ubuntu@NEW_VM:/home/ubuntu/page-stats/
# On new VM, start pm2 and caddy again

You can also snapshot/export CSV periodically.


8) Troubleshooting

  • “Cannot GET /” when visiting the VM IP: normal — define / or go to /stats.
  • Mixed content blocked: ensure the endpoint is HTTPS and CORS allows your blog origin.
  • DNS check fails: gray‑cloud the stats record until the certificate is issued.
  • Caddy fails to start (permission denied on log file): ensure the log path is owned by the caddy user:
    sudo mkdir -p /var/log/caddy
    sudo touch /var/log/caddy/stats-access.log
    sudo chown -R caddy:caddy /var/log/caddy
  • No data appears: test with a direct curl -X POST .../track and check pm2 logs.

Test your endpoint manually

You can manually test your tracking endpoint with:

curl -X POST http://localhost:8080/track \
  -H "Content-Type: text/plain" \
  -d '{"path":"/hello","referrer":""}'
curl http://localhost:8080/stats

A new entry appearing in /stats confirms your endpoint is working correctly.


Hooray! This blog now uses a self‑hosted, portable, privacy‑friendly analytics system. If you build your own, feel free to fork these snippets and adapt the endpoints to your domain.