Dispatch runs anywhere Docker runs. The stack is three containers (app, worker and PostgreSQL) described in a single docker-compose.yml. The only thing you configure in a file is your domain. Secrets are generated on first boot, and everything else is set up in the browser.
Using Coolify? Follow the Coolify guide instead.
- Requirements
- Quick start
- First-run setup
- Reverse proxy & TLS
- Where data and secrets live
- Operations: logs, updates, backups
- Troubleshooting
| Minimum | Recommended | |
|---|---|---|
| CPU | 1 vCPU | 2 vCPU |
| Memory | 1 GB | 2 GB+ |
| Disk | 10 GB | 20 GB + expected mail and attachments |
| Architecture | amd64 or arm64 |
You also need:
- Docker Engine 24+ with the Compose plugin (
docker compose version) - A domain or subdomain, e.g.
mail.example.com, with anA/AAAArecord pointing to the server - Ports 80 and 443 reachable from the internet (for HTTPS certificates), and outbound access to your mail providers' IMAP (993) and SMTP (465/587) ports
A small cloud VM (e.g. 2 vCPU / 4 GB) comfortably serves a team of dozens with several shared inboxes. Memory grows mainly with the number of connected inboxes that the worker keeps in sync.
mkdir -p /opt/dispatch && cd /opt/dispatch
# 1. Get the compose file and the Caddy override (automatic HTTPS)
curl -fsSLO https://raw.githubusercontent.com/codextde/dispatch/main/docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/codextde/dispatch/main/docker-compose.override.example.yml -o docker-compose.override.yml
# 2. Set your domain (the only setting)
echo "DOMAIN=mail.example.com" > .env
# 3. Start
docker compose up -dThen open https://mail.example.com/setup and enter the one-time setup code from docker compose logs app | grep -A2 "setup code" (First-run setup). The first start takes a moment: Postgres initializes, migrations run, and Caddy obtains a certificate.
Check that everything is healthy:
docker compose ps # app, worker, db (and caddy) should be "healthy" / "running"
curl -s https://mail.example.com/api/health # {"status":"ok","version":"…","db":"ok","uptime":…}Already running a reverse proxy? Skip the override file and see Reverse proxy & TLS.
Trying it locally?
DOMAIN=localhost:3000with the override's "Option B" (publish127.0.0.1:3000:3000) gives youhttp://localhost:3000.
The setup wizard at /setup runs once. To create the owner account, you need a one-time setup code. While no owner exists, Dispatch prints the code to the app logs on start, and again when /setup is opened (at most once a minute):
docker compose logs app | grep -A2 "setup code"
# Dispatch first-run setup code: ABCD-EFGH-JKLM
# Open https://mail.example.com/setup and enter this code
# to create the owner account of this instance.The code stays the same across restarts. It's stored in /data/secrets/setup-code and deleted when setup completes. Upper/lower case and dashes don't matter when typing it, and there's a limit of 10 attempts per hour per IP.
The wizard steps:
- Welcome & system check. Confirms the database connection, that the data directory is writable, and the public URL (HTTPS).
- Owner account. Enter the setup code, your name and your email. This creates the super admin and signs you in right away; no email needed.
- Instance.
- Instance name.
- Mode: Private (just your company) or Public SaaS.
- Who can sign up: invite only, allowed email domains, or anyone.
- Whether the public website is shown.
- Email delivery. Amazon SES, SMTP or Log only, with a Send test email button. You can skip this and configure it later.
- First workspace. Name and URL, optionally with demo data to explore. Finishing takes you to the workspace inbox.
If you lose your session halfway through, /setup asks you to sign in and resumes where you left off. After completion, /setup redirects to the app.
Why a setup code? A freshly deployed instance is reachable by anyone who knows its URL. Only someone who can read the server logs can get the code, so strangers can't claim your instance as its owner. The code is no longer needed once setup is complete.
Until email delivery is configured (provider Log only), Dispatch doesn't send emails. It prints them to the logs in a box titled Dispatch email (not delivered), which includes sign-in links and 6-digit codes for later sign-ins:
docker compose logs -f app | grep -A12 "Dispatch email"Configure SMTP or Amazon SES in Admin → Settings → Email before inviting your team (Email delivery). Then connect your mailboxes (Connecting inboxes).
Everything else is configured in the UI:
- The instance, at
/admin: sign-in, OAuth apps, storage, AI, branding, security, legal pages and billing. - Each workspace, under Settings: members, roles, teams, inboxes, labels, rules and integrations.
See Configuration.
The compose file doesn't publish any ports. Something must terminate TLS and forward to the app service on port 3000. Pick one option.
Caddy (bundled). docker-compose.override.example.yml adds a Caddy container with automatic Let's Encrypt certificates. It runs caddy reverse-proxy --from $DOMAIN --to app:3000 and needs ports 80 and 443 to be free. This is what the quick start uses.
Your own Caddy on the host: publish the app on localhost (the override's "Option B"), then add to your Caddyfile:
mail.example.com {
reverse_proxy 127.0.0.1:3000
}nginx on the host (with certificates from certbot). Publish the app on localhost as above. Disable buffering so realtime updates (Server-Sent Events) stream immediately:
server {
listen 443 ssl;
http2 on;
server_name mail.example.com;
ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
client_max_body_size 50m; # attachments
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_buffering off; # Server-Sent Events
proxy_read_timeout 1h;
}
}Traefik (Docker provider). Add labels to the app service in docker-compose.override.yml and join Traefik's network:
services:
app:
labels:
- traefik.enable=true
- traefik.http.routers.dispatch.rule=Host(`mail.example.com`)
- traefik.http.routers.dispatch.entrypoints=websecure
- traefik.http.routers.dispatch.tls.certresolver=letsencrypt
- traefik.http.services.dispatch.loadbalancer.server.port=3000
networks: [default, traefik]
networks:
traefik:
external: trueWhatever proxy you use:
- Keep
DOMAINequal to the public hostname. Sign-in links, OAuth redirect URIs and webhooks are built from it. - Don't buffer responses on
/api/w/*/events. - Allow request bodies of at least your attachment size limit (default 25 MB).
| Volume | Mounted at | Contents |
|---|---|---|
dispatch-db |
db:/var/lib/postgresql/data |
PostgreSQL data |
dispatch-data |
app,worker:/data |
secrets/master.key (encryption key, generated on first boot) and storage/ (attachments, when using local storage) |
dispatch-secrets |
db:/secrets, app,worker:/secrets (read-only) |
db_password, generated by the db container on first boot |
- Database password. The
dbcontainer writes a random password to/secrets/db_passwordon first boot and applies it to the database role on every start. That file is the single source of truth: delete it and restartdb,appandworkerto rotate the password. - Master key.
/data/secrets/master.keyencrypts mailbox passwords, OAuth tokens and all secrets stored in settings. Back it up, because a database backup can't be decrypted without it. See Backup & restore. - Settings live in the database (
instance_settings), not in files.
The app and worker run as uid 1001. If you replace a named volume with a bind mount, chown -R 1001:1001 the host directory first.
Logs
docker compose logs -f app # web requests, migrations, emails in log mode
docker compose logs -f worker # mailbox sync, sending, rules, webhooks
docker compose logs --tail 200 dbDocker keeps container logs forever by default. Enable rotation in /etc/docker/daemon.json, then systemctl restart docker:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "5" } }Status and health. docker compose ps shows the health of each container.
- The app is healthy when
GET /api/healthreturns 200 (this includes a database check). - The worker's status (last heartbeat, connected inboxes, last error) is shown in Admin → System.
Point your uptime monitor at https://<DOMAIN>/api/health.
Updates: docker compose pull && docker compose up -d. Migrations run automatically. Details and version pinning: Upgrading.
Backups: pg_dump plus the data volume. See Backup & restore.
Shell / one-off commands
docker compose exec db psql -U dispatch -d dispatch # SQL shell
docker compose run --rm app migrate # run migrations only
docker compose exec app sh # shell in the app container| Symptom | What to check |
|---|---|
app stays starting / unhealthy |
docker compose logs app. Usually the database isn't reachable yet (it retries for ~2 minutes) or /data isn't writable. |
dependency failed to start: container … is unhealthy |
The worker waits for a healthy app. Fix the app first, then run docker compose up -d again. |
| Caddy can't get a certificate | DNS must point to this server, and ports 80/443 must be open and not used by another web server. See docker compose logs caddy. |
/setup rejects the setup code |
Copy the code from docker compose logs app | grep -A2 "setup code", or docker compose exec app cat /data/secrets/setup-code. After 10 wrong attempts, wait an hour. |
Sign-in links point to localhost or the wrong host |
DOMAIN is missing or wrong in .env. Fix it and docker compose up -d. |
| No emails arrive | Email is still in log mode: sign-in links are in docker compose logs app. Configure email delivery. |
| Inbox won't connect | See Connecting inboxes → Troubleshooting. Outbound ports 993/465/587 must be open. |
| Realtime updates only after refresh | A proxy is buffering Server-Sent Events. Disable buffering (proxy_buffering off in nginx). |
password authentication failed for user "dispatch" |
The secrets volume was recreated while the database was kept. Restart db first, which re-applies the password, then app and worker. |
Still stuck? Open a discussion or a bug report with the output of docker compose ps and the relevant logs (remove personal data).