Running Multiple Instances

Several clones or jj workspaces of this project on one machine compete for the same ports. The recommended pattern is to give each clone explicit, distinct ports in its two local env files, rather than leaning on defaults or automatic port selection — then nothing depends on which instance you happened to start first. Neither file is committed:

File Read by Sets
.env.dev.local make dev, make mailpit dev server port, mailpit’s host ports, email
.env.test.local make test, make test-e2e email (opt-in, see Running tests)

Start mailpit with make mailpit rather than a bare docker compose up mailpit: mailpit is defined in docker-compose.dev.yml (not in the docker-compose.yml self-hosters copy), and on its own compose reads only a file literally named .env, so the make target passes both -f and --env-file .env.dev.local. That keeps each clone’s ports in one place.

Example: two clones side by side

Clone A in ~/src/sb-a keeps the defaults:

# .env.dev.local
DATABASE_URL=file:./data.db
AUTH_SECRET=<openssl rand -base64 32>
PORT=3000
SITE_URL=http://localhost:3000
COMPOSE_PROJECT_NAME=sb-a
MAILPIT_SMTP_PORT=1025
MAILPIT_UI_PORT=8025
SMTP_FROM=dev@example.test
SMTP_URL=smtp://localhost:1025
# .env.test.local
SMTP_URL=smtp://localhost:1025
MAILPIT_API_URL=http://localhost:8025
SMTP_FROM='Test <mailer-test@test.example>'

Clone B in ~/src/sb-b shifts every port by one:

# .env.dev.local
DATABASE_URL=file:./data.db
AUTH_SECRET=<openssl rand -base64 32>
PORT=3001
SITE_URL=http://localhost:3001
COMPOSE_PROJECT_NAME=sb-b
MAILPIT_SMTP_PORT=1026
MAILPIT_UI_PORT=8026
SMTP_FROM=dev@example.test
SMTP_URL=smtp://localhost:1026
# .env.test.local
SMTP_URL=smtp://localhost:1026
MAILPIT_API_URL=http://localhost:8026
SMTP_FROM='Test <mailer-test@test.example>'

Each clone can now run make mailpit, make dev, make test, and make test-e2e at the same time as the other. Not every line is always needed: COMPOSE_PROJECT_NAME, MAILPIT_SMTP_PORT, and MAILPIT_UI_PORT are consumed by docker compose rather than the app, and the SMTP_*/SITE_URL lines only matter if you want the dev server itself to send email — PORT alone is enough otherwise.

What each variable is for

Mailpit publishes two host ports, and the two app-side variables that point at them are unrelated to each other — each tracks a different one:

Compose variable Default Mailpit port for App-side variable to match
MAILPIT_SMTP_PORT 1025 receiving mail over SMTP SMTP_URL
MAILPIT_UI_PORT 8025 the web UI and its REST API MAILPIT_API_URL
  • SMTP_URL (utils/mailer.ts) is how schellingboard sends mail: an ordinary SMTP connection string, with mailpit merely one possible server behind it.
  • MAILPIT_API_URL (tests/helpers/mailpit.ts) is how the test suite reads mail back out of mailpit to assert on it. It is test-only and mailpit-specific; the app itself never reads it.

PORT is read by next dev. Without it a second make dev won’t fail — it quietly retries the next free port (3001, 3002, …), which is easy to miss. SITE_URL must then agree with PORT, since it’s the base URL emails use to link back to the site; a stale value sends readers to the other instance.

COMPOSE_PROJECT_NAME only matters when two clones share a directory name — compose derives the project from the directory, so ~/a/sb and ~/b/sb would otherwise fight over a single mailpit container. Setting it is cheap insurance. Note that mailpit lives in its own compose file, so a bare docker compose down won’t stop it; use the same two flags make mailpit passes — docker compose -f docker-compose.dev.yml --env-file .env.dev.local down — since without the env file compose looks for the container under the directory-derived project name instead. (Or just Ctrl-C the foreground make mailpit.)

Some things need no attention: DATABASE_URL and SB_UPLOADS_DIR are relative paths, so each clone already gets its own database and uploads. E2E runs pick a free port per run and override SITE_URL to match (playwright.config.ts), so don’t set SITE_URL in .env.test.local expecting it to apply — only SMTP_URL and MAILPIT_API_URL matter there. make test binds no port at all.