Privacy-first analytics setup: IP truncation, cookieless events, aggregation
For developers who need usable analytics without collecting unnecessary personal data. This guide gives you exact Nginx, app, and SQL snippets to truncate IPs, disable cookies for measurement, and store only aggregated metrics you can verify from the command line.
TL;DR — You can keep basic traffic analytics useful without storing full IP addresses or setting tracking cookies. The fastest path is: truncate IPs at ingest, send only page/event data with
credentials: "omit", and persist daily aggregates instead of raw request logs. Reading time: ~5 min
Goal
When you finish, your app will record page views and simple events without cookies, your analytics endpoint will never store full client IP addresses, and your database will expose only aggregated daily metrics you can query and verify end-to-end.
Prerequisites
- Shell access to the server or container running your app and reverse proxy
- Ability to edit your web server config and reload it (
sudoon Linux) - Nginx installed (
nginx -v) - PostgreSQL 14+ available (
psql --version) - Node.js 20+ if your app examples are Node-based (
node --version) - Your site origin, for example
https://app.example.com - A test page on your site where you can add one JavaScript snippet
- A database name, user, and password with permission to create tables and indexes
Steps
Step 1: Truncate client IPs before your app sees them
If you run Nginx in front of your app, overwrite the forwarded client IP with a truncated value. Use /24 for IPv4 and /64 for IPv6.
# /etc/nginx/conf.d/analytics-privacy.conf
map $remote_addr $anon_ip {
~^(\d+)\.(\d+)\.(\d+)\.\d+$ $1.$2.$3.0;
~^([0-9A-Fa-f:]+):[0-9A-Fa-f:]+$ $1::;
default 0.0.0.0;
}
server {
listen 443 ssl http2;
server_name app.example.com;
location /a/collect {
proxy_set_header X-Forwarded-For $anon_ip;
proxy_set_header X-Real-IP $anon_ip;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:3000;
}
}
sudo nginx -t && sudo systemctl reload nginx
What you should see when it succeeds: syntax is ok and test is successful, then Nginx reloads without errors.
Step 2: Create tables for raw minimal events and daily aggregates
Store only what you need for short-lived processing, then aggregate. This schema avoids cookies, user IDs, and full IPs.
CREATE TABLE IF NOT EXISTS analytics_events (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
site_host text NOT NULL,
path text NOT NULL,
event_name text NOT NULL,
referrer_host text,
anon_ip text NOT NULL,
ua_family text,
status smallint NOT NULL DEFAULT 204
);
CREATE INDEX IF NOT EXISTS analytics_events_ts_idx
ON analytics_events (ts);
CREATE INDEX IF NOT EXISTS analytics_events_site_path_idx
ON analytics_events (site_host, path, ts);
CREATE TABLE IF NOT EXISTS analytics_daily (
day date NOT NULL,
site_host text NOT NULL,
path text NOT NULL,
event_name text NOT NULL,
views bigint NOT NULL,
unique_networks bigint NOT NULL,
PRIMARY KEY (day, site_host, path, event_name)
);
psql "$DATABASE_URL" -f ./analytics-schema.sql
What you should see when it succeeds: CREATE TABLE and CREATE INDEX lines, or NOTICE: relation ... already exists, skipping if rerun.
Step 3: Add a cookieless collection endpoint
This example uses Express. It accepts JSON, ignores cookies, trusts only the truncated IP from Nginx, and returns 204 No Content.
// analytics.js
import express from "express";
import pg from "pg";
const app = express();
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
app.use(express.json({ limit: "10kb" }));
function uaFamily(ua = "") {
if (ua.includes("Firefox/")) return "Firefox";
if (ua.includes("Chrome/") && !ua.includes("Edg/")) return "Chrome";
if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari";
if (ua.includes("Edg/")) return "Edge";
return "Other";
}
app.post("/a/collect", async (req, res) => {
const site_host = req.hostname;
const path = String(req.body.path || "/").slice(0, 512);
const event_name = String(req.body.event_name || "pageview").slice(0, 64);
const referrer = req.get("referer") || "";
const referrer_host = (() => { try { return new URL(referrer).host || null; } catch { return null; } })();
const anon_ip = String(req.get("x-forwarded-for") || "0.0.0.0").split(",")[0].trim().slice(0, 64);
const ua_family = uaFamily(req.get("user-agent") || "");
await pool.query(
`INSERT INTO analytics_events (site_host, path, event_name, referrer_host, anon_ip, ua_family)
VALUES ($1, $2, $3, $4, $5, $6)`,
[site_host, path, event_name, referrer_host, anon_ip, ua_family]
);
res.status(204).end();
});
app.listen(3000, () => console.log("analytics listening on :3000"));
node analytics.js
What you should see when it succeeds: analytics listening on :3000 and no stack trace.
Step 4: Send page views without cookies or credentials
Add this snippet to your frontend layout or page template. It sends only path and event name; no cookies, no localStorage ID, no fingerprinting.
<script>
fetch("/a/collect", {
method: "POST",
credentials: "omit",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: location.pathname, event_name: "pageview" })
}).catch(() => {});
</script>
What you should see when it succeeds: the browser network panel shows POST /a/collect with status 204 and no Cookie request header.
Step 5: Aggregate and delete raw events on a schedule
Run this SQL every hour or daily. It rolls up counts by day/path/event and counts unique truncated networks, then deletes raw rows older than 2 days.
⚠️ This step deletes raw analytics rows older than 2 days. If you still need debugging visibility, change the interval before running it in production.
INSERT INTO analytics_daily (day, site_host, path, event_name, views, unique_networks)
SELECT
date(ts) AS day,
site_host,
path,
event_name,
count(*) AS views,
count(DISTINCT anon_ip) AS unique_networks
FROM analytics_events
WHERE ts < now() - interval '5 minutes'
GROUP BY 1,2,3,4
ON CONFLICT (day, site_host, path, event_name)
DO UPDATE SET
views = EXCLUDED.views,
unique_networks = EXCLUDED.unique_networks;
DELETE FROM analytics_events
WHERE ts < now() - interval '2 days';
psql "$DATABASE_URL" -c "$(cat ./aggregate-analytics.sql)"
What you should see when it succeeds: output shaped like INSERT 0 12 and DELETE 37.
Step 6: Add a cron job
Use cron on the app host if you do not already have a scheduler.
crontab -e
Add exactly this line:
5 * * * * psql "$DATABASE_URL" -f /opt/app/aggregate-analytics.sql >> /var/log/analytics-aggregate.log 2>&1
What you should see when it succeeds: crontab: installing new crontab after save and exit.
View as text
[Browser] ---- 1. POST /a/collect ----> [Nginx] [Nginx] ---- 2. X-Forwarded-For: truncated ----> [App] [App] ---- 3. INSERT minimal row ----> [analytics_events] [cron] ---- 4. aggregate + delete ----> [Postgres] [Postgres] ---- 5. query daily totals ----> [analytics_daily]
Verify it works
Run a request that simulates a client with a full IP, then confirm the stored value is truncated and the endpoint does not set cookies.
curl -i https://app.example.com/a/collect \
-X POST \
-H 'Content-Type: application/json' \
-H 'X-Forwarded-For: 203.0.113.42' \
-d '{"path":"/pricing","event_name":"pageview"}'
Expected response shape:
HTTP/2 204
server: nginx
content-length: 0
No Set-Cookie: header should appear.
Check the stored event:
psql "$DATABASE_URL" -c "SELECT path, event_name, anon_ip FROM analytics_events ORDER BY id DESC LIMIT 1;"
Expected output shape:
path | event_name | anon_ip
-----------+------------+-------------
/pricing | pageview | 203.0.113.0
(1 row)
Run aggregation and query the daily table:
psql "$DATABASE_URL" -f ./aggregate-analytics.sql
psql "$DATABASE_URL" -c "SELECT day, path, event_name, views, unique_networks FROM analytics_daily ORDER BY day DESC, views DESC LIMIT 5;"
Expected output shape:
day | path | event_name | views | unique_networks
------------+-----------+------------+-------+-----------------
2026-10-30 | /pricing | pageview | 1 | 1
(1 row)
Common pitfalls
Nginx still forwards the full client IP
Mistake: leaving proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; in the same location block.
Symptom: SELECT anon_ip ... shows 203.0.113.42 instead of 203.0.113.0.
Fix: replace that line with proxy_set_header X-Forwarded-For $anon_ip; and reload Nginx.
The frontend request silently includes cookies
Mistake: using fetch("/a/collect", { method: "POST" }) on same-origin pages and assuming no cookies are sent.
Symptom: browser devtools request headers include Cookie: session=....
Fix: set credentials: "omit" exactly in the fetch options.
Aggregation overcounts after repeated runs
Mistake: using ON CONFLICT ... DO UPDATE SET views = analytics_daily.views + EXCLUDED.views with a source query that scans all historical rows.
Symptom: totals grow every hour even when traffic is flat.
Fix: use views = EXCLUDED.views and unique_networks = EXCLUDED.unique_networks exactly as shown, or aggregate only a fixed unprocessed window.
IPv6 truncation is inconsistent
Mistake: relying on ad hoc string slicing in app code for IPv6 addresses.
Symptom: rows contain mixed forms like 2001:db8:1:2:3:: and 2001:db8:1:2:: for the same network.
Fix: normalize at the proxy layer and keep the stored field as the already-truncated forwarded value.
The endpoint returns 400 or 413 on some pages
Mistake: sending the full URL, title, referrer, and custom payloads without limits.
Symptom: app logs show JSON parse errors or PayloadTooLargeError: request entity too large.
Fix: send only path and event_name, and keep express.json({ limit: "10kb" }).
Ad blockers suppress some requests
Mistake: exposing the collector at a path like /analytics.js or /track that common blocklists match.
Symptom: browser devtools shows (blocked) or the request never appears for some clients.
Fix: use a first-party endpoint such as /a/collect on the same origin.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI