A self-hosted web push notification gateway. Single Go binary, embedded BoltDB, no external dependencies. You register one or more browsers, create named send endpoints, then POST to an endpoint URL from any script or webhook to deliver a browser notification — no app, no email, no third-party service.
Open source. A public instance runs at push8030.com, but anyone can self-host their own.
Designed to bridge tools like Grafana, Alertmanager, or custom shell scripts to browser notifications without middleware. Each send endpoint has its own URL and configurable behaviour: preset field values, control over which fields callers can override, choice of input format (JSON / form / HTTP headers), and optional token authentication.
- Visit the landing page and click Get started — the server assigns a random memorable ID (e.g.
happy-otter-42) and registers the current browser (stores a Web Push subscription). - Rename the profile inline at the top of
/{username}if you want something different — old URL 404s after rename. - Create a send endpoint (get back a URL like
/api/send/<token>) and/or a web form (get back a URL like/{username}/<slug>). - POST to the endpoint URL from anywhere; fill in the form from any browser — all targeted browsers receive the notification via Web Push.
- The service worker (
sw.js) receives the push event and callsshowNotification.
Browser registration uses a pairing-code flow so the same profile can be accessed from multiple browsers without a password. A profile and its username are freed when the last browser is removed.
Usernames must be 2–32 characters (letters, digits, hyphens, underscores). A blocklist of reserved names is enforced at registration and rename time — these include names that conflict with top-level routes (api, docs, sw) and administratively sensitive names (admin, root, support, etc.).
go build -o push8030 .The result is a single statically-linked binary with all HTML/JS/CSS embedded. Copy it to a server, make it executable, and run it. All state lives in a single BoltDB file (push8030.db) that is created on first run.
PORT=8080 DB_PATH=/var/lib/push8030/push8030.db ./push8030Environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP listen port |
DB_PATH |
push8030.db |
Path to BoltDB file |
PUSH8030_ADMIN_SLUG |
(unset) | URL path for the admin page (e.g. secret-admin → /secret-admin) |
PUSH8030_ADMIN_PASSWORD |
(unset) | Passphrase to log in to the admin page |
The binary speaks plain HTTP only — there is no built-in TLS. Web Push requires HTTPS (browsers refuse to register service workers over plain HTTP on non-localhost origins), so you must run it behind a reverse proxy (Caddy, nginx, Traefik, etc.) that terminates TLS. Example with Caddy:
push.example.com {
reverse_proxy 127.0.0.1:8080
}
A minimal unit file:
[Unit]
Description=push8030 web push gateway
After=network.target
[Service]
User=push8030
Group=push8030
WorkingDirectory=/var/lib/push8030
ExecStart=/opt/push8030/push8030
Environment=PORT=8080
Environment=DB_PATH=/var/lib/push8030/push8030.db
Restart=on-failure
[Install]
WantedBy=multi-user.targetThe whole service state is one file — push8030.db. Snapshot it while the service is stopped, or use bbolt to copy it live.
| File | Role |
|---|---|
main.go |
Globals, bucket names, resource caps, initDB, initTemplates, newMux, main, and the four static handlers (/, /docs, /sw.js, /api/vapid-public-key) |
types.go |
All data structs and type constants (homeRecord, subRecord, EndpointConfig, browserInfo, etc.) |
ratelimit.go |
In-memory rate limiter and per-route limit configs |
helpers.go |
Shared utilities: clientIP, scheme, token/code generation, validUsername, labelFromUA, profileVapidKeys, applyConfigDefaults |
usernames.go |
Adjective/noun wordlists and generateRandomUsername used for zero-friction signup |
handlers_auth.go |
VAPID key endpoints, profile page, browser registration (/api/register, /api/join, etc.), authBrowser, pairing code |
handlers_browsers.go |
Browser CRUD (list, rename, delete, prune), guest subscribe/unsubscribe, profile settings |
handlers_endpoints.go |
Send endpoint and web form CRUD, endpoint config, form slug update |
handlers_send.go |
handleSend, handleFormSend, form page/config, event helpers (appendEvent, countRecentEvents), field parsing and resolution |
handlers_admin.go |
Admin dashboard — stats aggregation, passphrase auth (cookie-based), profile impersonation |
static/profile.html |
Single-page profile UI — browser list, endpoint list, config panel, curl example, test button. Rendered server-side as a Go template (injects username). All subsequent data loaded via fetch. |
static/form.html |
Web form page — allows anyone with the form URL to send a notification via a browser form. Rendered server-side (injects username and formToken). |
static/index.html |
Landing page — one-click browser registration; server assigns a random memorable profile ID |
static/sw.js |
Service worker — handles push events and notificationclick. Uses skipWaiting + clients.claim for immediate activation. |
All state is in a single BoltDB file (push8030.db). Ten buckets:
| Bucket | Key | Value |
|---|---|---|
homes |
username | homeRecord — profile creation time, guest signups flag, per-profile VAPID key pair |
subs |
browser token (hex) | subRecord — Web Push subscription (endpoint + auth/p256dh keys), label, guest flag, last push status/time, push count |
endpoints_index |
push endpoint URL | browser token (dedup guard) |
send_endpoints |
send token (hex) | sendEndpRecord — name, username, browser token list, EndpointConfig, form token, form_only flag, send/form counts |
paircodes |
6-digit code | pairCodeRecord — username + expiry (5 min TTL) |
vapid |
"private" / "public" |
Global VAPID key pair (generated once on first run; used as fallback for profiles without their own keys) |
form_tokens |
username/formToken |
send endpoint token — maps form slugs to their send endpoint |
endp_events |
token + timestamp | empty — timestamped invocation log for per-hour endpoint counts |
browser_events |
token + timestamp | empty — timestamped push log for per-hour browser counts |
form_events |
token + timestamp | empty — timestamped form submission log for per-hour form counts |
Event buckets store keys of the form <32-byte-token> + 0x00 + <8-byte big-endian nanosecond timestamp>. Only events from the last 2 hours are retained; older entries are pruned on write.
Each send endpoint carries an EndpointConfig with eight notification fields (msg, title, ttl, urgency, topic, icon, url, tag). Each field is a FieldConfig{Value, Override}:
Value— preset sent on every notification regardless of callerOverride: true— caller is allowed to supply/replace the value
Three request formats: json (default), form, headers (reads X-Msg, X-Title, etc.).
Two auth modes: header or query — both compare a key/value token.
Default for new endpoints: msg has Override: true, Value: "Hello World" — so the curl example and test button are immediately usable.
GET / landing page
GET /{username} profile page
GET /{username}/{formToken} web form page
GET /sw.js service worker
GET /api/vapid-public-key global VAPID public key
GET /api/profile/{username}/vapid-public-key per-profile VAPID public key (used when subscribing)
GET /api/profile/{username}/vapid-keys per-profile VAPID key pair — public + private (owner auth required)
POST /api/check-browser check if browser is registered
POST /api/register register new profile + browser
POST /api/recover start pairing from existing profile
POST /api/join complete pairing with code
PATCH /api/profile/{username} rename profile (old URL 404s after rename)
POST /api/profile/{username}/link-code generate pairing code
GET /api/profile/{username}/browsers list browsers
PATCH /api/profile/{username}/browsers/{token} rename browser
DELETE /api/profile/{username}/browsers/{token} remove browser (cleans up profile if last)
DELETE /api/profile/{username}/browsers prune all dead (410/404) browsers
GET /api/profile/{username}/settings get profile settings (guest signup toggle)
PUT /api/profile/{username}/settings update profile settings
POST /api/profile/{username}/subscribe register a guest browser on this profile
DELETE /api/profile/{username}/subscribe unregister a guest browser
GET /api/profile/{username}/endpoints list send endpoints and web forms
POST /api/profile/{username}/endpoints create send endpoint
POST /api/profile/{username}/forms create standalone web form
PATCH /api/profile/{username}/endpoints/{token} rename / retarget endpoint or form
PUT /api/profile/{username}/endpoints/{token}/config update EndpointConfig
PATCH /api/profile/{username}/endpoints/{token}/form update form slug
DELETE /api/profile/{username}/endpoints/{token} delete endpoint or form
GET /api/form/{username}/{formToken} get public form config (no auth credentials)
POST /api/form/{username}/{formToken}/send send notification via web form
POST /api/send/{endpointToken} send notification (unauthenticated by default)
Profile management routes require a X-Browser-Endpoint header matching a registered (non-guest) browser on that profile.
Any visitor can subscribe to receive notifications from a profile via POST /api/profile/{username}/subscribe. Guest browsers:
- Appear in the owner's browser list (marked
is_guest: true) - Receive pushes from all send endpoints that include their token
- Cannot access profile management routes
- Can unsubscribe themselves via
DELETE /api/profile/{username}/subscribe - Are cleaned up automatically when the last owner browser removes the profile
The profile owner can disable guest signups via the settings API.
Web forms are first-class objects, created independently from send endpoints via POST /api/profile/{username}/forms. Each form gets its own URL at /{username}/{formToken} — anyone with the URL can submit a notification via their browser. The form slug defaults to a random hex token and can be customised. Auth credentials are never sent to the form page.
Forms are stored in the same send_endpoints bucket as API endpoints and share the same per-profile limit. They are distinguished by form_only: true in their record — a flag that suppresses the webhook URL and hides request-format and authentication config (which only apply to webhook sends).
Each profile gets its own VAPID key pair, generated at registration time and stored in the profile's homeRecord. This isolates push abuse by user — a compromised or revoked profile's key pair has no effect on any other profile. The global key pair (stored in the vapid bucket) is kept only as a fallback for profiles that pre-date per-profile key generation.
The profile page provides two download actions, both accessible only to authenticated profile owners:
- Download VAPID keys — downloads
vapid-keys-{username}.jsoncontaining the profile'spublic_keyandprivate_key. - Download (per-browser row) — downloads
push-{label}.jsoncontaining everything needed to send a push notification to that browser from any machine without access to this server:
{
"vapid_public_key": "...",
"vapid_private_key": "...",
"subscription": {
"endpoint": "https://fcm.googleapis.com/...",
"keys": {
"auth": "...",
"p256dh": "..."
}
}
}This bundle can be used directly with any Web Push library (e.g. webpush-go, web-push for Node) to deliver notifications offline — without routing through this server.
An optional admin dashboard is available when both PUSH8030_ADMIN_SLUG and PUSH8030_ADMIN_PASSWORD are set. If either is unset, the admin routes are not registered and the page is unreachable.
The dashboard lists all profiles with basic stats (owner browsers, guest subscribers, send endpoints, total pushes) and lets you silently impersonate any profile — viewing and managing it exactly as the owner would, without needing their browser subscription.
github.com/SherClockHolmes/webpush-go— VAPID signing and Web Push dispatchgo.etcd.io/bbolt— embedded key/value store- No frontend build step; all JS is vanilla, inlined in
profile.html
MIT — see LICENSE.