Install Pulse
Pulse is a single Node.js process that collects privacy-first visitor analytics for static HTML sites and serves its own dashboard. This guide covers a local install, production deployment, tracking snippets and the visual page editor.
No database
Events are append-only JSONL files in data/. Nothing else to install.
One script tag
Add the tracker to any static site. It works with Hugo, Astro, Eleventy, Jekyll or plain HTML.
Yours only
No third-party requests, cookies or cross-day identifiers. Back up one folder.
Requirements
- Node.js 20.11 or newer (tested on Node 22 and 26). Check with
node --version. - About 30 MB of disk for the code and 250–350 bytes per stored event.
- A persistent directory for
data/. Docker and PaaS filesystems are ephemeral unless you mount a volume. - For public deployments: a domain, TLS (Caddy, Nginx, a load balancer or platform ingress) and a strong
ADMIN_TOKEN.
No npm install is required. Pulse has zero runtime dependencies. The package.json only defines scripts.
Quick start
-
Get the code and start the server.
The console printscd pulse-analytics npm startlistening http://localhost:4000and the data directory in use. - Open the dashboard. Visit http://localhost:4000. On a fresh install the dashboard has no events yet, and shows the snippet to install.
-
Add a site.
Edit
sites.json(created on first run), then restart the server. See Add your site. -
Install the tracker.
Paste the snippet into your site's
<head>and deploy. A pageview appears in the Live panel within seconds. -
Set an admin token before going public.
# macOS / Linux ADMIN_TOKEN="$(openssl rand -hex 24)" npm start # Windows PowerShell $env:ADMIN_TOKEN = -join ((48..57) + (97..102) | Get-Random -Count 48 | % {[char]$_}) npm start
Windows notes
The commands in this guide use POSIX syntax. In PowerShell, set environment variables with $env:NAME = "value" for the current session, or use setx NAME "value" for future sessions. Quote paths that contain spaces. Stop the server with Ctrl+C; Pulse flushes pending events on shutdown.
Load demo data
To explore the dashboard before installing the tracker, generate 90 days of deterministic demo events for the two starter sites:
npm run seed -- --days 90 --reset
npm start
The seed writes directly into data/events/. It refuses to add duplicates unless --reset is passed. Use --days 30 for a smaller dataset.
Add your site
Pulse identifies each site with a short public ID. Edit sites.json in the project root (or the file pointed to by SITES_FILE):
{
"sites": [
{ "id": "demo", "name": "Acme Studio", "domain": "acme.example" },
{ "id": "docs", "name": "Acme Docs", "domain": "docs.acme.example" }
]
}
id— 2–32 characters: lowercase letters, digits,_or-. It is embedded in the snippet and is not a secret.name— label shown in the dashboard switcher.domain— used to treat links from your own domain as direct traffic instead of referrals.
Restart the server after editing the file. Then add this to every page, before </head>:
<script defer src="https://stats.example.com/t.js" data-site="demo"></script>
The tracker derives the collector origin from its own src. If you serve t.js from a CDN, point it at the collector explicitly:
<script
defer
src="https://cdn.example.com/t.js"
data-site="demo"
data-endpoint="https://stats.example.com"
></script>
Snippet options
| Attribute | Default | Purpose |
|---|---|---|
data-site | — | Required. Site ID from sites.json. |
data-endpoint | script origin | Collector origin without a trailing slash. |
data-dev | false | Track localhost, 127.0.0.1 and *.local. |
data-respect-dnt | true | Honor Do Not Track and Global Privacy Control. |
data-outbound | true | Track external and download-link clicks. |
data-exclude | — | Comma-separated paths or prefixes; supports *, e.g. /admin/*,/preview. |
Test the install locally
The repository includes an instrumented static page and a tiny file server:
npm start # collector on :4000
npm run example # example site on :8080
Open http://localhost:8080 and click a button. The example uses data-dev="true" because Pulse ignores localhost by default. Verify the API directly:
curl http://localhost:4000/api/v1/health
curl "http://localhost:4000/api/v1/stats?site=demo&range=24h"
No data after installing? Check that the page is not on localhost without data-dev, that Do Not Track is off, that the endpoint is reachable over HTTPS, and that the site ID matches sites.json. The troubleshooting table covers each case.
Custom events and opt-out
The tracker exposes a small global API. Call it after the script has loaded (for example from your own deferred script):
pulse.track('signup', { plan: 'pro', seats: 3 });
pulse.track('cta_click', { cta: 'hero' });
pulse.pageview(); // force a pageview after a client-side route change
pulse.optOut(); // stop tracking in this browser
pulse.optIn(); // resume tracking
Names may contain letters, digits, _, ., : and - (max 64 chars). Up to 10 properties are stored: keys max 40 chars, strings max 120, numbers and booleans accepted. Event names appear in the Custom events panel; properties stay in the JSONL data.
A privacy-friendly opt-out control can be a plain button:
<button type="button" onclick="pulse.optOut()">Opt out of analytics</button>
Configuration
All settings are environment variables. Defaults are suitable for local use.
| Variable | Default | Description |
|---|---|---|
PORT | 4000 | HTTP port. |
HOST | 0.0.0.0 | Bind address. Prefer 127.0.0.1 behind a proxy. |
DATA_DIR | ./data | Event files, salt and persistent state. |
SITES_FILE | ./sites.json | Site registry path. |
ADMIN_TOKEN | empty | Enables dashboard authentication. Empty means open mode. |
SALT_SECRET | generated | Secret for visitor hashes and auth cookies. Created at DATA_DIR/.salt. |
TRUST_PROXY | off | 1 behind a trusted proxy. Uses CF-Connecting-IP, X-Vercel-Forwarded-For, X-Real-IP, X-Forwarded-For. |
GEOIP_DB | DATA_DIR/geoip-country.bin | Country index built by npm run geoip; used when no geo header is present. |
GEO_HEADERS | see below | Country headers, first valid ISO-3166 code wins. |
RETENTION_DAYS | 400 | Delete event files older than this. |
MAX_EVENTS | 250000 | In-memory event cap; oldest events leave the index first. |
RATE_LIMIT | 120 | Collection requests per minute per IP. |
CONTENT_DIR | ./examples/static-site | Folder the page editor can read and write. |
EDITOR | enabled | Set 0, false or off to disable the page editor entirely. |
Default geo headers: cf-ipcountry,x-vercel-ip-country,x-country-code,x-geo-country. Cloudflare and Vercel send the first two automatically. Without a CDN, run npm run geoip to look countries up locally from the free DB-IP database; only the country code is stored.
Keep SALT_SECRET stable. The auto-generated data/.salt works across restarts. If you run on an ephemeral filesystem, set SALT_SECRET explicitly; changing it splits visitor identities within the same day.
Authentication
When ADMIN_TOKEN is set, the dashboard asks for it and exchanges it for an HttpOnly, SameSite=Lax cookie valid for seven days. Collection stays public because the tracker cannot hold a secret.
# Scripts and CI can use a Bearer token
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://stats.example.com/api/v1/stats?site=demo&range=7d"
- Login attempts are limited to 10 per minute per IP.
- Changing
ADMIN_TOKENorSALT_SECRETinvalidates existing sessions. - In open mode (no token) the dashboard and read APIs are public. Never expose that to the internet.
- The editor can write files; always run it with
ADMIN_TOKENset on a public host.
Page editor
Pulse includes a small WYSIWYG editor for static HTML pages. It edits the body content of existing files in CONTENT_DIR; head tags, styles, scripts and body attributes are preserved.
- Point
CONTENT_DIRat a folder of.htmlfiles. The default isexamples/static-site. - Open the dashboard and choose Page editor in the sidebar.
- Pick a file, click into the page and edit. Use the toolbar for headings, lists, links and emphasis, or switch to View source for raw HTML.
- Press Ctrl+S or Save page. The previous revision is written to
file.html.bak.
Details and limits:
- Only
.html/.htmfiles are listed, up to 200 files, three folders deep and 2 MB each. Dotfiles, symlinks and common vendor folders are skipped. - Editing happens in a sandboxed same-origin preview; page scripts are blocked there, but they remain in the saved file.
- Saves are atomic (temp file + rename) and keep one
.bakrevision per page. - The preview loads assets from
/content/, which requires the admin session. The editor is disabled withEDITOR=0. - Files are edited in place. If the folder is inside a container without a volume, edits disappear when the container is replaced.
The editor writes to disk. Do not enable it on a shared or untrusted machine in open mode. Set ADMIN_TOKEN, and keep CONTENT_DIR scoped to the pages you actually want editable.
Reverse proxy and TLS
Run Pulse behind a TLS-terminating proxy and set TRUST_PROXY=1, so rate limiting and visitor hashing see real client IPs. Without it, every visitor behind the proxy looks like the proxy's IP.
Caddy
stats.example.com {
reverse_proxy 127.0.0.1:4000
}
Nginx
server {
listen 443 ssl;
server_name stats.example.com;
location / {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Start Pulse bound to localhost:
ADMIN_TOKEN="a-long-random-value" TRUST_PROXY=1 HOST=127.0.0.1 node server.js
If the proxy overwrites X-Forwarded-For (the Nginx config above appends), keep TRUST_PROXY=1 limited to that trusted hop. Do not enable it when clients can set forwarding headers themselves.
Docker
The repository includes a Dockerfile and docker-compose.yml. The image runs as the unprivileged node user, stores data in a named volume and exposes a health check.
cp .env.example .env
# edit .env and set a long ADMIN_TOKEN
docker compose up -d --build
The compose file points SITES_FILE at /data/sites.json; the first run creates the starter registry. Replace it with your own and restart:
docker compose cp ./sites.json pulse:/data/sites.json
docker compose restart pulse
Check logs and health:
docker compose logs -f pulse
curl http://localhost:4000/api/v1/health
To edit a host folder with the page editor, mount it into the container and point CONTENT_DIR at the mount, for example:
services:
pulse:
environment:
CONTENT_DIR: /content
volumes:
- pulse-data:/data
- ./examples/static-site:/content
On Linux, the container user (uid 1000) needs write permission on the mounted folder. The default image does not persist edits made inside /app; always mount the content directory if you use the editor.
Run as a service
Create /etc/systemd/system/pulse.service:
[Unit]
Description=Pulse analytics
After=network.target
[Service]
Type=simple
User=pulse
WorkingDirectory=/opt/pulse-analytics
Environment=PORT=4000
Environment=HOST=127.0.0.1
Environment=TRUST_PROXY=1
Environment=ADMIN_TOKEN=replace-with-a-long-random-value
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now pulse
sudo journalctl -u pulse -f
Keep the token out of world-readable unit files on shared systems; use EnvironmentFile= with mode 0600 instead. The data/ directory must be writable by the service user.
Backups and retention
Everything that matters lives in DATA_DIR:
events/events-YYYY-MM-DD.jsonl— one append-only file per UTC day..salt— the secret used for daily visitor hashes and admin cookies. Treat it as a credential.
For a consistent backup, stop Pulse (or accept losing up to the last 500 ms of events) and copy the folder:
tar -czf pulse-backup-$(date +%F).tar.gz data/
Restore by placing the folder back and starting the server. Files older than RETENTION_DAYS are deleted automatically at startup and once a day; adjust retention before restoring if you need older data. Backups are outside Pulse's control — delete them according to your own policy.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| Dashboard is empty after adding the snippet | Localhost is ignored unless data-dev="true". Check the browser console for blocked requests, and confirm data-site matches sites.json. |
| Requests return 400 “Unknown site id” | The site ID is missing or not in sites.json; restart after editing the registry. |
| No events, no errors | Do Not Track / GPC is enabled, the user agent is classified as a bot, the page was prefetched, or the duration was under 250 ms. These are intentionally dropped. |
| CORS or network errors in the console | data-endpoint must point at the collector origin (HTTPS if the page is HTTPS). The tracker uses a CORS-simple text/plain beacon. |
| Everyone appears to come from one IP | Set TRUST_PROXY=1 and make sure your proxy overwrites forwarding headers. |
| Locations panel is empty | Run npm run geoip on the server to install the free DB-IP country database, or put Cloudflare/Vercel in front and configure GEO_HEADERS. |
| Only one visitor for many people | They share an IP and user agent, or you are behind a proxy that hides client IPs. Visitors are daily pseudonyms, not accounts. |
| 429 responses | Rate limit hit (120/min/IP by default). Raise RATE_LIMIT or fix the proxy IP issue. |
| 401 on the dashboard | ADMIN_TOKEN is set. Sign in, or use Authorization: Bearer … for scripts. |
| Editor says disabled | Set EDITOR=1 (or remove EDITOR=0) and restart. The editor is admin-only when a token is configured. |
| Editor lists no files | Check CONTENT_DIR exists and contains .html files within three folders. Restart after changing the variable. |
| Save fails with 403/413 | The file path is outside CONTENT_DIR, the file is over 2 MB, or the session expired. Sign in and retry. |
| Port already in use | Change PORT or stop the other process. On Windows: Get-NetTCPConnection -LocalPort 4000. |
| Docker data disappears | Mount the pulse-data volume (compose does) and keep DATA_DIR pointed at it. |
Health and diagnostics:
curl http://localhost:4000/api/v1/health
The response includes the store size, pending writes, write errors, loaded files and version. Logs are written to stdout for requests under /api/ and /t.js.
API reference
Read endpoints require auth when ADMIN_TOKEN is set. Collection is always public and CORS-enabled.
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/event | Collect an event (alias /api/collect). |
GET | /api/v1/health | Liveness, version, store status. Public. |
GET | /api/v1/sites | Site list, auth mode, editor availability. |
GET | /api/v1/stats?site=demo&range=30d | Aggregates; ranges 24h, 7d, 30d, 90d. |
GET | /api/v1/live?site=demo | Active visitors and the last 30 minutes of events. |
GET | /api/v1/export?site=demo&range=30d&dataset=pages | CSV export. |
GET | /api/v1/pages | List editable HTML files. |
GET | /api/v1/pages/file?path=index.html | Page metadata (title, size, backup). |
PUT | /api/v1/pages/file | Save { path, body, title }; writes a .bak. |
POST | /api/v1/login / /logout | Create or clear the admin cookie. |
CSV datasets: pages, entry, exit, events, outbound, sources, countries, devices, browsers, os.
Privacy notes
- No cookies. The tracker uses
sessionStoragefor a 30-minute session andlocalStorageonly for the opt-out flag. - Raw IPs and user agents are hashed with a server secret and the current UTC day, then discarded. The 16-character pseudonym rotates at UTC midnight.
- Referrers are reduced to a source label and hostname. Country comes from CDN headers. Full referring URLs are not stored.
- DNT and GPC are honored by default, and bots, prefetches and prerenders are dropped at ingest.
- Retention is bounded by
RETENTION_DAYS, and all data stays on your server.
Cookie-free does not automatically mean consent-free in every jurisdiction. Confirm your legal basis and retention policy for your context.