---
title: Deployment
path: troubleshooting/deployment
status: published
---

# Deployment

Running ScaiSend in production. Three services, three datastores, and a handful of DNS records. This page covers the moving parts; see the standalone deployment guide shipped with the repository (`docs/deployment-standalone.md`) for copy-pasteable install steps.

## What you're running

| Service | Command | Role |
|---------|---------|------|
| API | `scaisend serve` | FastAPI; handles `/v3/*`, `/api/admin/*`, `/t/*`, `/i/*`, `/webhooks/scaikey` |
| Worker | `scaisend worker` | arq; renders templates, delivers webhooks, aggregates stats |
| SMTP | `scaisend smtp` | aiosmtpd; direct outbound delivery + inbound DSN/FBL |

All three can run on a single host for small deployments, or split across hosts for scale. They're stateless — all state is in MySQL, Redis, or S3 — so you can add replicas freely.

## Dependencies

| System | Purpose | Version |
|--------|---------|---------|
| Python | Runtime | 3.11+ |
| MySQL | Durable state (27 tables) | 8.0+ |
| Redis | Queues, cache, rate-limit counters | 7+ |
| S3-compatible | Attachments, images | Garage, MinIO, or real S3 |
| ScaiKey | Identity, OAuth, webhook-based sync | Accessible over HTTPS |

A production deployment also needs:

