Deployment
Requirements
- A Linux server, VPS, home server, NAS, or Docker-capable machine.
- Docker + Docker Compose (for the quickstart). A static Go binary is also available for bare-metal installs.
- A public HTTPS endpoint (via reverse proxy) if your Android device is not on the same network.
- For real push delivery: a Firebase project (see §12.1 of the PRD). Without it the server accepts notifications but cannot deliver them.
Quickstart (Docker Compose)
git clone https://codeberg.org/prelay/prelay.git
cd prelay
docker compose up --build -d
curl http://localhost:8080/healthz
# {"status":"ok"}That's the whole server. It runs SQLite on a named volume and survives restarts.
Create an admin token
# On the host, or exec into the container (the binary lives at /app/prelayd):
docker compose exec prelay-server /app/prelayd admin-tokenThis prints a prelay_1_… token once. Use it to create scoped tokens via
the API (see docs/api.md).
Send your first notification
curl -X POST http://localhost:8080/v1/notify \
-H "Authorization: Bearer $PRELAY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Hello","message":"Prelay is working!"}'The server encrypts and routes it to every registered active device.
Configuration
All configuration is via environment variables (sane defaults shown):
| Variable | Default | Purpose |
|---|---|---|
PRELAY_ADDR | :8080 | listen address |
PRELAY_DB_PATH | ./data/prelay.db | SQLite database path |
PRELAY_LOG_LEVEL | info | debug | info | warn | error |
PRELAY_LOG_FORMAT | text | text | json |
PRELAY_CLOCK_SKEW | 300s | accepted created_at skew for Mode B envelopes |
PRELAY_DELIVERY_TTL | 24h | how long delivery records are retained |
PRELAY_JANITOR_INTERVAL | 1h | how often expired records are purged |
PRELAY_RATE_LIMIT_RPS | 20 | per-token request rate (per second) |
PRELAY_RATE_LIMIT_BURST | 40 | per-token burst allowance |
PRELAY_TRUSTED_PROXIES | 0 | number of trusted reverse-proxy hops; 0 never trusts X-Forwarded-For. All per-IP rate limiters and the trial-per-IP gate use the direct peer unless this is set. Set to your proxy count when behind a reverse proxy |
PRELAY_FCM_CREDENTIALS | — | path to a Firebase service-account JSON; enables FCM delivery |
PRELAY_FCM_GOOGLE_SERVICES | — | path to a google-services.json; enables GET /v1/fcm-config for the app |
PRELAY_SAAS_MODE | false | enable multi-tenant hosted mode (Prelay Cloud) |
PRELAY_DB_URL | — | PostgreSQL URL (required in SaaS mode) |
PRELAY_TRIAL_DAYS | 14 | trial length for new accounts |
PRELAY_TRIAL_PER_IP_PER_DAY | 3 | max accounts per client IP that receive the trial per 24h (0 = unlimited) |
PRELAY_SIGNUP_ENABLED | true | set false to close new account registration; the signup form shows a closed notice |
PRELAY_MAX_SIGNUPS | -1 | cap on total accounts; -1 = unlimited, otherwise signups close once the count is reached |
PRELAY_TURNSTILE_SITE_KEY | — | Cloudflare Turnstile site key for the signup CAPTCHA (public); set together with the secret to enable |
PRELAY_TURNSTILE_SECRET_KEY | — | Turnstile secret key (server-side). Both keys must be set together; setting only one fails startup |
PRELAY_SESSION_TTL | 720h | absolute dashboard session lifetime from creation |
PRELAY_SESSION_IDLE_TTL | 24h | sliding idle window: activity renews the session, capped at the absolute lifetime (0 = disabled) |
PRELAY_PASSWORD_RESET_TTL | 30m | how long a password-reset link stays valid |
PRELAY_EMAIL_VERIFY_TTL | 24h | how long an email-verification link stays valid |
PRELAY_STRIPE_SECRET_KEY | — | enable Stripe billing |
PRELAY_STRIPE_WEBHOOK_SECRET | — | verify Stripe webhooks; required when billing is enabled (startup fails without it) |
PRELAY_BILLING_SYNC_INTERVAL | 1h | how often subscription state is reconciled with Stripe (heals lost webhooks; one pass also runs at startup) |
PRELAY_STRIPE_PRICE_{SOLO,PRO,TEAM} | — | Stripe price IDs per plan (monthly) |
PRELAY_STRIPE_PRICE_{SOLO,PRO,TEAM}_ANNUAL | — | annual price IDs; empty disables annual billing |
PRELAY_DASHBOARD_URL | — | web dashboard origin; required in SaaS mode and whenever Stripe billing is enabled (verification/reset/invite links are rendered from it, and Stripe redirects here) |
PRELAY_CORS_ORIGINS | — | comma-separated allowlist of browser origins for cross-origin dashboard/API calls (required when the dashboard is served from a different origin than the API) |
PRELAY_SUPPORT_EMAIL | — | support address surfaced to users and in public server metadata |
PRELAY_EMAIL_PROVIDER | resend | email driver: resend (HTTP) or smtp. Email is disabled only when neither PRELAY_RESEND_API_KEY nor PRELAY_SMTP_HOST is set |
PRELAY_EMAIL_FROM | — | sender address; required when email is enabled |
PRELAY_RESEND_API_KEY | — | enables email via the Resend HTTP API |
PRELAY_SMTP_HOST | — | enables email via SMTP (e.g. Mailtrap in dev); requires PRELAY_EMAIL_PROVIDER=smtp and PRELAY_EMAIL_FROM |
PRELAY_SMTP_PORT | 587 / 465 / 25 | SMTP port; defaults to 587 for starttls, 465 for implicit, 25 for none |
PRELAY_SMTP_USER / PRELAY_SMTP_PASSWORD | — | SMTP credentials (AUTH is used when a user is set) |
PRELAY_SMTP_TLS | starttls | starttls (explicit STARTTLS, required — never falls back to plaintext) | implicit (TLS from connect, e.g. port 465) | none (plaintext; trusted local relays only) |
PRELAY_METRICS_ADDR | — | listen address for the Prometheus /metrics endpoint; empty disables metrics |
PRELAY_METRICS_TOKEN | — | when set, /metrics requires Authorization: Bearer <token> |
PRELAY_METRICS_INTERVAL | 30s | how often service gauges are refreshed |
Billing checkout requires the monthly PRELAY_STRIPE_PRICE_{SOLO,PRO,TEAM} IDs:
the server starts without them, but /v1/billing/checkout fails for a plan with
no configured price. Annual billing additionally needs the matching _ANNUAL
IDs.
SaaS mode (Prelay Cloud)
The same binary runs the hosted, multi-tenant service with
PRELAY_SAAS_MODE=1. Hard startup requirements: PostgreSQL (PRELAY_DB_URL),
the dashboard origin (PRELAY_DASHBOARD_URL), and transactional email (accounts
gate on the verification email). Stripe is optional: set
PRELAY_STRIPE_SECRET_KEY to enable billing, and then
PRELAY_STRIPE_WEBHOOK_SECRET also becomes required.
export PRELAY_SAAS_MODE=1
export PRELAY_DB_URL=postgres://user:pass@host/prelay
export PRELAY_DASHBOARD_URL=https://dash.prelay.sh
export PRELAY_RESEND_API_KEY=re_live_... # or PRELAY_EMAIL_PROVIDER=smtp + PRELAY_SMTP_HOST
# Optional: enable billing (then the webhook secret is also required)
export PRELAY_STRIPE_SECRET_KEY=sk_live_...
export PRELAY_STRIPE_WEBHOOK_SECRET=whsec_...
prelayd serveStartup fails fast when a hard dependency is missing; a SaaS deployment never runs silently degraded.
In SaaS mode:
- accounts + dashboard sessions (
/v1/auth/*), resources tenant-scoped, - zero-knowledge enforcement —
POST /v1/notifyaccepts only encrypted Mode B envelopes, - Stripe billing: Solo/Pro/Team plans, 14-day trial, quotas,
prelayd admin-tokenis disabled (registration replaces it).
Single replica only (SaaS and self-hosted)
Run exactly one prelayd process per database (issue #97). Login
lockouts, TOTP/rotate challenges, and all rate-limiter/cooldown caches
are per-process memory: correct for the single-container deploy and the
single-replica SaaS, silently wrong under load balancing — a second
replica bypasses lockouts, misses challenges, and multiplies every
limit by the replica count. The janitor heartbeats this instance into
the database each sweep and logs an error plus the
prelay_live_peer_instances metric whenever another live instance is
seen; treat either as a paging-severity misconfiguration, not noise. Do
not scale out until these counters are externalized.
Web dashboard
The customer dashboard is a separate React Router app in prelay-dashboard/
(login, register, devices, tokens, sessions, billing). In development it
proxies /api to the SaaS server on localhost:9090. In production bake the
build-time vars: VITE_PRELAY_API (e.g. https://api.prelay.sh),
VITE_PRELAY_SITE_URL (marketing origin, e.g. https://prelay.sh; without it
the Terms/Privacy links 404), and VITE_PRELAY_DASHBOARD_URL (this dashboard's
own origin). Serve the built app behind the dashboard origin
(PRELAY_DASHBOARD_URL on the server).
Dashboard sign-in uses an HttpOnly Secure cookie, so the dashboard must be
served over HTTPS in production (cookies with the Secure flag are not sent
over cleartext). For local development, Chrome and Firefox accept Secure
cookies through the localhost Vite proxy; Safari does not — use Chrome/Firefox
or a local HTTPS certificate when testing the dashboard in Safari.
If the dashboard and API are served from different origins, add the dashboard
origin to PRELAY_CORS_ORIGINS on the server — it allowlists the cross-origin
Origin header for the CSRF check (credentials are already allowed by the
CORS middleware).
Reverse proxy + TLS
Run Prelay behind a TLS-terminating reverse proxy. With Caddy:
prelay.example.com {
reverse_proxy 127.0.0.1:8080
}With nginx:
server {
listen 443 ssl;
server_name prelay.example.com;
ssl_certificate /etc/letsencrypt/live/prelay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/prelay.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}Self-hosted deployments may expose http:// for LAN-only use; the Android
release build is HTTPS-only, while the debug build allows cleartext for
development.
When running behind a reverse proxy, set PRELAY_TRUSTED_PROXIES to the
number of proxy hops in front of the server (usually 1) so all per-IP
limiters and the trial-per-IP gate key on the real client IP from
X-Forwarded-For. The default 0
ignores the header entirely — immune to spoofing, but all clients then share
the proxy's limiter bucket.
Metrics (Prometheus)
PRELAY_METRICS_ADDR serves Prometheus metrics on a port separate from the
public API (e.g. 127.0.0.1:9100):
PRELAY_METRICS_ADDR=127.0.0.1:9100 prelayd serveThe endpoint exposes account/device counts, delivery volumes, email counters, and webhook-failure totals. Treat it as private: anyone who can scrape it learns your tenant counts and traffic volumes.
- Bind it to a loopback or private address (
127.0.0.1:9100, not:9100) whenever the network is not fully trusted. The server logs a warning at startup if the address binds all interfaces without a token set. - If the address must be reachable beyond the host (e.g. a shared scrape
network), set
PRELAY_METRICS_TOKENand have Prometheus authenticate:
scrape_configs:
- job_name: prelay
bearer_token: <the same token>
static_configs:
- targets: ["prelay-host:9100"]Requests without a valid Authorization: Bearer <token> header get 401.
The repo ships a ready-to-run monitor stack in docker-compose.monitoring.yml
(Prometheus :9095, Grafana :3002, Alertmanager :9093, plus Loki and
Alloy); the app stack sets PRELAY_METRICS_ADDR=:9091 on the shared network and
Prometheus scrapes prelay-server:9091. The 127.0.0.1:9100 example above is
the generic case.
Logs (Loki)
The monitor stack ships Loki (internal-only, no published port) and
Alloy, which tails the Docker container logs for prelay-server /
prelay-admin and pushes them to Loki. Grafana is provisioned with a Loki
datasource and a Prelay Logs dashboard (log volume by level, an errors
panel, and a free-text/container filter).
- The app stack sets
PRELAY_LOG_FORMAT=json, so each line carries alevelfield that Alloy promotes to a stream label (info/warn/error/debug). Override withPRELAY_LOG_FORMAT=textif you prefer plain lines (level filtering then falls back to text matching). - Labels stay low-cardinality:
job,container,level. Everything else (request ids, account ids, messages) is parsed in Grafana, not indexed. - Retention defaults to 14 days (
monitoring/loki/config.yml); adjust there and rebuild the image. - Alloy reaches the Docker API through a restricted
docker-socket-proxy(read-only: containers/events only,POSTdisabled) and never holds the host socket itself; only the proxy mounts it. Alloy's own HTTP API binds loopback inside its container. It only reads logs from the two Prelay app containers. - The server never logs notification plaintext; review any new log line so secrets (tokens, Stripe keys) cannot reach Loki.
Grafana Explore example queries:
{job="prelay"} |= "stripe" | json | level="ERROR"
{job="prelay", container="prelay-server"} |= "delete account"Firebase / FCM setup
See PRD §12.1 Firebase Requirements for the full reasoning and steps. In short:
- Create a Firebase project (do not enable Analytics or Gemini).
- Register an Android app for package
io.prelay.android; downloadgoogle-services.json. - In Project settings → Service accounts, generate a private key.
- Configure the server with both files:
services:
prelay-server:
build: ./server
restart: unless-stopped
ports:
- "8080:8080"
environment:
PRELAY_DB_PATH: /data/prelay.db
PRELAY_FCM_CREDENTIALS: /secrets/firebase-service-account.json # sending
PRELAY_FCM_GOOGLE_SERVICES: /secrets/google-services.json # app-side values
volumes:
- prelay-data:/data
- ./firebase-service-account.json:/secrets/firebase-service-account.json:ro
- ./google-services.json:/secrets/google-services.json:ro
volumes:
prelay-data:The shipped docker-compose.yml already contains the two FCM lines commented
out — uncomment them there rather than hand-writing the service.
The app has no build-time Firebase config: the server serves the app-side
values via GET /v1/fcm-config, and the app initializes Firebase at runtime.
A single prebuilt APK works with any deployment.
Security: the service account is a secret. Never commit it or share it. Every self-hosted deployment uses its own Firebase project — never a shared or the SaaS's project.
Bare-metal install
The release provides static binaries (no CGO, SQLite is embedded). Install and run as a systemd service:
[Unit]
Description=Prelay notification relay
After=network.target
[Service]
ExecStart=/usr/local/bin/prelayd serve
Environment=PRELAY_DB_PATH=/var/lib/prelay/prelay.db
Environment=PRELAY_FCM_CREDENTIALS=/etc/prelay/firebase-service-account.json
Restart=on-failure
User=prelay
Group=prelay
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable --now prelayBackups
Self-hosted deployments store everything in one SQLite file (device records and token hashes — never notification plaintext). Stop the service or use SQLite's online backup, then copy the file:
sqlite3 prelay.db ".backup /backups/prelay.db"Restoring to a new host is: install, place the DB, set the same env, start.
SaaS (Prelay Cloud) uses PostgreSQL, not SQLite. Back it up with your
managed database's backup, or pg_dump:
pg_dump "$PRELAY_DB_URL" > /backups/prelay.sqlUpgrading
- Server: pull, rebuild the image (
docker compose up --build -d), runprelayd— migrations apply automatically on startup. - Android app: install the new APK. The device keeps its identity and keys; on first launch the app refreshes its FCM token if it changed.