Self-hosting
Upgrades, backups and troubleshooting
Keep a self-hosted send0 current, backed up, secure and working.
Commands run in the directory with compose.yml. For the Cloudflare edition, see Upgrading there.
Upgrading
docker compose pull && docker compose up -dsend0 applies database migrations when it starts, before it serves anything. If several send0 processes start at once, they take turns under a lock.
- Pin a version. Set
SEND0_VERSION=0.2.0in.envto stay on one release, or0.2to get its patch releases.latestmoves to every new release. To upgrade, change it and run the commands above. - Back up first. Migrations change the database, and there's no automatic rollback.
- Check the release notes on GitHub releases. If
compose.yml,Caddyfileor.env.examplechanged, download them again from the new tag.
Backups
Back up the database and the blobs (raw mail and attachments). One without the other is incomplete: messages point at blobs by key.
docker compose exec -T postgres pg_dump -U send0 -Fc send0 > send0-$(date +%F).dump
docker run --rm -v send0_blobs:/data -v "$PWD":/backup alpine \
tar czf /backup/blobs-$(date +%F).tgz -C /data .Also keep a copy of .env. Without SECRET_KEY and the passwords, a restore needs new secrets.
- With
BLOB_DRIVER=s3, the blobs live in your bucket. Back it up with your provider's tools, like versioning or replication. - On Cloudflare, back up Postgres with
pg_dumpand your connection string, or your provider's backups (Neon has point-in-time restore). R2 has an S3-compatible API, so any S3 tool can copy thesend0-blobsbucket.
Restoring
On a new server, with the same .env, compose.yml and Caddyfile:
docker compose up -d postgres
docker compose exec -T postgres pg_restore -U send0 -d send0 --clean --if-exists < send0-2026-10-07.dump
docker run --rm -v send0_blobs:/data -v "$PWD":/backup alpine \
tar xzf /backup/blobs-2026-10-07.tgz -C /data
docker compose up -dThen run docker compose exec send0 send0 doctor, and move DNS if the server's IP changed.
Retention
send0 removes old mail on its own, every hour (the worker role on Docker, the cron on Cloudflare):
- When a message is older than its inbox's
retention_days(1 to 30, default 7), its content is deleted: body, extracted fields, attachments and the raw.eml. The API then returns it with"expired": true. Addresses, subject and status stay, for reply-only sending, threading and bounce and complaint rates. - 35 days after a message was created, the row is deleted, with threads left empty. Webhook events and delivery logs older than 35 days go too.
It works in batches of 1,000 for up to 20 seconds per run and logs a retention line when it changed anything. If it stops early, the next run carries on.
Where the blobs go depends on the storage:
- Local disk (
BLOB_DRIVER=fs) and R2 (the Cloudflare edition): send0 deletes them itself when it removes a message's content. - S3 or S3-compatible (
BLOB_DRIVER=s3): send0 doesn't delete from the bucket, so it never needs delete permission. Add a lifecycle rule that expiresraw/andatt/after 35 days:
aws s3api put-bucket-lifecycle-configuration --bucket <your-bucket> \
--lifecycle-configuration '{"Rules":[
{"ID":"send0-raw","Filter":{"Prefix":"raw/"},"Status":"Enabled","Expiration":{"Days":35}},
{"ID":"send0-att","Filter":{"Prefix":"att/"},"Status":"Enabled","Expiration":{"Days":35}}]}'This command replaces the bucket's existing lifecycle rules, so add these to them if it has any. MinIO has the same rules (mc ilm rule add --prefix raw/ --expire-days 35 <alias>/<bucket>, and again for att/). On R2, where send0 deletes blobs itself, the same rule is an optional backstop for anything left behind, such as the blobs of a workspace purged before its mail expired:
npx wrangler r2 bucket lifecycle add send0-blobs send0-raw raw/ --expire-days 35
npx wrangler r2 bucket lifecycle add send0-blobs send0-att att/ --expire-days 35Logs
send0 writes one JSON object per line, with an event field.
docker compose logs -f send0
docker compose logs send0 | grep '"event":"message.rejected"'On Cloudflare, use Workers Logs in the dashboard, or npx wrangler tail send0.
| Event | Means |
|---|---|
server.listening, smtp.listening, worker.started | A role started |
message.received | An inbound message was stored, with its sender, subject, thread and auth verdicts |
message.rejected | An inbound message was refused, with the reason |
message.forwarded_to_operator | Mail for postmaster@ or abuse@ went to OPERATOR_FORWARD_TO |
smtp.ingest_failed, smtp.lookup_failed | The SMTP server couldn't store a message or look up an inbox; the sender got 451 and retries |
auth.email_failed | A verification, reset or invite email couldn't be sent. Check the relay or SES |
sending.paused | SES events pushed a workspace over the bounce or complaint limit |
queue.error, sweep.error | Webhook delivery or the outbox sweep failed; it's retried |
sweep | The outbox sweep found and re-queued missed events or deliveries |
retention | Old messages were scrubbed or deleted, with counts; complete: false means more next run |
retention.blob_delete_failed, retention.error | Deleting old blobs or the retention run failed; the next run retries |
sns.subscription_confirmed | The SES events subscription is confirmed |
healthz.failed | /healthz couldn't reach Postgres |
config.invalid, boot.failed, migrations.applied | Cloudflare only: settings rejected, database boot failed, migrations ran |
server.crashed | The process stopped on an unexpected error |
Scaling
One container runs all three roles: http, smtp and worker. To split them, or run more than one, see Running roles separately.
- Inbound memory is bounded by
SMTP_MAX_CLIENTS(default 30 sessions, each up to about 25 MiB). - Each send0 process opens up to 10 Postgres connections.
- Roles on different machines need
BLOB_DRIVER=s3, because thesmtprole writes blobs thathttpreads.
Security checklist
- Set
OWNER_EMAILand sign up right away. Until that account exists, sign-up is open toOWNER_EMAILonly, and it must verify the address. Whoever controls that mailbox claims the install, so use an address you control. - Keep port 3000 unpublished. Only Caddy should reach it. The compose file doesn't publish it.
- Set
TRUSTED_PROXIESif your network has other private hosts. By default, send0 believesX-Forwarded-Forfrom any loopback or private address, which covers Caddy. If other machines on a private network can reach port 3000, narrow it to the Docker network's range. It matters for rate limits and session records. - Rotate
SECRET_KEYby setting a new value and runningdocker compose up -d. It signs download links for blobs on local disk (and for every download on Cloudflare). Links already issued stop working; they last 15 minutes anyway. Sessions and API keys aren't affected. WithBLOB_DRIVER=s3, links are pre-signed by S3 instead. - Treat
SES_EVENTS_TOKENas a password. It's the only credential on/internal/ses-events, and it's part of the SNS subscription URL. send0 also refuses messages from any topic exceptSES_EVENTS_TOPIC_ARN. To rotate it, set a new token, then replace the subscription with the new URL. - Keep
.envprivate. It holds every secret. Don't commit it.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
send0 keeps restarting; logs start with Invalid configuration: | A setting is missing or invalid. Each line names one | Fix .env, then docker compose up -d. restart doesn't reload .env. |
| Dashboard shows send0 isn't configured yet (Cloudflare) | A var or secret is missing or invalid | Fix the listed variables, deploy again, reload. See If a setting is wrong. |
Doctor: ✗ database cannot connect | Postgres isn't up, or POSTGRES_PASSWORD changed after the volume was created | docker compose ps and docker compose logs postgres. Put the original password back, or change it inside Postgres with ALTER USER. |
Doctor: ✗ mailer with "rejected the login" | Wrong relay credentials | Check the user and password in SMTP_URL. See SMTP_URL. |
Invalid configuration: lists SMTP_URL, like "port 465 uses implicit TLS" or "is missing a port" | The URL doesn't match the rules for SMTP_URL | Fix it as the message says. See SMTP_URL. |
Doctor: ✗ mx … no MX record | The mail domain has no MX | Add MAIL_DOMAIN MX 10 DOMAIN. DNS can take a while to update. |
Doctor: ! port 25 … could not connect, or no mail arrives at all | Your provider blocks port 25, or hairpin NAT | Run nc -vz mail.example.com 25 from another network. If it fails, ask your provider to open inbound port 25, or move to one that allows it. |
Doctor: ! public url … is not reachable | DNS doesn't point at the server yet, or Caddy hasn't got a certificate | Check the A record and docker compose logs caddy. Ports 80 and 443 must be open. |
Dashboard actions fail with 403 "Cross-site request refused." | The browser's URL doesn't match PUBLIC_URL (by default https://DOMAIN) | Open send0 at exactly that URL, or set PUBLIC_URL to the origin you use, then docker compose up -d. |
| Mail doesn't arrive in an inbox | MX, port 25, or the recipient | Run the doctor. Then grep '"event":"message.rejected"' in the logs: the reason says why. On Cloudflare, check the Email Routing catch-all points at the Worker. |
Senders get 550 5.1.1 Mailbox does not exist | No inbox with that address | Create the inbox. Check the domain is in MAIL_DOMAIN or MAIL_DOMAINS. |
Senders get 550 5.7.1 Relaying denied | The domain isn't one of your mail domains | Add it to MAIL_DOMAINS. |
Sign-up fails with signup_closed: "This send0 is waiting for its owner to sign up." | No account exists yet, and the email isn't OWNER_EMAIL | Sign up with exactly OWNER_EMAIL. |
Sign-up fails with signup_closed: "Sign-up is closed here." | The owner exists and ALLOW_SIGNUP is false | Ask a workspace owner or admin for an invite, or set ALLOW_SIGNUP=true. |
| The verification email never arrives | Sending fails (auth.email_failed in the logs), or SES is in the sandbox | Run the doctor. With SES in the sandbox, verify the recipient address in SES. Then use resend on the verification screen. |
| SPF fails for mail from some senders | They connect over IPv6 through Docker's proxy | See IPv6 and Docker's proxy. |
503 with starting_up (Cloudflare) | The database took too long to wake up | Retry after a few seconds. |