OpenSend docs
Self Hosting
Run OpenSend on infrastructure you control with Docker Compose, PostgreSQL, AWS SES/S3, and the standalone ingester service.
Self-hosted OpenSend uses your AWS SES quota, your database, your secrets, and your observability stack. The reference Compose stack is meant to be truthful for evaluation, while production deployments should split the app, ingester, scheduler, database, queue, cache, and secrets into managed runtime services.
Reference topology
Docker Compose starts the same service boundaries used by production deployments:
app: Next.js dashboard and public API on port3015.postgres: OpenSend application database.redis: shared rate limiting and cache coordination for the Compose stack.migrate: one-shot Drizzle migration runner.ingester: SES/SNS event receiver and background worker on port3016.scheduler: sidecar that triggers ingester/jobs/*scans.smtp-relay: optional SMTP compatibility service, started only with thesmtpprofile.
Production deployments can run these as separate services on ECS, Fly, Railway, Cloud Run, Kubernetes, or a single VM. Keep app traffic pointed at the Next.js service, and point SES/SNS event webhooks at the ingester.
Release images and pinned deploys
The default Compose file uses pinned release images for reproducible self-host deploys. The authorized v1.0.0 workflow publishes these exact GHCR tags instead of a moving tag:
| Service | Release image | Notes |
|---|---|---|
| App/API/dashboard | ghcr.io/namuh-eng/opensend:v1.0.0 | Built from the root Dockerfile runner target. |
| Ingester | ghcr.io/namuh-eng/opensend-ingester:v1.0.0 | Handles SES/SNS, inbound events, workers, and job endpoints. |
| Scheduler | ghcr.io/namuh-eng/opensend-ingester:v1.0.0 | Same image, started with bun /app/job-scheduler.js. |
The release workflow publishes the images; the ingester service consumes the published image and does not publish images itself. The workflow also publishes :1.0.0 aliases and intentionally does not publish :latest.
docker-compose.yml pins those app, ingester, and scheduler tags by default. Use docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build when you intentionally want to build app and ingester from the checked-out source tree. The one-shot migrator and optional SMTP relay profile still build locally because v1.0.0 does not publish separate migrator or relay images. Run migrations before rolling app or ingester containers; if your platform requires a migrator image, build the root Dockerfile migrator target into your own registry.
Quick start
git clone https://github.com/namuh-eng/opensend.git
cd opensend
bun run setup
docker compose up -dOpen http://localhost:3015.
bun run setup writes a complete .env with fresh local secrets for the app, ingester, and scheduler: Better Auth, webhook/integration encryption, ingester job and inbound tokens, tracking, unsubscribe, DKIM, cron auth, and the Compose Postgres password. It prompts only for external provider values such as AWS, Cloudflare, S3, and Google OAuth; blank answers are safe until those integrations are used.
For production, move the generated values into a secrets manager or platform secret store and inject them at runtime. Do not commit generated secrets or bake .env into images.
For real email delivery, add AWS SES credentials and verify a sending domain. For dashboard login, add Google OAuth credentials.
The app, ingester, and scheduler run boot preflights in production and report every missing or invalid required key in one error instead of failing later at first use.
Configuration model
All runtime configuration comes from environment variables. Local Compose reads .env; production should inject secrets at runtime from a secrets manager such as AWS Secrets Manager, Doppler, Vault, or the secret store for your platform.
Minimum local evaluation values are generated by:
bun run setup.env.example remains a placeholder reference, but bun run setup is the recommended path because it creates correctly shaped secrets and scheduler connection values for every boot preflight. Compose uses INGESTER_URL=http://ingester:3016; non-Compose production deploys should override INGESTER_URL to the ingester base URL reachable by the scheduler.
Production values to plan before real traffic:
| Category | Variables |
|---|---|
| Database | DATABASE_URL, POSTGRES_PASSWORD for Compose-only Postgres |
| Auth | BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, BETTER_AUTH_TRUSTED_ORIGINS |
AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY or equivalent IAM role credentials | |
| Attachments | S3_BUCKET_NAME |
| Domain DNS | CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID when using automatic DNS setup |
| Background jobs | Default: BACKGROUND_JOBS_DB_POLLING_FALLBACK=true, BACKGROUND_WORKER_POLL=true on the ingester. Production scale: BACKGROUND_JOBS_QUEUE_URL, BACKGROUND_JOBS_REQUIRE_QUEUE=true, BACKGROUND_WORKER_POLL=true |
| Scheduler auth | INGESTER_URL, INGESTER_JOB_TOKEN, INGESTER_SCHEDULER_INTERVAL_SECONDS |
| Inbound receiving | INGESTER_INBOUND_TOKEN when /events/inbound is exposed, plus SES_INBOUND_SNS_TOPIC_ARN and S3_BUCKET_NAME or SES_INBOUND_BUCKET_NAME for SES receipt-rule ingestion |
| Rate limiting/cache | RATE_LIMIT_BACKEND=redis, REDIS_URL, OPENSEND_APP_REPLICAS |
| Secret encryption | WEBHOOK_SECRET_ENCRYPTION_KEY, INTEGRATION_SECRET_ENCRYPTION_KEY, TRACKING_SECRET, UNSUBSCRIBE_SECRET, DKIM_ENCRYPTION_KEY, DKIM_KEY_VERSION, CRON_AUTH_TOKEN |
| Observability | Sentry, PostHog, CloudWatch, or OTel variables you explicitly configure |
Database and migrations
Migrations are committed Drizzle SQL files. Run them before deploying app code that expects new tables or columns:
bun run db:migrateDocker Compose runs the migrate service before the app and ingester start. If your platform does not run the migrator automatically, make it a release step. A detail page that 404s while list pages still work is often a swallowed schema mismatch, not a missing route.
SES and event ingestion
OpenSend sends through AWS SES v2. The app accepts and queues email work; the ingester handles background delivery, SES/SNS feedback events, scheduled sends, webhook retries, and domain verification scans.
Point SES SNS notifications at the ingester:
https://YOUR_INGESTER_HOST/events/sesDo not point SES/SNS events at the Next.js app URL. Keep the ingester reachable from AWS, and keep its /jobs/* endpoints protected by INGESTER_JOB_TOKEN.
Background jobs
The default Docker Compose path runs without SQS. When BACKGROUND_JOBS_QUEUE_URL is empty and BACKGROUND_JOBS_DB_POLLING_FALLBACK=true, the ingester started with BACKGROUND_WORKER_POLL=true polls Postgres for queued and retry-ready email rows, claims each row atomically, and sends through the same SES worker lifecycle as SQS jobs. Ingester logs include:
opensend-ingester running in DB-polling mode; configure SQS for production scaleIf you intentionally disable the fallback with BACKGROUND_JOBS_DB_POLLING_FALLBACK=false and do not configure SQS, POST /api/emails fails with an actionable background-worker error instead of accepting rows that can never dispatch.
For production scale, graduate to the queue-backed path:
- Create an SQS queue and dead-letter queue.
- Set
BACKGROUND_JOBS_QUEUE_URLon the app and ingester. - Set
BACKGROUND_JOBS_REQUIRE_QUEUE=trueon the app so missing queue wiring fails loudly. - Keep
BACKGROUND_WORKER_POLL=trueon the ingester so it long-polls SQS. - Keep the scheduler, EventBridge, or an equivalent trusted caller posting to
/jobs/scheduled-emails,/jobs/webhooks, and/jobs/domain-verifywithAuthorization: Bearer ${INGESTER_JOB_TOKEN}.
The DB-polling fallback is intended for single-stack self-hosting and evaluation. SQS remains the recommended production path for high throughput, backpressure, visibility timeouts, and DLQ operations. When SQS is configured, DB polling is gated off so the same email is not dispatched by both workers.
Rate limiting and cache
The Docker Compose stack starts Redis and sets the app to use Redis-backed rate limiting by default:
RATE_LIMIT_BACKEND=redis
REDIS_URL=redis://redis:6379
OPENSEND_APP_REPLICAS=1Outside Compose, an unset RATE_LIMIT_BACKEND is treated as disabled so single-process local development does not unexpectedly require Redis. Any shared, staging, production, or multi-replica deployment should set RATE_LIMIT_BACKEND=redis and point REDIS_URL at a shared Redis endpoint. Use rediss://... for managed production Redis and keep it private to your runtime network.
Set OPENSEND_APP_REPLICAS to the number of app containers you expect to run. Startup checks warn when this value is greater than 1 and Redis-backed rate limiting is not enabled. REDIS_URL alone does not enable rate limiting; RATE_LIMIT_BACKEND=redis is required.
Redis backs API rate limiting plus hot-path auth/domain metadata caches and ingester domain-cache invalidation. If Redis is selected for rate limiting but unavailable, API requests fail closed with HTTP 503 instead of silently downgrading to a per-process limiter.
SMTP relay profile
The SMTP relay is available in the reference Compose file but is not part of the default footprint. Enable it only when an application must submit mail over SMTP:
docker compose --profile smtp up -d smtp-relayBy default the relay publishes port 2587 and authenticates with OpenSend API keys as SMTP passwords. It requires the same Postgres database as the app. In default Compose, accepted SMTP rows are dispatched by the ingester DB-polling fallback when SQS is absent. For production scale, set BACKGROUND_JOBS_QUEUE_URL consistently on the app, ingester, and relay, and keep BACKGROUND_WORKER_POLL=true on the ingester. Configure SMTP_RELAY_TLS_CERT_PATH and SMTP_RELAY_TLS_KEY_PATH before advertising STARTTLS on a public relay.
Privacy and telemetry
Self-hosted OpenSend makes zero outbound calls to OpenSend-operated vendors unless you configure the related environment variables. See Privacy for the full promise and the hosted-cloud boundary.
Validation checklist
Before sending real production traffic:
- Run migrations against the target database.
- Confirm
GET /api/healthreturns healthy. - Confirm the ingester
/healthendpoint returns healthy. - Send a real SES-backed email and confirm provider success.
- Confirm SES/SNS events reach
/events/ses. - If using receiving, confirm the inbound SNS topic reaches
/events/inbound/ses-s3and SES can write raw MIME to the configured bucket. - Confirm scheduled jobs run with the same
INGESTER_JOB_TOKENconfigured on the scheduler and ingester. - Confirm the app, ingester, and scheduler boot preflights pass with production env and list no missing/invalid secret keys.
- Confirm rate limiting, queue, integration encryption, and secret-manager variables are set for shared deployments.
- If scaling app containers beyond one, set
OPENSEND_APP_REPLICASand verify all replicas share the same Redis. - If enabling SMTP, start the
smtpprofile and send a message through port2587with an OpenSend API key as the password.
Useful local checks:
docker compose --env-file .env.example config
make check
make test
bun run buildTroubleshooting
Compose fails before starting
Run docker compose --env-file .env.example config. Missing interpolation errors usually mean a required env var was removed from .env.
Emails stay queued
Check ingester logs first. Without SQS, you should see running in DB-polling mode; configure SQS for production scale; then verify BACKGROUND_JOBS_DB_POLLING_FALLBACK=true, BACKGROUND_WORKER_POLL=true, and SES credentials/sandbox status. With SQS, check BACKGROUND_JOBS_QUEUE_URL, BACKGROUND_WORKER_POLL, SQS IAM permissions, and DLQ redrive evidence.
Domain verification does not update
Check that the scheduler is posting to /jobs/domain-verify, that the scheduler and ingester share the same INGESTER_JOB_TOKEN, and that SES reports the domain identity as verified in the selected region.
Public docs or API docs look stale
Run bun run docs:generate after changing public/docs/**/*.md, and keep /docs, /docs/llms.txt, and /openapi.json reachable from the deployment.