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.
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
SQLitefile for easy backup/migration - Runs forever with
pm2, served over HTTPS withCaddy
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.
- 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
- Lock Go to the local toolchain:
go env -w GOTOOLCHAIN=local
go env -w GOPROXY=https://proxy.golang.org,direct
- Install xcaddy:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
export PATH="$PATH:$HOME/go/bin"
- Build Caddy with the Cloudflare DNS module:
xcaddy build --with github.com/caddy-dns/cloudflare
- Replace the system Caddy binary:
sudo systemctl stop caddy
sudo install -m 0755 ./caddy /usr/bin/caddy
sudo systemctl start caddy
- Verify the module exists:
caddy list-modules | grep cloudflare
- Create a Cloudflare API token with:
Zone.Zone:ReadZone.DNS:Edit
Scope it to your zone.
- Add the token to the Caddy systemd service:
sudo systemctl edit caddy
[Service]
Environment=CLOUDFLARE_API_TOKEN=YOUR_TOKEN_HERE
sudo systemctl daemon-reload
- 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.
- 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
- 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
/dailyand/summaryto 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
statsrecord until the certificate is issued. - Caddy fails to start (permission denied on log file): ensure the log path is owned by the
caddyuser: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 .../trackand checkpm2 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.