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.
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, thenrevoke-secondary.
main rebuilds every instance from the same commit, and the
pinned Dockerfile makes the resulting binaries identical. /v1/health
exposes commit and built_at_epoch so drift between instances is visible.| 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.
rmzlb/notifyd, branch main, compose path ./docker-compose.yml,
Autodeploy on.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.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).
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.
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.craie project is gone on its own. Migration 005_craie_project.sql
inserts a craie project and CRAIE templates into every fresh database;
migration 023_drop_unused_seed_project.sql removes them again wherever they were
never used. A fresh instance therefore starts with no project (checked on the RVK
instance, 2026-10-21). Delete craie by hand (DELETE /v1/admin/projects/craie)
only on an instance older than migration 023 that still lists it.git source needs a
push webhook on this repository pointing at
<dokploy>/api/deploy/compose/<refreshToken> (the token is on the compose
record). Dokploy only deploys when the pushed branch matches the configured one.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.
sqlx::migrate! at
boot on the same push; a destructive migration breaks every company at the
same minute. Add columns and tables, never drop or rename in the same
release as the code that stops using them.from_email sends as the instance default. Always
set from_email and from_name when creating a project.Cargo.lock, never implicitly.NOTIFY and stream tickets live in sse_tickets. Outbound pacing is per
replica: EMAIL_RATE_PER_SEC × replicas must stay under the provider limit
(Resend: 10 req/s per team).