notifyd

Logo

Self-hosted notification server. One Rust binary, Postgres only. Email, SMS, WhatsApp, push, in-app inbox. Operated by your agent over MCP. Open source, MIT.

View the Project on GitHub rmzlb/notifyd

Deployments — one notifyd per company

notifyd is deployed once per company, always from this repository, always from main, through a Dokploy Docker Compose service that points at ./docker-compose.yml. Everything company-specific lives in the Dokploy environment of that service; nothing company-specific lives in this repo. No secret value is ever written here, only variable names.

Keys since migration 020. The database stores only SHA-256 hashes of project keys. A lost key cannot be read back from the database: issue a new one with POST /v1/admin/projects/:id/rotate-key, update the application, then revoke-secondary.

Why per company, not one shared instance

Inventory (verified 2026-09-05, belote rows 2026-10-21)

Company Server (Dokploy alias) Dokploy service Public URL Notes
CRAIE CRAIE’s own server (own Dokploy) compose from this repo, autodeploy main notifyd.craie.ctrlnz.com Dedicated instance, redeploys within ~5 min of a push to main.
sqare + helmai square compose novu/notifyd-stack notifyd.ctrlnz.com Shared by two companies. Global sender is Helmai; the sqare project has its own from_email. Its stale craie project (created 2026-06, no key hash, zero jobs) is gone (checked 2026-09-25). The orphan Application notifyd/notifyd (2026-03) was removed on 2026-09-25 (app, database and empty Dokploy project; its containers had no network and no connection; a verified dump is kept on the server). Sends SMS through Twilio (SMS_PROVIDER=twilio): PUBLIC_URL is required for SMS delivery status (/webhooks/twilio/status, docs/CONNECTORS.md). SMSMODE_API_KEY (smsmode channel sqhealm, TRANSACTIONAL) is set since 2026-10-17 for the switch to smsmode (docs/CONNECTORS.md § Switching provider); SMSMODE_WEBHOOK_SECRET is set since 2026-10-18 and must equal the secret entered in smsmode (Paramètres > Webhooks), or smsmode delivery reports are refused (403).
Philoé philoe compose notifyd (git source, autodeploy main via a GitHub webhook on this repo) internal http://notifyd:3400; public https://api-os.philoeparis.com/notifyd (path route on the API host, stripPath) for the admin in-app inbox Created 2026-09-05. Path route instead of a dedicated hostname because no Cloudflare DNS token was available; switch to notifyd-os.philoeparis.com when one exists. Project philoe, from_email in philoeparis.fr.
Baaton belote (the Dokploy shared with RVK and belote) compose baaton/notifyd, raw compose (not this repository’s file), image ghcr.io/rmzlb/notifyd:0.4.0, autodeploy off internal http://baaton-notifyd:3400 (dedicated alias), used by baaton/api Checked 2026-10-21. Sends through Cloudflare (EMAIL_PROVIDER=cloudflare). A push to main does not update it: it stays on 0.4.0 until its image tag is raised.
RVK belote compose rvk/notifyd (Dokploy GitHub app, autodeploy main), app name rvk-notifyd-2raxon internal http://rvk-notifyd-2raxon-notifyd-1:3400 (container name, see Two instances on one server) Created 2026-10-21. Project rvk, from_email no-reply@rvk.ctrlnz.com; the sending subdomain rvk.ctrlnz.com is onboarded in Cloudflare Email Service. Sends through Cloudflare (EMAIL_PROVIDER=cloudflare) with an account token limited to Email Sending Write (rvk-notifyd-email-sending, no zone access); first send accepted 2026-10-21. No public route: a temporary path route was used for the setup and removed.

The CRAIE, square and Philoé instances use the same Resend team (every company domain is verified there); Baaton and RVK send through Cloudflare Email Service. A per-company Resend team would need a per-instance RESEND_API_KEY, which this layout already allows.

Keep this table current: it is the only place that says which instance serves which company.

Creating an instance (Dokploy)

  1. Project → Create Service → Compose. Provider: GitHub, repository rmzlb/notifyd, branch main, compose path ./docker-compose.yml, Autodeploy on.
  2. Environment (names; generate secrets with openssl rand -hex 32): POSTGRES_PASSWORD, JWT_SECRET, ADMIN_API_KEY, RESEND_API_KEY (the company’s Resend team), EMAIL_FROM, EMAIL_FROM_NAME, CORS_ORIGINS (the company’s back-office origin), PORT=3400, RUST_LOG=notifyd=info. Optional: RESEND_WEBHOOK_SECRET (only if this instance, and not the application, is the consumer of the Resend webhooks), push/SMS connector variables.
  3. Deploy. docker compose attaches the container to dokploy-network under the alias notifyd, so applications on the same server reach it at http://notifyd:3400 without leaving the host. Add a public domain in Dokploy only if browsers must reach it (in-app inbox over SSE).

    Two instances on one server. The compose service name notifyd is a DNS name on dokploy-network as well, so with two instances on one server http://notifyd:3400 answers from either of them. Address each instance by a name of its own: its container name <app name>-notifyd-1 (RVK), or a dedicated alias in a raw compose (Baaton).

  4. Create the company project, from the server (docker exec into the container, or any host that can reach the instance):

    curl -s -X POST "$NOTIFYD_URL/v1/admin/projects" \
      -H "x-api-key: $ADMIN_API_KEY" -H 'content-type: application/json' \
      -d '{"id":"<company>","name":"<Company>","channels":["email","in_app"],
           "from_email":"<sender@company-domain>","from_name":"<Company>"}'
    

    The response carries the project API key: it goes into the application’s NOTIFYD_API_KEY, nowhere else.

  5. Check: GET /v1/health returns status: ok, the current commit, and a fresh built_at_epoch; GET /v1/admin/projects lists the project with its from_email.

Two things about every new instance

Observability

GET /v1/metrics/prometheus (admin key as Authorization: Bearer) exposes outcomes, provider errors by kind, lane pauses, provider latency, queue depth and the age of the oldest waiting job per priority band. Point the Dokploy monitoring stack (or any Prometheus) at each instance. /v1/health carries commit and built_at_epoch for drift checks.

notifyd’s own logs record request paths without their query string. The reverse proxy is another matter: Dokploy’s Requests toggle turns on the Traefik access log (accessLog in /etc/dokploy/traefik/traefik.yml, JSON in /etc/dokploy/traefik/dynamic/access.log), and Traefik’s RequestPath field holds the query string, so every stream ticket lands there. A ticket is dead within 30 s and after one use, but keep query strings out anyway: on Traefik v3.7.2 or later add fields: {queryParameters: {defaultMode: drop}} under accessLog; on older versions only fields: {names: {RequestPath: drop}} exists, which drops the whole path. Toggling Requests again rewrites the accessLog block and removes this setting.

Rules that keep N instances safe