Docker Deployment
Deploy with Docker Compose
Quick Start
- Copy
docker-compose.prod.ymlfrom the repository root — it is the source of truth for the deployed service graph. (The rootdocker-compose.ymlis an intentionally empty placeholder; local dev infra lives indocker/docker-compose.dev.yml.) - Configure environment variables
- 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>):
| Container | Purpose |
|---|---|
web | Dashboard + HTTP API (APP=web) — serves the app, api, and tracking hosts |
landing | Marketing site (APP=landing) |
wiki | Documentation site (APP=wiki) |
iii-engine | Durable event / queue engine (Redis-backed) |
iii-worker | Background job worker (APP=iii, SCOPE=iii-worker) |
mastra | AI assistant service (APP=mastra) |
postgres | PostgreSQL 16 database |
redis | Redis 7 (queue + cache) |
minio | S3-compatible object storage |
migrate | One-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.
| Port | Service |
|---|---|
| 3000 | App containers (internal; proxied) |
| 9000 | Minio API |
| 9001 | Minio console |
| 465 | SMTP implicit TLS (optional) |
| 587 | SMTP 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:roSendly 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:roIf 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 .