docs/self-hosting
Self-hosting Dispatch
Run the whole thing on your own server: install, configure, proxy, keep it alive, and upgrade without losing the schedule.
ai setup
prompt · readyGet AI help setting this up
Paste this into Claude, ChatGPT, or Gemini. It plays senior DevOps engineer: asks what's already on your server, walks you through one stage at a time, and debugs from your real output when something fails.
works with claude · chatgpt · gemini · any conversational ai
01/overview
Your box, your schedule
Self-hosting means running both halves of Dispatch — the API (which owns the scheduler) and the web dashboard — on infrastructure you control, with your jobs in your own MySQL.
what you get
- The full product, MIT-licensed — not a gutted community edition
- Every job, payload, and delivery log in your own database
- Webhooks that can reach services inside your network — opt in with
ALLOW_PRIVATE_WEBHOOKS - No usage limits beyond what your server can do
what you own
- Database backups and credentials
- TLS, DNS, and the reverse proxy in front
- An SMTP/Gmail account for transactional email
- Applying upgrades and their migrations
02/prerequisites
What you need first
Node.js 20+
Runs both the API and the dashboard. The scheduler relies on modern fetch, which ships with Node 18+, but 20 is the supported floor.
MySQL 8+ or MariaDB
The only datastore. Prisma manages the schema; you manage the backups.
A Gmail App Password
Dispatch emails a verification code on signup and links for password resets, so transactional email must be configured. A Gmail account with a 16-character App Password is the path of least resistance.
A server with nginx + PM2
Any VPS works — Dispatch is two Node processes and a light DB poll, not a resource hog. The production setup assumes you can edit an nginx site config and read pm2 status output.
03/installation
Clone, configure, migrate, build
The repo holds both apps side by side: dispatch-backend (the API and scheduler) and dispatch-web (the dashboard).
01 · clone
git clone https://github.com/imtiyazsayyidpro/dispatchcd dispatch02 · configure the api
Create a MySQL database (and ideally a dedicated user), then copy .env.example to .env and fill it in. Every variable is documented in the reference below.
# dispatch-backend/.env # MySQL/MariaDB connection string — used by Prisma at runtime and for# migrations. Format: mysql://USER:PASSWORD@HOST:PORT/DATABASEDATABASE_URL=mysql://dispatch:change-me@localhost:3306/dispatch # Port the API listens on — keep in sync with the nginx upstream below.PORT=4000 # Transactional email. Signup sends a verification code, so these are# required. GMAIL_APP_PASSWORD is a 16-char App Password, not your login:# https://myaccount.google.com/apppasswordsGMAIL_USER=you@gmail.comGMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxx # Public URL of the dashboard — used to build password-reset links.FRONTEND_URL=https://dispatch.example.com # Only needed if the dashboard is served from a DIFFERENT origin than the# API (split-domain setup). Comma-separated list of allowed browser origins.CORS_ORIGINS=https://dispatch.example.com # It runs behind nginx, so trust the proxy for real client IPs (rate limiting).TRUST_PROXY=true03 · configure the dashboard
# dispatch-web/.env.local # Public origin of the Dispatch API — the browser calls it directly.# With the path-routed nginx setup below, it's the same origin as the# dashboard. No trailing slash, no /api/v1 suffix. Inlined at build# time: set it BEFORE `next build`, rebuild if it changes.NEXT_PUBLIC_API_URL=https://dispatch.example.com04 · migrate and build
# the APIcd dispatch-backendnpm install # also runs 'prisma generate' (postinstall)npx prisma migrate deploy # creates/updates the schema in MySQLnpm run build # the dashboardcd ../dispatch-webnpm installnpm run build # NEXT_PUBLIC_API_URL must be set by now04/running in production
PM2 keeps both alive
One ecosystem file declares both processes; PM2 restarts them on crash and on reboot.
// ecosystem.config.js — at the repo rootmodule.exports = { apps: [ { name: "api-dispatch", cwd: "./dispatch-backend", script: "npm", args: "start", instances: 1, // one box needs only one; safe to scale (see below) exec_mode: "fork", autorestart: true, env: { NODE_ENV: "production" }, }, { name: "dispatch-web", cwd: "./dispatch-web", script: "npm", args: "start", instances: 1, exec_mode: "fork", autorestart: true, env: { NODE_ENV: "production" }, }, ],};npm install -g pm2 pm2 start ecosystem.config.jspm2 save # persist the process listpm2 startup # then run the command it prints — survives reboots pm2 status # both apps should read "online"pm2 logs api-dispatchscaling the api
SCHEDULED → FIRING in a single conditional update) before it fires, so two instances will never double-send a webhook. The only caveat is that rate limiting is in-memory per process — add a shared store (e.g. Redis) if you want limits enforced globally across replicas. The dashboard is stateless; scale it freely.05/reverse proxy
nginx in front of both
The dashboard's browser code calls the API directly, so both have to be publicly reachable. Path-based routing puts them behind one domain — the simplest option, and it sidesteps CORS entirely.
# /etc/nginx/sites-available/dispatch# One domain, two upstreams: /api/* goes to the API, the rest to the# dashboard. Longest-prefix match means the /api/ block always wins. upstream dispatch_web { server 127.0.0.1:3000; }upstream dispatch_api { server 127.0.0.1:4000; } server { listen 80; server_name dispatch.example.com; # the API serves everything under /api (routes live at /api/v1/...) location /api/ { proxy_pass http://dispatch_api; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # everything else is the dashboard location / { proxy_pass http://dispatch_web; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}sudo ln -s /etc/nginx/sites-available/dispatch /etc/nginx/sites-enabled/dispatchsudo nginx -t && sudo systemctl reload nginx # TLS — certbot rewrites the config for HTTPSsudo certbot --nginx -d dispatch.example.comBecause the API is reached at the same origin as the dashboard, NEXT_PUBLIC_API_URL is simply https://dispatch.example.com — the web app appends /api/v1/… paths itself, and same-origin requests need no CORS config.
prefer separate domains?
api.example.com and app.example.com) with a server block each. Two extra steps then matter: set NEXT_PUBLIC_API_URLto the API's domain, and list the dashboard's origin in CORS_ORIGINS on the API — otherwise the browser blocks the cross-origin calls.06/environment variables
Every variable
The API reads a handful; the dashboard reads one. Anything else you find in an env file is not read by Dispatch.
dispatch-backend/.env
DATABASE_URLstring · mysql urlrequired
MySQL/MariaDB connection string, used by Prisma at runtime and by prisma migrate. Example: mysql://dispatch:secret@localhost:3306/dispatch
PORTnumber · default 4000optional
Port the API listens on. Keep it in sync with the nginx upstream. Example: 4000
GMAIL_USERstring · emailrequired
Gmail address that sends verification codes and reset links. Signup fails without working email. Example: you@gmail.com
GMAIL_APP_PASSWORDstringrequired
A 16-character Google App Password — not your normal account password.
FRONTEND_URLstring · urlrequired
Public origin of the dashboard, used to build links inside emails (password reset). Example: https://dispatch.example.com
CORS_ORIGINSstring · csvoptional
Comma-separated browser origins allowed to call the API. Only needed when the dashboard is on a different origin than the API; localhost is auto-allowed in development. Example: https://dispatch.example.com
TRUST_PROXYboolean · default offoptional
Set true when behind a reverse proxy so rate limiting and logs use the real client IP from X-Forwarded-For.
ALLOW_PRIVATE_WEBHOOKSboolean · default offoptional
When unset, the API blocks webhooks pointed at private/internal addresses (an SSRF guard). Set true only if you intentionally fire at services inside your own network.
dispatch-web/.env.local
NEXT_PUBLIC_API_URLstring · originrequired
Public origin the browser uses to reach the API — with the path-routed nginx setup, the same origin as the dashboard. No trailing slash, no /api/v1 suffix. Inlined at build time: it must be set before next build, and a change requires a rebuild. Example: https://dispatch.example.com
07/local development
Developing against Dispatch
Both halves run locally in dev mode, and there's a well-worn answer to testing webhooks on localhost.
# 1 · databasemysql -u root -e "CREATE DATABASE dispatch" # 2 · the API — terminal onecd dispatch-backendnpm install # also runs prisma generatecp .env.example .env # point DATABASE_URL at your MySQL, set GMAIL_*npx prisma migrate devnpm run dev # http://localhost:4000, hot-reloads # 3 · the dashboard — terminal twocd dispatch-webnpm installecho "NEXT_PUBLIC_API_URL=http://localhost:4000" > .env.localnpm run dev # http://localhost:3000testing webhooks locally
The question every developer hits first: "my handler runs on my laptop — what do I put in webhookUrl?" It depends on where Dispatch is running:
Dispatch running locally too? The scheduler fires from the backend process on your machine, so http://localhost:5000/hooks/test works as a webhookUrl directly — but note that loopback and private addresses are blocked by default, so set ALLOW_PRIVATE_WEBHOOKS=true in your local .env first.
Dispatch hosted somewhere else?It can't reach your localhost — you need a tunnel. ngrok is the standard move (and its public URL passes the SSRF guard):
# your webhook handler is listening on :5000ngrok http 5000Session Status onlineWeb Interface http://127.0.0.1:4040Forwarding https://f3a1-203-0-113-7.ngrok-free.app -> http://localhost:5000# schedule against the forwarding URLcurl -X POST https://dispatch.example.com/api/v1/jobs \ -H "Content-Type: application/json" \ -H "x-api-key: sk_your_key_here" \ -d '{ "title": "Local webhook test", "webhookUrl": "https://f3a1-203-0-113-7.ngrok-free.app/hooks/test", "fireAt": "2026-06-12T12:02:00Z", "payload": { "hello": "local" } }'ngrok field notes
- On the free plan the forwarding URL changes every restart — jobs scheduled against a dead tunnel will fail and retry into the void. Claim ngrok's free static domain so the URL survives restarts.
- The web interface at
http://127.0.0.1:4040shows every delivery ngrok forwarded — request body, your response, timing. It pairs perfectly with the Dispatch delivery log for debugging both sides. - Deliveries that failed while your tunnel was down are retried on the normal 30s → 1m → 5m backoff, so a quick tunnel restart often catches the retry.
08/upgrading
Pull, migrate, rebuild, restart
Migrations are additive and applied with prisma migrate deploy — it only runs what hasn't run yet.
cd dispatchgit pull # the APIcd dispatch-backendnpm installnpx prisma migrate deploy # applies any new migrations; safe to re-runnpm run build # the dashboardcd ../dispatch-webnpm installnpm run build # restart bothpm2 restart api-dispatch dispatch-webThe restart is safe for your schedule: on boot the API runs a catch-up sweep that fires any job whose fireAt passed while it was down, then keeps a background sweep running every 15 seconds. Nothing is lost in the restart window — though a quiet minute is still the considerate time to do it. Check pm2 logs for the scheduler start line afterwards.
09/known limitations
The honest list
Things Dispatch does not handle yet. Read this before you put it in front of anything critical.
No HMAC signing yet
Deliveries are not signed, so your endpoint can't cryptographically verify the sender. Use an unguessable token in the webhook URL as an interim check — the pattern is in the Using Dispatch guide.
Rate limiting is per-instance
The API rate-limits requests using in-process memory. On a single instance that's exactly right. If you run multiple API replicas, each enforces its own limit — wire in a shared store (e.g. Redis) for a global one. Job firing itself is already safe across replicas.
Email is required to sign up
Registration sends a verification code, so a working GMAIL_USER / GMAIL_APP_PASSWORDpair must be configured before anyone can create an account. There is no "skip email" mode yet.