PulseWeaver

PulseWeaver is a self-hosted device address tracker and forward-auth gate for reverse proxies.
It keeps an up-to-date registry of your devices' current IP addresses and tells your reverse proxy whether to allow or
block each incoming request β no config file reloads, no static IP lists, and no complex identity providers required for
the services you want to protect.
[!NOTE]
PulseWeaver is not an authentication or authorization system. It is an IP gate. It does not verify who a user is;
it only checks whether the IP a request comes from belongs to a registered device. Think of it as a network-layer
bouncer, not a login system.
Screenshots
| Dashboard |
Devices |
 |
 |
| Device addresses |
 |
How it works
Two flows work together. Your reverse proxy calls GET /api/policy-engine/verify-ip on every request β PulseWeaver
answers 200 (allow) or 403 (block) from an in-memory cache. Your devices send periodic heartbeats (
POST /api/v1/heartbeat with an X-API-Key header) to register their current IP. As long as heartbeats keep coming,
the IP stays active. If a device has an address lease configured, the IP expires automatically when the TTL runs out.
π Detailed flow diagrams β
Why use this?
Many self-hosted services β Home Assistant, Jellyfin, Nextcloud, Grafana, etc. β are not designed to work behind complex
identity proxies like Authelia or authentik. Adding OIDC/SSO to them is often painful and sometimes breaks the app
entirely.
PulseWeaver takes a different approach: only accept connections from IP addresses you know. This drastically reduces
the attack surface without touching how the application itself authenticates users.
- No OIDC configuration, no identity provider to maintain.
- Works with any service out of the box.
- Devices with changing IPs (phones, laptops on roaming) stay covered automatically via heartbeat.
- Travel and flexible remote access: heartbeat + address lease gives you zero-config access from wherever your device
is.
Key concepts
| Concept |
Description |
| Device |
A logical endpoint (phone, laptop, serverβ¦) with a unique API key. |
| Address |
An IP address (v4 or v6) linked to a device. Can be enabled or disabled. |
| Heartbeat |
A device call to /api/v1/heartbeat that enables the caller's current IP as the device's active address. |
| Address lease |
A TTL* rule per device. When the TTL expires, the address is automatically disabled by PulseWeaver's background scheduler. |
| Forward Auth |
The GET /api/policy-engine/verify-ip endpoint. Your reverse proxy calls this on every request to check the client IP. |
TTL: Time-To-Live
Setup
Docker Compose (recommended)
The easiest way to run PulseWeaver alongside Caddy. The key points are:
- Both services must be on the same Docker network so
pulseweaver:8080 resolves.
POLICY_ENGINE_API_SECRET is defined once in your .env and injected into both containers.
TRUSTED_PROXY must be set to Caddy's container IP so PulseWeaver can correctly extract the real
client IP on both the heartbeat and forward-auth endpoints.
See Understanding TRUSTED_PROXY
for the full explanation.
# docker-compose.yml
name: proxy
services:
caddy:
image: caddy:2.11.1 # Example version, ensure you're running latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
PULSEWEAVER_POLICY_ENGINE_API_SECRET: ${PULSEWEAVER_POLICY_ENGINE_API_SECRET}
TZ: ${TZ}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
- ./caddy/data:/data
- ./caddy/config:/config
networks:
proxy:
ipv4_address: ${CADDY_IP} # Set specific IP so we can wire it to PulseWeaver TRUSTED_PROXY
depends_on:
- pulseweaver
pulseweaver:
image: ghcr.io/diegoguidaf/pulseweaver:dev
container_name: pulseweaver
restart: unless-stopped
expose: # No need to use "ports" if you access this via Caddy
- 8080
environment:
ADMIN_PASSWORD: ${PULSEWEAVER_ADMIN_PASSWORD}
SERVER_PORT: 8080
TRUSTED_PROXY: ${CADDY_IP} # Caddy's container IP on the shared network (single IP only, no CIDR)
POLICY_ENGINE_API_SECRET: ${PULSEWEAVER_POLICY_ENGINE_API_SECRET}
TZ: ${TZ}
volumes:
- ./pulseweaver/data:/data # Bind mount; ensure writable by non-root (chown 65532:65532) or use a named volume
networks:
- proxy
networks:
proxy:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/24
A minimal .env alongside it:
PULSEWEAVER_POLICY_ENGINE_API_SECRET=a-very-long-random-secret-at-least-32-chars
PULSEWEAVER_ADMIN_PASSWORD=a-strong-admin-password
CADDY_IP=172.20.0.2 # Caddy's fixed IP on the proxy network (single IP, no CIDR)
TZ=Europe/Madrid
[!TIP]
Give Caddy a fixed ipv4_address on the shared docker network and set TRUSTED_PROXY to that exact IP.
TRUSTED_PROXY accepts a single IP address only β CIDR ranges are not supported. Pinning Caddy's IP is the
simplest way to keep this stable.
First-run admin account
On first startup, PulseWeaver creates an admin user using the ADMIN_PASSWORD environment variable. Use a strong,
unique password and store it securely (e.g. in your .env file with restricted permissions).
Configuration reference
| Variable |
Required |
Default |
Description |
ADMIN_PASSWORD |
Yes |
β |
Password for the admin UI account (bootstrapped on first run). |
POLICY_ENGINE_API_SECRET |
Yes (min 32 chars) |
β |
Shared secret between Caddy and PulseWeaver. Minimum 32 characters. |
SERVER_PORT |
No |
8080 |
Port PulseWeaver listens on. |
TRUSTED_PROXY |
No |
β |
Single IP address of your reverse proxy. Required when running behind a proxy β see Understanding TRUSTED_PROXY. |
RULE_CHECK_INTERVAL |
No |
1m |
How often the scheduler checks for expired address leases. Set this to the lowest address lease TTL you'll use. |
DB_DIR |
No |
./data (Docker: /data) |
Directory for the SQLite database. See Data Persistence. |
TZ |
No |
UTC |
Application timezone for explicit wall-clock operations. Persisted timestamps are UTC; API timestamps are serialized as UTC RFC3339. |
LOG_LEVEL |
No |
info |
Log level: debug, info, warn, error. |
LOG_FORMAT |
No |
text |
Log format: text (human-readable) or json. |
LOG_COLOR |
No |
true |
Use coloured output for text format. |
Proxy integration
Caddy (forward_auth)
Add the forward_auth block to any site you want to protect:
your-service.example.com {
forward_auth http://pulseweaver:8080 {
uri /api/policy-engine/verify-ip
header_up X-Real-IP {http.request.remote.host}
header_up Authorization "Bearer {$PULSEWEAVER_POLICY_ENGINE_API_SECRET}"
}
reverse_proxy your-service:port
}
PulseWeaver's verify-ip endpoint is fail-closed: any missing header, invalid secret, or inactive IP returns 403.
Other reverse proxies
Any proxy that supports forward auth can work. The requirements are:
- Call
GET http://pulseweaver:8080/api/policy-engine/verify-ip before forwarding the request.
- Pass the real client IP in the
X-Real-IP header.
- Pass
Authorization: Bearer <POLICY_ENGINE_API_SECRET> to authenticate the proxy-to-PulseWeaver call.
- Allow the request through on
200; block on anything else.
Keeping devices connected
Devices send periodic heartbeats to keep their current IP active. There are several ways to set this up:
| Method |
Best for |
Details |
| Heartbeat Client |
Android, desktop (Linux/macOS/Windows) |
Dedicated app with background scheduling, network-awareness, and system tray support. |
| systemd timer / launchd agent |
Headless Linux & macOS servers |
Zero-dependency curl + OS scheduler. No app needed. |
| Tasker |
Android (DIY) |
HTTP request on a timer or network change event. |
| Manual |
Static IP devices |
Add addresses directly in the PulseWeaver UI β no heartbeat needed. |
The heartbeat endpoint (POST /api/v1/heartbeat) must be exposed without the forward-auth gate β see
Heartbeat Endpoint Setup for the Caddy configuration.
Device provisioning
If using the heartbeat-client, instead of manually entering a server URL and API key, an admin can generate a *
registration code* that fully
configures the heartbeat client in a single paste (or QR scan on mobile).
How it works
- The admin opens Devices β Provisioning in the PulseWeaver UI and creates an invite β choosing a device name,
owner, heartbeat interval, biometric settings, and an expiry window.
- PulseWeaver generates a single-use registration code (an opaque base64 string that encodes the server URL and a
random token). The admin shares the code as a QR (generated client-side in the browser) or copyable text.
- The user pastes the code in the
Heartbeat Client. The app decodes the server URL from
the code, calls the server's claim endpoint, and receives the full device configuration + API key.
- The device is created, the code is invalidated, and the app starts sending heartbeats β no manual setup required.
The code is retrievable by the admin until it is claimed or expires. After claim, both the code and the plaintext API
key are deleted from the database; an audit trail (timestamp, device link, key prefix) is retained.
For details on how the heartbeat client handles provisioning, see the
Heartbeat Client documentation.
Public endpoints
Device provisioning adds a second endpoint that must be reachable from devices without the forward-auth gate:
| Endpoint |
Purpose |
Auth |
POST /api/v1/heartbeat |
Ongoing heartbeats |
X-API-Key header |
POST /api/v1/register |
One-time device registration |
Registration code in body |
Proxy configuration
If you prefer not to expose the full PulseWeaver API publicly, configure your reverse proxy to only allow the two
device-facing endpoints. A single public domain with path-based filtering is the simplest approach:
# Only expose heartbeat + registration β everything else returns 404
device.example.com {
@device-endpoints path /api/v1/heartbeat /api/v1/register
handle @device-endpoints {
reverse_proxy pulseweaver:8080
}
respond 404
}
This keeps your admin panel and all other API endpoints accessible only from trusted networks (e.g. via a separate
site with the forward_auth gate), while devices can reach exactly the two endpoints they need.
[!TIP]
Set the Heartbeat server URL in the invite to https://device.example.com. The heartbeat client will use that
URL for both the initial registration and all subsequent heartbeats.
Security model
PulseWeaver is an IP gate β it reduces your attack surface by blocking unknown IPs before they reach any service.
It is not a user authentication system or a replacement for identity providers.
| β
Works well |
β οΈ Not enough on its own |
| Services that break behind SSO proxies |
Identifying individual users |
| Homelab with a small set of trusted networks |
Clients on ISP-level CGNAT |
| Reducing blast radius of unpatched CVEs |
Compromised active networks |
| Zero-config travel access via heartbeat + lease |
Replacing TLS or app-level auth |
PulseWeaver should be one layer in a defence-in-depth strategy, not the only one.
π **Deep dives:
** Security Model Β· Shared-IP Model Β· Understanding TRUSTED_PROXY
Development
PulseWeaver compiles to a single binary with the frontend SPA embedded. The frontend is built with Vite and
embedded at compile time β no separate web server needed in production.
Quick start for local development
For local runs (no Docker), the app writes to /data/data.db. Create that directory and make it writable before
starting: sudo mkdir -p /data && sudo chown $(whoami) /data.
# Backend (hot reload via Air)
make dev-back
# Frontend (Vite dev server, in a separate terminal)
make dev-front
Useful make targets
| Command |
Description |
make build |
Full production build β bin/pulseweaver |
make test |
Run all Go tests |
make lint-back |
Format + lint |
make api |
Regenerate backend + frontend types from api/openapi.yaml |
make migrate-up |
Apply pending database migrations |
make migrate-create |
Create a new migration pair |
Further reading
CODEBASE-Backend.md β backend package structure, domain boundaries, service lifecycle,
observer pattern.
CODEBASE-Frontend.md β frontend directory structure, routing, hook conventions, UX surfaces.
CLAUDE.md β full reference for AI-assisted development, conventions, and testing patterns.
api/openapi.yaml β API schema; single source of truth for all endpoints and types.
A note on AI usage
This project has not been vibe-coded. The author is a software developer with 9+ years of experience (primary stack:
Java/Kotlin). This is a first Go project and first React frontend. AI has been used extensively to accelerate tests and
frontend work, and as a learning tool β not as a replacement for understanding the code.