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

Get 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

01

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.

02

MySQL 8+ or MariaDB

The only datastore. Prisma manages the schema; you manage the backups.

03

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.

04

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

shell
git clone https://github.com/imtiyazsayyidpro/dispatch
cd dispatch

02 · 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
# dispatch-backend/.env
 
# MySQL/MariaDB connection string — used by Prisma at runtime and for
# migrations. Format: mysql://USER:PASSWORD@HOST:PORT/DATABASE
DATABASE_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/apppasswords
GMAIL_USER=you@gmail.com
GMAIL_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=true

03 · configure the dashboard

dispatch-web/.env.local
# 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.com

04 · migrate and build

shell
# the API
cd dispatch-backend
npm install # also runs 'prisma generate' (postinstall)
npx prisma migrate deploy # creates/updates the schema in MySQL
npm run build
 
# the dashboard
cd ../dispatch-web
npm install
npm run build # NEXT_PUBLIC_API_URL must be set by now

04/running in production

PM2 keeps both alive

One ecosystem file declares both processes; PM2 restarts them on crash and on reboot.

ecosystem.config.js
// ecosystem.config.js — at the repo root
module.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" },
},
],
};
shell
npm install -g pm2
 
pm2 start ecosystem.config.js
pm2 save # persist the process list
pm2 startup # then run the command it prints — survives reboots
 
pm2 status # both apps should read "online"
pm2 logs api-dispatch

scaling the api

One API instance is plenty for a single box, but Dispatch is safe to scale: every job is claimed atomically (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
# /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;
}
}
shell
sudo ln -s /etc/nginx/sites-available/dispatch /etc/nginx/sites-enabled/dispatch
sudo nginx -t && sudo systemctl reload nginx
 
# TLS — certbot rewrites the config for HTTPS
sudo certbot --nginx -d dispatch.example.com

Because 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?

You can instead give the API and dashboard their own domains (e.g. 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_URL

string · mysql urlrequired

MySQL/MariaDB connection string, used by Prisma at runtime and by prisma migrate. Example: mysql://dispatch:secret@localhost:3306/dispatch

PORT

number · default 4000optional

Port the API listens on. Keep it in sync with the nginx upstream. Example: 4000

GMAIL_USER

string · emailrequired

Gmail address that sends verification codes and reset links. Signup fails without working email. Example: you@gmail.com

GMAIL_APP_PASSWORD

stringrequired

A 16-character Google App Password — not your normal account password.

FRONTEND_URL

string · urlrequired

Public origin of the dashboard, used to build links inside emails (password reset). Example: https://dispatch.example.com

CORS_ORIGINS

string · 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_PROXY

boolean · 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_WEBHOOKS

boolean · 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_URL

string · 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.

dev setup
# 1 · database
mysql -u root -e "CREATE DATABASE dispatch"
 
# 2 · the API — terminal one
cd dispatch-backend
npm install # also runs prisma generate
cp .env.example .env # point DATABASE_URL at your MySQL, set GMAIL_*
npx prisma migrate dev
npm run dev # http://localhost:4000, hot-reloads
 
# 3 · the dashboard — terminal two
cd dispatch-web
npm install
echo "NEXT_PUBLIC_API_URL=http://localhost:4000" > .env.local
npm run dev # http://localhost:3000

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

shell
# your webhook handler is listening on :5000
ngrok http 5000
ngrok output
Session Status online
Web Interface http://127.0.0.1:4040
Forwarding https://f3a1-203-0-113-7.ngrok-free.app -> http://localhost:5000
schedule against the tunnel
# schedule against the forwarding URL
curl -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:4040 shows 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.

shell
cd dispatch
git pull
 
# the API
cd dispatch-backend
npm install
npx prisma migrate deploy # applies any new migrations; safe to re-run
npm run build
 
# the dashboard
cd ../dispatch-web
npm install
npm run build
 
# restart both
pm2 restart api-dispatch dispatch-web

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

01

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.

02

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.

03

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.