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-token

This 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):

VariableDefaultPurpose
PRELAY_ADDR:8080listen address
PRELAY_DB_PATH./data/prelay.dbSQLite database path
PRELAY_LOG_LEVELinfodebug | info | warn | error
PRELAY_LOG_FORMATtexttext | json
PRELAY_CLOCK_SKEW300saccepted created_at skew for Mode B envelopes
PRELAY_DELIVERY_TTL24hhow long delivery records are retained
PRELAY_JANITOR_INTERVAL1hhow often expired records are purged
PRELAY_RATE_LIMIT_RPS20per-token request rate (per second)
PRELAY_RATE_LIMIT_BURST40per-token burst allowance
PRELAY_TRUSTED_PROXIES0number 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_MODEfalseenable multi-tenant hosted mode (Prelay Cloud)
PRELAY_DB_URL—PostgreSQL URL (required in SaaS mode)
PRELAY_TRIAL_DAYS14trial length for new accounts
PRELAY_TRIAL_PER_IP_PER_DAY3max accounts per client IP that receive the trial per 24h (0 = unlimited)
PRELAY_SIGNUP_ENABLEDtrueset false to close new account registration; the signup form shows a closed notice
PRELAY_MAX_SIGNUPS-1cap 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_TTL720habsolute dashboard session lifetime from creation
PRELAY_SESSION_IDLE_TTL24hsliding idle window: activity renews the session, capped at the absolute lifetime (0 = disabled)
PRELAY_PASSWORD_RESET_TTL30mhow long a password-reset link stays valid
PRELAY_EMAIL_VERIFY_TTL24hhow 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_INTERVAL1hhow 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_PROVIDERresendemail 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_PORT587 / 465 / 25SMTP 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_TLSstarttlsstarttls (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_INTERVAL30show 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 serve

Startup 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/notify accepts only encrypted Mode B envelopes,
  • Stripe billing: Solo/Pro/Team plans, 14-day trial, quotas,
  • prelayd admin-token is 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 serve

The 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_TOKEN and 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 a level field that Alloy promotes to a stream label (info/warn/error/debug). Override with PRELAY_LOG_FORMAT=text if 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, POST disabled) 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:

  1. Create a Firebase project (do not enable Analytics or Gemini).
  2. Register an Android app for package io.prelay.android; download google-services.json.
  3. In Project settings → Service accounts, generate a private key.
  4. 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.target
systemctl daemon-reload
systemctl enable --now prelay

Backups

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

Upgrading

  • Server: pull, rebuild the image (docker compose up --build -d), run prelayd — 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.

On this page