Install and operate
Upgrading
The sequence is pull, install, migrate, restart. The order matters and the migration step is the one that can hurt.
Before you start#
pg_dump --format=custom --no-owner "$DATABASE_URL" > pre-upgrade-$(date +%F).dumpTake it every time. Migrations are forward-only in practice — migrate down rolls back exactly one migration, and only if that migration wrote a down — so the dump is your actual undo.
Read what changed:
git fetch origin
git log --oneline HEAD..origin/main
git diff --stat HEAD..origin/main -- migrations/A changed migrations/ directory means schema work. Read those files before running anything; hand-written SQL is easier to review than it is to reverse.
The upgrade#
cd /opt/corsair/app
sudo -u corsair git pull
sudo -u corsair bun installCheck what is pending, then apply:
sudo -u corsair bun scripts/migrate.ts status
sudo -u corsair bun scripts/migrate.ts upConfirm the schema matches what the code expects:
sudo -u corsair bun scripts/migrate.ts diff
# schema in syncdiff compares the live database against allSchemas. Anything other than "schema in sync" means the migration did not fully land — do not restart into that state.
sudo systemctl restart corsairThen verify:
systemctl status corsair
sudo ss -lntp | grep bun # every listener came back
curl -sf localhost:3000/api/plans # the API answersWhy migrations do not run at startup#
Two instances coming up at once would race on the migration table. Running it separately also means a failed migration fails there, visibly, rather than inside a service that then restart-loops.
On a single-host install this is a small inconvenience. On anything with more than one instance it is the difference between a controlled upgrade and a corrupted schema.
With Docker Compose#
The compose file already sequences it: the migrate service runs once and exits, and corsair waits for service_completed_successfully.
cd /opt/corsair
git pull
docker compose build
docker compose up -dTo run migrations by hand instead:
docker compose run --rm corsair bun scripts/migrate.ts up
docker compose up -d --force-recreate corsairZero-downtime, roughly#
Corsair is one process, so a restart drops connections. You can narrow the window rather than eliminate it.
Split the tiers. Run src/server.ts for HTTP and src/start.ts with the listeners for mail. Deploy the HTTP tier without touching IMAP sessions.
Two mail hosts behind one MX. Publish two MX records at equal priority. Senders retry the other on a refused connection, so restarting one at a time loses nothing — SMTP is built to retry, and this is the case it was built for.
Accept the blip. A restart drops IMAP IDLE sessions, which clients reconstruct within seconds, and in-flight SMTP transactions, which senders retry for days. For most installs this is the right answer.
Rolling back#
Application only, no migration involved:
git checkout <previous-tag>
bun install
sudo systemctl restart corsairAfter a migration — restore the dump. A newer schema with older code is not a supported combination, and rolling the schema back by hand while mail is arriving is not a good afternoon.
sudo systemctl stop corsair
sudo -u postgres dropdb corsair
sudo -u postgres createdb -O corsair corsair
pg_restore --no-owner --dbname "$DATABASE_URL" pre-upgrade-2026-08-10.dump
git checkout <previous-tag>
sudo systemctl start corsairEverything that happened between the dump and the rollback is lost — messages received, messages sent, flags changed. That is why the dump is taken immediately before the upgrade and not that morning.
Upgrading PostgreSQL#
Corsair targets PostgreSQL 17. A major version upgrade is a Postgres operation:
pg_dump --format=custom --no-owner "$DATABASE_URL" > full.dump
# install the new major version, create the cluster
pg_restore --no-owner --dbname "postgres://…/corsair" full.dumpStop Corsair first. A dump taken while mail is arriving is a dump of a moving target, and the one thing you cannot tolerate is a folder whose uid_next is older than its messages.
Upgrading Bun#
bun upgrade
sudo systemctl restart corsairAfter any upgrade#
- Send a message out, check for
dkim=pass - Receive one from outside
- Connect a client over IMAP
bun scripts/migrate.ts diffreports in sync- The queue is draining
- Nothing new in
journalctl -u corsair --since "10 minutes ago" | grep -i error
Then delete the pre-upgrade dump — or keep it for a week, which is cheaper than regretting it.