Docs navigationBrowse documentation217
OverviewStart here

OpenSend docs

Self Hosting

Run OpenSend on infrastructure you control with Docker Compose, PostgreSQL, AWS SES/S3, and the standalone ingester service.

Raw markdown

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 port 3015.
  • 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 port 3016.
  • scheduler: sidecar that triggers ingester /jobs/* scans.
  • smtp-relay: optional SMTP compatibility service, started only with the smtp profile.

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:

ServiceRelease imageNotes
App/API/dashboardghcr.io/namuh-eng/opensend:v1.0.0Built from the root Dockerfile runner target.
Ingesterghcr.io/namuh-eng/opensend-ingester:v1.0.0Handles SES/SNS, inbound events, workers, and job endpoints.
Schedulerghcr.io/namuh-eng/opensend-ingester:v1.0.0Same 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

bash
git clone https://github.com/namuh-eng/opensend.git
cd opensend
bun run setup
docker compose up -d

Open 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:

bash
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:

CategoryVariables
DatabaseDATABASE_URL, POSTGRES_PASSWORD for Compose-only Postgres
AuthBETTER_AUTH_URL, NEXT_PUBLIC_APP_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, BETTER_AUTH_TRUSTED_ORIGINS
EmailAWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY or equivalent IAM role credentials
AttachmentsS3_BUCKET_NAME
Domain DNSCLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID when using automatic DNS setup
Background jobsDefault: 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 authINGESTER_URL, INGESTER_JOB_TOKEN, INGESTER_SCHEDULER_INTERVAL_SECONDS
Inbound receivingINGESTER_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/cacheRATE_LIMIT_BACKEND=redis, REDIS_URL, OPENSEND_APP_REPLICAS
Secret encryptionWEBHOOK_SECRET_ENCRYPTION_KEY, INTEGRATION_SECRET_ENCRYPTION_KEY, TRACKING_SECRET, UNSUBSCRIBE_SECRET, DKIM_ENCRYPTION_KEY, DKIM_KEY_VERSION, CRON_AUTH_TOKEN
ObservabilitySentry, 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:

bash
bun run db:migrate

Docker 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:

txt
https://YOUR_INGESTER_HOST/events/ses

Do 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:

txt
opensend-ingester running in DB-polling mode; configure SQS for production scale

If 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:

  1. Create an SQS queue and dead-letter queue.
  2. Set BACKGROUND_JOBS_QUEUE_URL on the app and ingester.
  3. Set BACKGROUND_JOBS_REQUIRE_QUEUE=true on the app so missing queue wiring fails loudly.
  4. Keep BACKGROUND_WORKER_POLL=true on the ingester so it long-polls SQS.
  5. Keep the scheduler, EventBridge, or an equivalent trusted caller posting to /jobs/scheduled-emails, /jobs/webhooks, and /jobs/domain-verify with Authorization: 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:

env
RATE_LIMIT_BACKEND=redis
REDIS_URL=redis://redis:6379
OPENSEND_APP_REPLICAS=1

Outside 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:

bash
docker compose --profile smtp up -d smtp-relay

By 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:

  1. Run migrations against the target database.
  2. Confirm GET /api/health returns healthy.
  3. Confirm the ingester /health endpoint returns healthy.
  4. Send a real SES-backed email and confirm provider success.
  5. Confirm SES/SNS events reach /events/ses.
  6. If using receiving, confirm the inbound SNS topic reaches /events/inbound/ses-s3 and SES can write raw MIME to the configured bucket.
  7. Confirm scheduled jobs run with the same INGESTER_JOB_TOKEN configured on the scheduler and ingester.
  8. Confirm the app, ingester, and scheduler boot preflights pass with production env and list no missing/invalid secret keys.
  9. Confirm rate limiting, queue, integration encryption, and secret-manager variables are set for shared deployments.
  10. If scaling app containers beyond one, set OPENSEND_APP_REPLICAS and verify all replicas share the same Redis.
  11. If enabling SMTP, start the smtp profile and send a message through port 2587 with an OpenSend API key as the password.

Useful local checks:

bash
docker compose --env-file .env.example config
make check
make test
bun run build

Troubleshooting

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.