Pulseanalytics
Self-hosted · zero dependencies

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.

01

No database

Events are append-only JSONL files in data/. Nothing else to install.

02

One script tag

Add the tracker to any static site. It works with Hugo, Astro, Eleventy, Jekyll or plain HTML.

03

Yours only

No third-party requests, cookies or cross-day identifiers. Back up one folder.

Requirements

No npm install is required. Pulse has zero runtime dependencies. The package.json only defines scripts.

Quick start

  1. Get the code and start the server.
    cd pulse-analytics
    npm start
    The console prints listening http://localhost:4000 and the data directory in use.
  2. Open the dashboard. Visit http://localhost:4000. On a fresh install the dashboard has no events yet, and shows the snippet to install.
  3. Add a site. Edit sites.json (created on first run), then restart the server. See Add your site.
  4. Install the tracker. Paste the snippet into your site's <head> and deploy. A pageview appears in the Live panel within seconds.
  5. 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" }
  ]
}

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

AttributeDefaultPurpose
data-site—Required. Site ID from sites.json.
data-endpointscript originCollector origin without a trailing slash.
data-devfalseTrack localhost, 127.0.0.1 and *.local.
data-respect-dnttrueHonor Do Not Track and Global Privacy Control.
data-outboundtrueTrack 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.

VariableDefaultDescription
PORT4000HTTP port.
HOST0.0.0.0Bind address. Prefer 127.0.0.1 behind a proxy.
DATA_DIR./dataEvent files, salt and persistent state.
SITES_FILE./sites.jsonSite registry path.
ADMIN_TOKENemptyEnables dashboard authentication. Empty means open mode.
SALT_SECRETgeneratedSecret for visitor hashes and auth cookies. Created at DATA_DIR/.salt.
TRUST_PROXYoff1 behind a trusted proxy. Uses CF-Connecting-IP, X-Vercel-Forwarded-For, X-Real-IP, X-Forwarded-For.
GEOIP_DBDATA_DIR/geoip-country.binCountry index built by npm run geoip; used when no geo header is present.
GEO_HEADERSsee belowCountry headers, first valid ISO-3166 code wins.
RETENTION_DAYS400Delete event files older than this.
MAX_EVENTS250000In-memory event cap; oldest events leave the index first.
RATE_LIMIT120Collection requests per minute per IP.
CONTENT_DIR./examples/static-siteFolder the page editor can read and write.
EDITORenabledSet 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"

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.

  1. Point CONTENT_DIR at a folder of .html files. The default is examples/static-site.
  2. Open the dashboard and choose Page editor in the sidebar.
  3. 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.
  4. Press Ctrl+S or Save page. The previous revision is written to file.html.bak.

Details and limits:

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:

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

SymptomLikely cause and fix
Dashboard is empty after adding the snippetLocalhost 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 errorsDo 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 consoledata-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 IPSet TRUST_PROXY=1 and make sure your proxy overwrites forwarding headers.
Locations panel is emptyRun 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 peopleThey share an IP and user agent, or you are behind a proxy that hides client IPs. Visitors are daily pseudonyms, not accounts.
429 responsesRate limit hit (120/min/IP by default). Raise RATE_LIMIT or fix the proxy IP issue.
401 on the dashboardADMIN_TOKEN is set. Sign in, or use Authorization: Bearer … for scripts.
Editor says disabledSet EDITOR=1 (or remove EDITOR=0) and restart. The editor is admin-only when a token is configured.
Editor lists no filesCheck CONTENT_DIR exists and contains .html files within three folders. Restart after changing the variable.
Save fails with 403/413The file path is outside CONTENT_DIR, the file is over 2 MB, or the session expired. Sign in and retry.
Port already in useChange PORT or stop the other process. On Windows: Get-NetTCPConnection -LocalPort 4000.
Docker data disappearsMount 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.

MethodPathPurpose
POST/api/v1/eventCollect an event (alias /api/collect).
GET/api/v1/healthLiveness, version, store status. Public.
GET/api/v1/sitesSite list, auth mode, editor availability.
GET/api/v1/stats?site=demo&range=30dAggregates; ranges 24h, 7d, 30d, 90d.
GET/api/v1/live?site=demoActive visitors and the last 30 minutes of events.
GET/api/v1/export?site=demo&range=30d&dataset=pagesCSV export.
GET/api/v1/pagesList editable HTML files.
GET/api/v1/pages/file?path=index.htmlPage metadata (title, size, backup).
PUT/api/v1/pages/fileSave { path, body, title }; writes a .bak.
POST/api/v1/login / /logoutCreate or clear the admin cookie.

CSV datasets: pages, entry, exit, events, outbound, sources, countries, devices, browsers, os.

Privacy notes

Cookie-free does not automatically mean consent-free in every jurisdiction. Confirm your legal basis and retention policy for your context.