Pushing to main triggers .github/workflows/deploy.yml, which copies the repo
to the server, builds images, runs migrations, and starts services.
Prepare server → Copy files → Setup env → Build images → Run migrations → Start services
No teardown. The start step is docker compose up -d with no preceding
down, so only services whose image or configuration changed are recreated.
Postgres, Redis, and MinIO are left running.
This matters more than it sounds. down took the whole site offline for the
length of every deploy, and anyone hitting it in that window got a Cloudflare
502 with no explanation — including the database, which was restarted on every
push for no reason.
--remove-orphans is deliberately not used: Postgres was an undeclared orphan
until recently, and if a service ever falls out of the compose file again, that
flag would delete its container on the next deploy.
prisma migrate deploy runs between building images and starting services, so a
schema change arrives with the code that needs it. The API image carries the
Prisma CLI because it is a devDependency and npm ci installs devDependencies.
Before this existed, the schema was only ever advanced by hand — which meant code could deploy referencing a table that did not exist.
Apply it to a throwaway database on the server and ask Prisma whether the
migration history reproduces schema.prisma exactly:
docker exec infra-postgres-1 psql -U drawpro -d postgres \
-c "CREATE DATABASE drawpro_mcheck;"
docker run --rm --network container:infra-postgres-1 \
-v /tmp/prisma-check:/w -w /w \
-e DATABASE_URL='postgresql://drawpro@localhost:5432/drawpro_mcheck' \
node:20-alpine sh -c '
apk add --no-cache openssl
npx -y prisma@5.22.0 migrate deploy
npx -y prisma@5.22.0 migrate diff \
--from-url "$DATABASE_URL" --to-schema-datamodel schema.prisma --exit-code
'
Exit code 0 means no drift. Sharing the Postgres container's network namespace
reaches it on 127.0.0.1, which pg_hba.conf grants trust — avoiding
quoting a password through three shell layers.
Dockerfiles copy package-lock.json and use npm ci, so images install exactly
what the lockfile pins.
They previously ran npm install without the lockfile, and the deploy builds
with --no-cache, so every deploy re-resolved every ^range against whatever
was newest that day. A dependency release could break production with no code
change at all.
Each Dockerfile copies only its own workspace's package.json, so npm ci
installs just that subtree: api-ts 256 packages, collab 150, frontend 415.
Copying every workspace manifest instead would install 983, including Electron,
into the API image.
nginx and the API must agree. nginx defaults to 1MB; the API allows 10MB. When they disagreed, a large sheet was rejected at the proxy with a 413 the API never saw and never logged. Both are now 10MB.
| Service | Port | |
|---|---|---|
| frontend | 80 | Nginx serving the SPA, proxying /api/ |
| api | 3001 | Express REST API |
| postgres | 5432 | Primary datastore |
| redis | 6379 | Refresh-token store |
| minio | 9000 | Object storage |
| collab | 3002 | Yjs — behind the collab profile, off by default |
Real-time collaboration is not enabled: the server exists but no client is wired
to it. Re-enable with docker compose --profile collab up, and restore the
/collab/ block in apps/frontend/nginx.conf at the same time — nginx resolves
proxy_pass hostnames at startup, so an active block with no container fails
the frontend's boot.