SendlySendly
Self-Hosting

Docker Deployment

Deploy with Docker Compose

Quick Start

  1. Copy docker-compose.prod.yml from the repository root — it is the source of truth for the deployed service graph. (The root docker-compose.yml is an intentionally empty placeholder; local dev infra lives in docker/docker-compose.dev.yml.)
  2. Configure environment variables
  3. Run docker compose -f docker-compose.prod.yml up -d

The reference compose is oriented for the maintainers' Traefik/Dokploy deploy — it references an external dokploy-network and pulls digest-pinned images from GHCR. Adapt the routing labels and image sources for your own environment.

Minimal .env

These are the ${VAR} references the reference compose reads from your environment (see Environment Variables for the complete set the apps validate at boot):

# Secrets
DB_PASSWORD="your-secure-password"
MINIO_ROOT_PASSWORD="your-minio-password"
BETTER_AUTH_SECRET="$(openssl rand -base64 32)"   # 32+ chars
INTERNAL_SERVICE_TOKEN="$(openssl rand -hex 32)"

# Public URLs (point these at your server)
BETTER_AUTH_URL="https://app.yourdomain.com"
DASHBOARD_URI="https://app.yourdomain.com"
LANDING_URI="https://yourdomain.com"
WIKI_URI="https://docs.yourdomain.com"

# AWS SES
AWS_SES_REGION="us-east-1"
AWS_SES_ACCESS_KEY_ID="your-key"
AWS_SES_SECRET_ACCESS_KEY="your-secret"
SES_CONFIGURATION_SET="sendly-tracking"

See Environment Variables for all options.

Services

Each app runs as its own container (the root Dockerfile builds one app per image, selected with --build-arg APP=<name>):

ContainerPurpose
webDashboard + HTTP API (APP=web) — serves the app, api, and tracking hosts
landingMarketing site (APP=landing)
wikiDocumentation site (APP=wiki)
iii-engineDurable event / queue engine (Redis-backed)
iii-workerBackground job worker (APP=iii, SCOPE=iii-worker)
mastraAI assistant service (APP=mastra)
postgresPostgreSQL 16 database
redisRedis 7 (queue + cache)
minioS3-compatible object storage
migrateOne-shot Prisma migration runner (exits after applying)

Ports

The app containers listen on port 3000 internally and are published through a reverse proxy (Traefik in the reference deploy) rather than bound directly to the host. Minio publishes its API/console; the SMTP ports apply only when the optional mail ingress is enabled.

PortService
3000App containers (internal; proxied)
9000Minio API
9001Minio console
465SMTP implicit TLS (optional)
587SMTP STARTTLS (optional)

Building Individual Apps

There is no runtime SERVICE switch — the single root Dockerfile builds one app per image, selected at build time with the APP build arg (web, iii, mastra, landing, wiki; default web), which names the app's directory under apps/. A companion SCOPE arg selects the workspace package to build and defaults to APP; it only differs for the worker, whose directory is apps/iii but whose package is iii-worker — so that image builds with APP=iii and SCOPE=iii-worker. Each image then runs as its own container:

Pass the matching runner stage with --target: runner-nextjs for the Next.js apps (web, landing, wiki), runner-node for iii-worker, and runner-mastra for mastra.

docker build --build-arg APP=web        --target runner-nextjs -t sendly-web .
docker build --build-arg APP=iii --build-arg SCOPE=iii-worker --target runner-node -t sendly-iii-worker .
docker build --build-arg APP=mastra     --target runner-mastra -t sendly-mastra .
docker build --build-arg APP=landing    --target runner-nextjs -t sendly-landing .
docker build --build-arg APP=wiki       --target runner-nextjs -t sendly-wiki .

Sentry source maps (optional)

Without source maps, a production stack trace in Sentry shows minified frames. If you run your own Sentry, pass your auth token as a BuildKit secret and name the release the image is built from:

DOCKER_BUILDKIT=1 SENTRY_AUTH_TOKEN=<your token> docker build \
  --secret id=SENTRY_AUTH_TOKEN,env=SENTRY_AUTH_TOKEN \
  --build-arg SENTRY_ORG=<your org> \
  --build-arg SENTRY_PROJECT=<your project> \
  --build-arg SENTRY_RELEASE="$(git rev-parse HEAD)" \
  --build-arg SENTRY_URL=https://sentry.example.com \
  --build-arg APP=web --target runner-nextjs -t sendly-web .

Pass the token as --secret, never as --build-arg: a build arg is recorded in docker history and readable by anyone who can pull the image. SENTRY_RELEASE has to be supplied explicitly because the builder stage has no .git to read it from, and Sentry resolves a trace by matching the event's release to the uploaded bundle — a mismatch resolves nothing. SENTRY_URL is only needed for a self-hosted Sentry; omit it and the upload goes to https://sentry.io/. The maps are deleted from the image once uploaded. Omit all of them and the build simply skips the upload.

SMTP TLS Certificates

For TLS on ports 465/587, provide certificates via one of these methods:

Traefik acme.json (Dokploy, Coolify)

Mount the acme.json file and set SMTP_DOMAIN:

environment:
  SMTP_DOMAIN: "smtp.yourdomain.com"
volumes:
  - /path/to/acme.json:/certs/acme.json:ro

Sendly automatically extracts the certificate for SMTP_DOMAIN from acme.json.

PEM Files

Mount certificate files directly:

volumes:
  - /path/to/privkey.pem:/certs/privkey.pem:ro
  - /path/to/fullchain.pem:/certs/fullchain.pem:ro

If no certificates are mounted, SMTP runs without TLS.

Building from Source

The root Dockerfile defaults to APP=web, so a bare build produces the web image (see Building Individual Apps for the others):

docker build -t sendly-web .

On this page