Self-hosting

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#

ContainerJob
dbPostgres 16, data in a Docker volume.
appThe 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.
tunnelCloudflare's cloudflared, which connects Cloudflare to app:3000. Cloudflare serves HTTPS.
backupA 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#

  1. Put your domain on Cloudflare

    Buy or move the domain to Cloudflare (its registrar sells at cost).

  2. Create a tunnel

    Cloudflare dashboard, Zero Trust, Networks, Tunnels, Create a tunnel (Cloudflared). Copy the token. Add a Public hostname: pulse.yourdomain.com, service type HTTP, URL app:3000.

  3. 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.sh monthly from cron and set PULSE_GEOIP=/geoip/city.mmdb in .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.

  4. Prepare the server

    Create an Ubuntu 24.04 VPS, SSH in, and run:

    bash
    git clone <your repo> pulse && cd pulse
    PULSE_URL=https://pulse.yourdomain.com CLOUDFLARE_TUNNEL_TOKEN=<token> ./deploy/setup.sh

    The script installs Docker if it is missing, writes a .env with freshly generated secrets, builds and starts everything, and waits until /api/health answers.

  5. 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=closed to .env and run sudo docker compose up -d app: nobody new can sign up (email or Google), while existing accounts still sign in.

Run it#

TaskCommand
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.
Logssudo docker compose logs app --tail 100
Healthcurl 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_SITEsudo 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.gz every day and keeps the newest 14. A failed or empty dump never deletes older ones.
  • Set BACKUP_PASSPHRASE in .env to 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 .env too (somewhere other than this server): PULSE_SECRET is needed to read stored provider keys and keeps visitor ids stable, so a database restore without it loses both.
bash
./deploy/restore.sh backups/pulse-2026-10-19.sql.gz
# encrypted: BACKUP_PASSPHRASE=... ./deploy/restore.sh backups/pulse-2026-10-19.sql.gz.enc

Restore 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.

VariableWhat it does
PULSE_URLPublic URL, no trailing slash. Used in snippets and as the webhook address. Webhooks register themselves only when it starts with https://.
PULSE_SECRET32+ 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_PASSWORDPassword 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_URLOnly 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_TOKENFrom the tunnel you created.
TRUST_PROXYWhich 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_SITEOptional. A site id that makes Pulse track its own marketing pages. Create the site first. Needs a rebuild.
BACKUP_PASSPHRASEOptional. Encrypts the daily backups (AES-256).
RESEND_API_KEY, PULSE_EMAIL_FROMOptional. Email through Resend for alerts, digests and password resets. Both are needed, otherwise email is off. See alerts & reports.
CRON_SECRETOptional. 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_SECRETOptional. 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_SIGNUPclosed 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#

bash
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 request

Without 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 missing PULSE_SECRET or a database that is still starting.
The site loads but tracking returns 404 or 400
The snippet uses PULSE_URL from build time. After changing it, rebuild with sudo docker compose up -d --build app.
Webhooks say “manual”
PULSE_URL is not https://…, 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 use none with X-Real-IP set. The app logs [pulse] TRUST_PROXY=… but no … header once per start when the expected header is missing: check sudo docker compose logs app.
Disk is filling up
Events are kept until you delete the site, and backups keep 14 files. Check docker system df and prune old images with docker image prune (the update script already does).