Skip to content

Local development stack

The local dev stack is what you’ll iterate against. It’s completely self-contained — no external accounts, no provider API keys required to boot. Same compose file is consulted on every CI run.

docker-compose.yml — services + profiles
infra (always-on)
postgres :5433 → 5432 inside the container
redis :6380 → 6379
mailpit :1026 SMTP, :8026 UI
seaweedfs :9000 S3 API (scani-dev / scani-dev-secret)
init (one-shot, exit cleanly)
env-sync — materialises per-app .env files from root .env
deps — bun install on the shared workspace volume
migrate — applies the application and queue schemas to postgres
services (profile: full)
data-provider :8082
api :3011
worker
frontend :5173 (Vite dev server)
  1. Seed the root env file.

    Terminal window
    cp .env.example .env

    The defaults work as-is for local development.

  2. Boot.

    Terminal window
    bun install
    bun run dev:stack

    bun run dev:stack is scripts/sync-env.ts + docker compose --profile full up -d --build. First boot pulls images and builds the app containers.

  3. Open the SPA.

    Terminal window
    open http://localhost:5173
  4. Sign in.

    Magic-link emails land in Mailpit. Click the link there.

Terminal window
bun run dev:stack:down # remove containers; volumes preserved

To also wipe Postgres / SeaweedFS / Redis data:

Terminal window
bun run dev:stack:down -- --volumes

Run apps on the host instead of in containers

Section titled “Run apps on the host instead of in containers”

Faster HMR, easier debugger attach, host-side breakpoints — useful when iterating on the api or worker:

Terminal window
bun run dev:stack:infra # Postgres, Redis, SeaweedFS, Mailpit — nothing else
bun install
bun dev # api + frontend/app
bun dev:worker # separate terminal
bun dev:data-provider # separate terminal

The api binds PORT from the root .env on this path — 3001, not the 3011 in Default ports below. See the note under that table.

The root .env is the single source of truth. scripts/sync-env.ts materialises per-app .env files from it on every bun run dev:stack and on every env-sync container run.

The script:

  1. Reads root .env.
  2. For each app under apps/*/, parses that app’s .env.example to discover which keys it cares about.
  3. Writes per-app .env filtered to only those keys.
  4. Skips writes when content is unchanged (prevents Vite dev-server restart loops).

Adding a new env var an app needs: append it to that app’s .env.example and to root .env.example. The script picks it up automatically.

The compose file overrides URLs that need a container-network hostname (postgres, redis, seaweedfs, mailpit, data-provider) on the relevant service’s environment: block. The root .env versions are for host-side bun dev; the compose overrides take precedence inside containers.

Host ports published by the containerised stack — the bun run dev:stack path. A linked git worktree offsets every one of them and prints what it chose; the numbers below are what a plain checkout gets.

ServiceHost port
Postgreslocalhost:5433 (user scani, password scani)
Redislocalhost:6380
Mailpit SMTPlocalhost:1026
Mailpit UIhttp://localhost:8026
SeaweedFS S3 APIhttp://localhost:9000
Data-providerhttp://localhost:8082
APIhttp://localhost:3011
Frontendhttp://localhost:5173

On the host-side path (bun dev, above) only the api moves: it binds PORT from the root .env, which .env.example ships as 3001. 3011 is the host port compose publishes for the containerised api (container port 8080), and nothing publishes it when the api runs on the host. The data-provider (8082) and the SPA (5173) are the same on both paths.

The database the stack’s apps use is not scani. bun run dev:stack creates one per checkout and passes its name as SCANI_DEV_DB; the compose default scani applies only to a stack started without it.

Each of these drives compose directly, so export this checkout’s identity first — otherwise they address a project bun run dev:stack never created, and say nothing about it:

Terminal window
export $(bun scripts/dev-stack.ts env | xargs)
Terminal window
# Rebuild just one image
docker compose --profile full up -d --build api
# Tail logs across services
docker compose --profile full logs -f api worker data-provider
# Run migrations on demand (when Drizzle generates new ones)
docker compose --profile migrate run --rm migrate
# Open a psql shell on this checkout's database
docker compose exec postgres psql -U scani "$SCANI_DEV_DB"
# Open a redis-cli
docker compose exec redis redis-cli