API v1Private beta. The API is stable; send limits are low while the shared domain warms up.

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 -d

send0 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.0 in .env to stay on one release, or 0.2 to get its patch releases. latest moves 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, Caddyfile or .env.example changed, 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_dump and 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 the send0-blobs bucket.

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 -d

Then 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 expires raw/ and att/ 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 35

Logs

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.

EventMeans
server.listening, smtp.listening, worker.startedA role started
message.receivedAn inbound message was stored, with its sender, subject, thread and auth verdicts
message.rejectedAn inbound message was refused, with the reason
message.forwarded_to_operatorMail for postmaster@ or abuse@ went to OPERATOR_FORWARD_TO
smtp.ingest_failed, smtp.lookup_failedThe SMTP server couldn't store a message or look up an inbox; the sender got 451 and retries
auth.email_failedA verification, reset or invite email couldn't be sent. Check the relay or SES
sending.pausedSES events pushed a workspace over the bounce or complaint limit
queue.error, sweep.errorWebhook delivery or the outbox sweep failed; it's retried
sweepThe outbox sweep found and re-queued missed events or deliveries
retentionOld messages were scrubbed or deleted, with counts; complete: false means more next run
retention.blob_delete_failed, retention.errorDeleting old blobs or the retention run failed; the next run retries
sns.subscription_confirmedThe SES events subscription is confirmed
healthz.failed/healthz couldn't reach Postgres
config.invalid, boot.failed, migrations.appliedCloudflare only: settings rejected, database boot failed, migrations ran
server.crashedThe 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 the smtp role writes blobs that http reads.

Security checklist

  • Set OWNER_EMAIL and sign up right away. Until that account exists, sign-up is open to OWNER_EMAIL only, 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_PROXIES if your network has other private hosts. By default, send0 believes X-Forwarded-For from 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_KEY by setting a new value and running docker 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. With BLOB_DRIVER=s3, links are pre-signed by S3 instead.
  • Treat SES_EVENTS_TOKEN as 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 except SES_EVENTS_TOPIC_ARN. To rotate it, set a new token, then replace the subscription with the new URL.
  • Keep .env private. It holds every secret. Don't commit it.

Troubleshooting

SymptomCauseFix
send0 keeps restarting; logs start with Invalid configuration:A setting is missing or invalid. Each line names oneFix .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 invalidFix the listed variables, deploy again, reload. See If a setting is wrong.
Doctor: ✗ database cannot connectPostgres isn't up, or POSTGRES_PASSWORD changed after the volume was createddocker 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 credentialsCheck 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_URLFix it as the message says. See SMTP_URL.
Doctor: ✗ mx … no MX recordThe mail domain has no MXAdd MAIL_DOMAIN MX 10 DOMAIN. DNS can take a while to update.
Doctor: ! port 25 … could not connect, or no mail arrives at allYour provider blocks port 25, or hairpin NATRun 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 reachableDNS doesn't point at the server yet, or Caddy hasn't got a certificateCheck 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 inboxMX, port 25, or the recipientRun 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 existNo inbox with that addressCreate the inbox. Check the domain is in MAIL_DOMAIN or MAIL_DOMAINS.
Senders get 550 5.7.1 Relaying deniedThe domain isn't one of your mail domainsAdd 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_EMAILSign up with exactly OWNER_EMAIL.
Sign-up fails with signup_closed: "Sign-up is closed here."The owner exists and ALLOW_SIGNUP is falseAsk a workspace owner or admin for an invite, or set ALLOW_SIGNUP=true.
The verification email never arrivesSending fails (auth.email_failed in the logs), or SES is in the sandboxRun 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 sendersThey connect over IPv6 through Docker's proxySee IPv6 and Docker's proxy.
503 with starting_up (Cloudflare)The database took too long to wake upRetry after a few seconds.

On this page