- **Public HTTPS for the API.** Typically via a reverse proxy (Nginx, Caddy, Traefik) terminating TLS.
- **Public IP on port 25** for the SMTP inbound server (DSN and FBL reception). Reachable from the internet.
- **Outbound port 25** unrestricted (or restricted to your SMTP service's egress IPs). Many cloud providers block outbound 25 by default; contact support to unblock.
- **Publishing DNS** for each sender domain (DKIM, SPF, DMARC) and for the ScaiSend instance itself (A/AAAA, reverse DNS).

## Configuration

All configuration is via environment variables. Minimal set:

```bash
# Database
DATABASE_URL=mysql+aiomysql://scaisend:password@mysql/scaisend

# Redis
REDIS_URL=redis://redis:6379

# S3
S3_ENDPOINT_URL=https://s3.example.com
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
S3_BUCKET=scaisend-attachments

# ScaiKey
SCAIKEY_API_URL=https://scaikey.scailabs.ai
SCAIKEY_CLIENT_ID=...
SCAIKEY_CLIENT_SECRET=...
SCAIKEY_WEBHOOK_SECRET=...
SCAIKEY_JWKS_URL=https://scaikey.scailabs.ai/.well-known/jwks.json

# SMTP
SMTP_HELO_HOSTNAME=mail.example.com
SMTP_POOL_SIZE_PER_DOMAIN=5
SMTP_CONNECT_TIMEOUT=30
SMTP_COMMAND_TIMEOUT=60
SMTP_MAX_RETRIES=5
SMTP_RETRY_BASE_DELAY=60
INBOUND_SMTP_HOST=0.0.0.0
INBOUND_SMTP_PORT=25

# Tracking
TRACKING_BASE_URL=https://scaisend.example.com

# Admin UI
ADMIN_URL=https://scaisend.example.com
```

See `.env.example` in the repository for every variable.

## Self-registration with ScaiKey

If your ScaiKey deployment allows self-registration, let ScaiSend bootstrap itself:

```bash
scaisend register \
  --public-url https://scaisend.example.com \
  --contact-email admin@example.com
```

This:

1. Calls ScaiKey's `/v1/clients/register` with ScaiSend's metadata.
2. Waits for an admin to approve the registration (or auto-approves if ScaiKey is configured for that).
3. Writes the returned client credentials to `.env`.

Check status with:

```bash
scaisend register-status --update-env
```

For scoped registration (one tenant or partner only):

```bash
scaisend register --public-url https://... --tenant acme-corp --contact-email ...
scaisend register --public-url https://... --partner reseller-xyz --contact-email ...
```

## Initial sync

After registration, mirror the current state from ScaiKey into your ScaiSend DB:

```bash
scaisend sync --full --create-roles
```

`--create-roles` creates the default `admin`, `developer`, and `viewer` roles for every tenant. `--full` mirrors every partner, tenant, user, and group.

Re-run after prolonged ScaiKey outages, but it's unnecessary during normal operation — webhooks keep everything in sync in real time.

## Database migrations

Alembic handles schema. On first install:

```bash
alembic upgrade head
```

On upgrade:

```bash
# Back up first
mysqldump scaisend > scaisend-$(date +%Y%m%d).sql

# Then migrate
alembic upgrade head
```

Migrations are forward-only. There's no automated downgrade path; if you need to roll back, restore from backup.

## Running the services

### Systemd units

A typical setup runs each service as a separate systemd unit:

```
# /etc/systemd/system/scaisend-api.service
[Unit]
Description=ScaiSend API
After=network.target mysql.service redis.service

[Service]
User=scaisend
EnvironmentFile=/etc/scaisend/.env
ExecStart=/opt/scaisend/.venv/bin/scaisend serve --host 0.0.0.0 --port 8000
Restart=always

[Install]
WantedBy=multi-user.target
```

Replicate for `scaisend-worker` and `scaisend-smtp`. Put the API behind a reverse proxy terminating TLS on 443.

### Docker / Kubernetes

ScaiSend runs cleanly in containers. Run three separate deployments (one per service), each with its own image tag or process flag. Scale independently.

Key things to remember:

- **The SMTP service needs `hostNetwork: true`** (Kubernetes) or `network_mode: host` (Docker Compose) if it's listening on port 25 — running it in a NAT-ed container means recipient MXs see a private IP in the reverse DNS check.
- **All three services share the `.env`** — the same MySQL, Redis, S3 credentials.
- **Only the API service needs inbound HTTP exposure.**

## DNS for the ScaiSend instance itself

Separate from per-sender-domain DNS, the ScaiSend instance hosts some records:

| Record | Purpose |
|--------|---------|
| `A` / `AAAA` for the API hostname | HTTPS traffic |
| `A` / `AAAA` for the SMTP inbound hostname | DSN / FBL reception (can be same as API host) |
| `PTR` for the SMTP outbound IPs | Reverse DNS; required by most receivers for deliverability |
| `MX` for the abuse address domain | If you're receiving FBLs, the MX must point at the SMTP inbound server |

PTR records are usually managed by your IP provider. Make sure every outbound IP has a PTR pointing at a hostname that resolves back to that IP (forward-confirmed reverse DNS, FCrDNS).

## Health checks

| Endpoint | Purpose |
|----------|---------|
| `GET /health` | Liveness — returns 200 if the API process is up |
| `GET /ready` | Readiness — returns 200 if DB is reachable, 503 otherwise |

Point your load-balancer health checks at `/ready`. Kubernetes `livenessProbe` at `/health`; `readinessProbe` at `/ready`.

The worker and SMTP services don't expose HTTP; rely on systemd / Kubernetes to restart them if they die.

## Backup and recovery

State in order of criticality:

| Store | Backup strategy |
|-------|-----------------|
| MySQL | Regular `mysqldump` or managed-DB snapshots. Daily minimum. |
| S3 | Versioning + replication. Attachments aren't regenerable from anywhere else. |
| Redis | No backup. Queued-but-unsent messages are lost on wipe; persistently stored messages survive. |

On restore:

1. Restore MySQL to the desired point in time.
2. Restore S3 attachments (or rely on durability).
3. Let Redis rebuild naturally. Messages in the `QUEUED` state in MySQL will be picked up by the worker on next poll.

## Scaling

| Bottleneck | How to scale |
|------------|--------------|
| API throughput | Add more API replicas behind the load balancer |
| Template rendering | Add more worker replicas |
| SMTP delivery concurrency | Increase `SMTP_POOL_SIZE_PER_DOMAIN`; add more SMTP replicas |
| Inbound DSN/FBL volume | One SMTP replica is usually plenty; only add if you're processing millions of DSNs/day |
| MySQL write load | Vertical scale MySQL; tune indexes; consider a read-replica for `/v3/messages` reads |
| Redis memory | Vertical scale; queue depth is usually the bottleneck, not key count |

## Related

- [Health and Monitoring](health-and-monitoring) — what to observe.
- [Troubleshooting](index) — common failure modes.
- [Sender Domains](../concepts/sender-domains) — per-domain DNS setup.
