Install and operate
Installation
Three ways to run Corsair. All of them need the same four things from the host — see Prerequisites — and all of them run the same code.
Entrypoints#
bun src/dev.ts # everything, with a summary of where it is listening
bun src/start.ts # everything, quietly — this is what the container runs
bun src/server.ts # the HTTP tier only, for a split deploymentbun scripts/migrate.ts up | down | status | diff
bun scripts/seed.tsMigrations deliberately do not run on startup. Two instances coming up at once would race on the migration table, and this is the one step worth being able to run — and fail — on its own.
Docker Compose#
The shipped compose.yaml is the whole stack: Postgres, a one-shot migration container, and Corsair with the real ports mapped.
git clone https://github.com/wess/corsair
cd corsair
cp .env.example .envEdit .env for production values (Configuration), then:
export POSTGRES_PASSWORD='pick-something-long'
mkdir -p certs # fullchain.pem and privkey.pem go here
docker compose up -dThe compose file overrides the ports to the real ones and mounts ./certs read-only at /certs:
environment:
SMTP_MX_PORT: 25
SMTP_SUBMISSION_PORT: 587
SMTP_SUBMISSION_TLS_PORT: 465
IMAP_PORT: 143
IMAP_TLS_PORT: 993
POP3_PORT: 110
POP3_TLS_PORT: 995
TLS_CERT_PATH: /certs/fullchain.pem
TLS_KEY_PATH: /certs/privkey.pem
volumes:
- ./certs:/certs:roThe migrate service runs scripts/migrate.ts up once and exits; corsair waits for it with service_completed_successfully, so a fresh stack comes up in the right order without a race.
Seed the first account:
docker compose run --rm corsair bun scripts/seed.tsThe image#
The Dockerfile builds on oven/bun:1.3-alpine, installs libcap, grants cap_net_bind_service to the Bun binary, then drops to an unprivileged corsair user. Ports below 1024 bind without the process being root.
docker compose logs -f corsair
docker compose restart corsairBare metal with systemd#
The route with the fewest moving parts, and what the first-server tutorial walks through in full.
sudo adduser --system --group --home /opt/corsair corsair
sudo -u corsair git clone https://github.com/wess/corsair /opt/corsair/app
cd /opt/corsair/app
sudo -u corsair bun install
sudo -u corsair cp .env.example .env# /etc/systemd/system/corsair.service
[Unit]
Description=Corsair mail server
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=corsair
Group=corsair
WorkingDirectory=/opt/corsair/app
ExecStart=/opt/corsair/.bun/bin/bun src/start.ts
Restart=always
RestartSec=5
Environment=NODE_ENV=production
# Ports below 1024, granted to the service rather than to the binary.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/corsair/app
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now corsairSplit deployment#
The HTTP tier and the mail listeners can run as separate processes against the same database.
bun src/server.ts # HTTP only: API, panel, webmail, JMAP
SMTP_ENABLED=true IMAP_ENABLED=false POP3_ENABLED=false bun src/start.tsReasons to bother:
- Restart the panel without dropping IMAP sessions. A deploy of the web tier no longer disconnects every mail client.
- Different machines. The HTTP tier behind a load balancer, the mail listeners on the host that owns the reputable IP.
- Scale the worker separately. Several instances can drain the delivery queue; it claims work with
FOR UPDATE SKIP LOCKED, so they do not coordinate and cannot deliver anything twice.
The listener toggles are SMTP_ENABLED, IMAP_ENABLED, and POP3_ENABLED.
Reverse proxy#
Corsair serves plain HTTP on PORT (default 3000). Terminate TLS in front of it.
server {
listen 443 ssl http2;
server_name mail.example.com;
ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
# JMAP blob upload and webmail attachments.
client_max_body_size 60m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Then set TRUSTED_PROXIES or every request appears to come from 127.0.0.1 and the rate limiter treats the entire internet as one client:
TRUSTED_PROXIES=127.0.0.1Object storage#
Set the bucket and message bodies go to object storage, with only headers and flags in Postgres.
STORAGE_BUCKET=my-mail-bucket
STORAGE_REGION=nyc3
STORAGE_ENDPOINT=https://nyc3.digitaloceanspaces.com
STORAGE_ACCESS_KEY_ID=...
STORAGE_SECRET_ACCESS_KEY=...
STORAGE_PREFIX=corsairLeave STORAGE_BUCKET empty and bodies stay inline in Postgres. That works and keeps a single-container install to one dependency, but it puts mail volume through the WAL. Configure a bucket for anything real.
Objects are written with no ACL, so they inherit the bucket's default of private. Mail must never be publicly readable — check the bucket's default before pointing Corsair at it.
Verifying the install#
sudo ss -lntp | grep bun # every enabled listener
curl -s localhost:3000/api/plans # the API answers
bun scripts/migrate.ts status # every migration applied
bun scripts/migrate.ts diff # "schema in sync"Then open /app, sign in with the seeded account, and add a domain.
Upgrading#
See Upgrading. The short version: pull, install, run migrations, restart — and read the migration list before you do.