Self-hosting
Pulse runs on one small server. Docker starts Postgres, the app, a Cloudflare Tunnel (so you open no ports and manage no certificates) and daily database backups (every 24 hours). A 2 vCPU, 4 GB machine, such as a Hetzner CX23, is plenty to start.
What runs#
| Container | Job |
|---|---|
db | Postgres 16, data in a Docker volume. |
app | The Next.js server (standalone build). Listens on port 3000, bound to 127.0.0.1 only, with a read-only filesystem and dropped privileges. Runs database migrations itself on boot. |
tunnel | Cloudflare's cloudflared, which connects Cloudflare to app:3000. Cloudflare serves HTTPS. |
backup | A dump every 24 hours into ./backups, keeping the newest 14. Built from deploy/backup.Dockerfile (Postgres 16 plus the openssl command, which the stock image lacks and encryption needs). |
Install#
- Put your domain on Cloudflare
Buy or move the domain to Cloudflare (its registrar sells at cost).
- Create a tunnel
Cloudflare dashboard, Zero Trust, Networks, Tunnels, Create a tunnel (Cloudflared). Copy the token. Add a Public hostname:
pulse.yourdomain.com, service typeHTTP, URLapp:3000. - Turn on visitor location
Your domain, Rules, Managed Transforms, enable Add visitor location headers. Without it Pulse knows the country but not the city or region.
No Cloudflare in front (your own reverse proxy with
TRUST_PROXY=none)? Run./deploy/geoip-update.shmonthly from cron and setPULSE_GEOIP=/geoip/city.mmdbin.env: Pulse then looks the place up in a local IP Geolocation by DB-IP database (CC BY 4.0), in memory, and still stores no IP. - Prepare the server
Create an Ubuntu 24.04 VPS, SSH in, and run:
bashgit clone <your repo> pulse && cd pulse PULSE_URL=https://pulse.yourdomain.com CLOUDFLARE_TUNNEL_TOKEN=<token> ./deploy/setup.shThe script installs Docker if it is missing, writes a
.envwith freshly generated secrets, builds and starts everything, and waits until/api/healthanswers. - Create your account
Open
https://pulse.yourdomain.com/signup, sign up, and add your sites. Tracking snippets and webhook URLs now use your domain.Running it only for yourself? Add
PULSE_SIGNUP=closedto.envand runsudo docker compose up -d app: nobody new can sign up (email or Google), while existing accounts still sign in.
Run it#
| Task | Command |
|---|---|
| Update | ./deploy/update.sh first takes a backup (and stops if that fails), keeps the running app image as pulse-app:previous, pulls the latest code, rebuilds the app container, and waits for the health check. Migrations run on boot and your data stays in its volume. If the health check fails it prints the exact rollback command: sudo docker tag pulse-app:previous pulse-app:latest && sudo docker compose up -d --no-build app. |
| Logs | sudo docker compose logs app --tail 100 |
| Health | curl http://127.0.0.1:3000/api/health answers {"ok":true} when the app and database are up. |
| After changing PULSE_URL or NEXT_PUBLIC_PULSE_SITE | sudo docker compose up -d --build app (these are baked into static pages at build time, including the snippets in these docs). |
Backups and restore#
- The backup container writes
backups/pulse-YYYY-MM-DD.sql.gzevery day and keeps the newest 14. A failed or empty dump never deletes older ones. - Set
BACKUP_PASSPHRASEin.envto encrypt them with AES-256 (files end in.sql.gz.enc). Keep that passphrase somewhere else too: without it the backups cannot be restored. - Copy backups off the server now and then (another machine or object storage). A backup on the same disk does not survive losing the disk.
- Back up your
.envtoo (somewhere other than this server):PULSE_SECRETis needed to read stored provider keys and keeps visitor ids stable, so a database restore without it loses both.
./deploy/restore.sh backups/pulse-2026-10-19.sql.gz
# encrypted: BACKUP_PASSPHRASE=... ./deploy/restore.sh backups/pulse-2026-10-19.sql.gz.encRestore first decrypts and checks the file (nothing is touched if it is corrupt or the passphrase is wrong), then asks you to type restore, stops the app, replaces the whole database with the dump in a single transaction that stops at the first error, and starts the app again only if that worked. Try a restore on a spare machine before you need it.
Settings (.env)#
deploy/setup.sh creates .env from .env.example and fills the secrets. Never commit it.
| Variable | What it does |
|---|---|
PULSE_URL | Public URL, no trailing slash. Used in snippets and as the webhook address. Webhooks register themselves only when it starts with https://. |
PULSE_SECRET | 32+ random characters. Encrypts stored provider keys, signs sessions and salts visitor ids. Changing it signs everyone out, makes stored provider and Umami keys unreadable (reconnect them), and changes every visitor id. |
POSTGRES_PASSWORD | Password for the bundled database. With docker compose you do not set DATABASE_URL: compose builds it for the app (postgres://pulse:…@db:5432/pulse) and ignores one in .env. |
DATABASE_URL | Only for running without the compose file (local development, or your own external Postgres): the connection string of any Postgres. Without it Pulse uses an embedded database in .data/, for development only. |
CLOUDFLARE_TUNNEL_TOKEN | From the tunnel you created. |
TRUST_PROXY | Which header holds the visitor's real IP, used for rate limits and visitor ids. cloudflare (default) trusts cf-connecting-ip, right behind the tunnel. none trusts x-real-ip set by your own reverse proxy. Never use any on the public internet. |
NEXT_PUBLIC_PULSE_SITE | Optional. A site id that makes Pulse track its own marketing pages. Create the site first. Needs a rebuild. |
BACKUP_PASSPHRASE | Optional. Encrypts the daily backups (AES-256). |
RESEND_API_KEY, PULSE_EMAIL_FROM | Optional. Email through Resend for alerts, digests and password resets. Both are needed, otherwise email is off. See alerts & reports. |
CRON_SECRET | Optional. Lets an outside scheduler call POST /api/cron/tick with Authorization: Bearer <secret>. Pulse also runs its scheduler itself every minute. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Optional. Shows “Continue with Google” on sign-in. Redirect URI: https://pulse.yourdomain.com/api/auth/callback/google. |
PULSE_LS_* | Optional. Charge for Pulse itself through your own Lemon Squeezy store (API key, store id, plan variants, webhook secret). Without them billing is off: no plans, no site cap and no event limits, however many sites and events you have. |
PULSE_SIGNUP | closed stops new sign-ups with email or Google; existing users still sign in. Any other value (the default) leaves sign-up open. Create your own account first, then close it. |
Behind your own reverse proxy#
Skipping Cloudflare is possible: run Pulse behind nginx or Caddy that terminates HTTPS, set TRUST_PROXY=none, and make the proxy set X-Real-IP to the client address and overwrite any value the client sent. Only the app on 127.0.0.1:3000 should be reachable from the proxy.
Running locally for development#
pnpm install
createdb pulse # Postgres 16
cp .env.example .env.local # set DATABASE_URL, PULSE_SECRET, PULSE_URL=http://localhost:4322
pnpm dev --port 4322 # migrations run on the first requestWithout DATABASE_URL, Pulse falls back to an embedded Postgres in .data/. That is fine for a quick look but runs in one process only, so do not use it for anything real. Open /signup to create an account. Local tracking needs data-allow-localhost on the script tag.
Common issues#
- setup.sh says the app did not come up
- Read the logs:
sudo docker compose logs app --tail 100. The usual causes are a missingPULSE_SECRETor a database that is still starting. - The site loads but tracking returns 404 or 400
- The snippet uses
PULSE_URLfrom build time. After changing it, rebuild withsudo docker compose up -d --build app. - Webhooks say “manual”
PULSE_URLis nothttps://…, so Pulse could not register webhooks. Fix the URL, then disconnect and reconnect the provider.- Visitors all look like one visitor, or rate limits trip for everyone
- Pulse is not seeing real client IPs. Behind the Cloudflare Tunnel keep
TRUST_PROXY=cloudflare; behind your own proxy usenonewithX-Real-IPset. The app logs[pulse] TRUST_PROXY=… but no … headeronce per start when the expected header is missing: checksudo docker compose logs app. - Disk is filling up
- Events are kept until you delete the site, and backups keep 14 files. Check
docker system dfand prune old images withdocker image prune(the update script already does).