ci: Coolify autodeploy from main; migrate-on-boot and split dev/prod compose

- Add Nitro plugin to apply Drizzle migrations on server boot (skips when
  DATABASE_URL is unset, e.g. during build).
- Split compose: docker-compose.dev.yml (hot-reload + Postgres) vs
  docker-compose.yml (production/self-host, env-driven, Coolify-deployable).
- Add .dockerignore; parameterise compose env; document the autodeploy
  decision (Coolify watches main, no CI workflow needed) in ADR 0003 and
  plan.md T12.
This commit is contained in:
Julien Calixte
2026-05-27 23:11:51 +02:00
parent c71718ccbc
commit 405b929f66
11 changed files with 135 additions and 29 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
.git
node_modules
.nuxt
.output
.data
*.log
.env
.env.*
!.env.example
docker-compose*.yml
Dockerfile*
tests
docs
tasks
.DS_Store
.fleet
.idea

View File

@@ -4,6 +4,11 @@ NUXT_SESSION_SECRET=change-me
# Database (Postgres) — Coolify-managed in prod, docker-compose in dev
DATABASE_URL=postgres://andon:andon@localhost:5432/andon
# Credentials for the bundled Postgres service in docker-compose.yml
POSTGRES_USER=andon
POSTGRES_PASSWORD=andon
POSTGRES_DB=andon
# Google OAuth (Task 9) — restricted to the theodo.com hosted domain
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

View File

@@ -4,9 +4,8 @@
FROM node:24-alpine AS build
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm build
# --- Runtime stage: minimal image running the built server ---

View File

@@ -3,7 +3,7 @@ RUN corepack enable
WORKDIR /app
# Install dependencies first for better layer caching.
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .

View File

@@ -10,7 +10,7 @@ Two apps, one codebase, behind Google SSO (`@theodo.com`):
- **`andon.apoena.dev`** — the reporting view: click an ASCII board section, pick
your project, describe the problem.
- **`dashboard.andon.apoena.dev`** — the same board as a red-dot defect map, a
- **`dashboard-andon.apoena.dev`** — the same board as a red-dot defect map, a
reverse-chronological feed, and verbatims in a modal.
## Status
@@ -33,14 +33,35 @@ House of Quality: **F1 board definition → F2/F3 filing → F4/F5 weak-point ma
## Stack
Nuxt 3 (full-stack Vue) · PostgreSQL + Drizzle (Coolify-managed, auto-backups) ·
Nuxt 4 (full-stack Vue) · PostgreSQL + Drizzle (Coolify-managed, auto-backups) ·
Google OAuth (`hd=theodo.com` + server-side domain recheck) · Web Push (PWA) ·
Docker Compose on Coolify.
Docker on Coolify.
## Deployment notes
## Running with Docker
- **Use Coolify's managed Postgres** (with scheduled backups); the app connects
via `DATABASE_URL`. See [ADR 0003](./docs/adr/0003-use-coolify-managed-postgres-over-sqlite.md).
- **Don't auto-deploy from `main`** — every merge would deploy. Use a tagged
release or manual trigger.
- **Don't trust the OAuth `hd` claim alone** — verify the email domain server-side.
```bash
# Production-like full stack (build the image + Postgres)
docker compose up --build # app on http://localhost:3000
# Local development (hot-reload, source-mounted)
docker compose -f docker-compose.dev.yml up
# Or the dev server directly against a containerized Postgres
docker compose -f docker-compose.dev.yml up -d db
DATABASE_URL=postgres://andon:andon@localhost:5432/andon pnpm dev
```
Migrations are applied automatically on server boot (a Nitro plugin) — no manual
migrate step is needed.
## Deploying to Coolify
1. Point Coolify at this repo; it builds the production `Dockerfile`.
2. Create a **managed Postgres** in Coolify (automatic backups — [ADR 0003](./docs/adr/0003-use-coolify-managed-postgres-over-sqlite.md))
and set `DATABASE_URL` to it. _(Alternatively deploy `docker-compose.yml`, which bundles a Postgres service.)_
3. Set the env vars from [`.env.example`](./.env.example) (`NUXT_SESSION_SECRET`,
Google OAuth, VAPID) and route `andon.apoena.dev` + `dashboard-andon.apoena.dev` to the service.
4. **Enable Coolify's autodeploy** so it builds and deploys on every push to
`main`. Protect `main` with required PR review + CI so only vetted commits
reach production.
5. The OAuth `hd` claim is spoofable — the app re-checks the email domain server-side (T9).

37
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,37 @@
# Local development stack: app with hot-reload (source bind-mounted) + Postgres.
# docker compose -f docker-compose.dev.yml up
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://andon:andon@db:5432/andon
HOST: 0.0.0.0
depends_on:
db:
condition: service_healthy
volumes:
- .:/app
- /app/node_modules
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: andon
POSTGRES_PASSWORD: andon
POSTGRES_DB: andon
ports:
- "5432:5432"
volumes:
- andon_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U andon"]
interval: 5s
timeout: 5s
retries: 5
volumes:
andon_pgdata:

View File

@@ -1,32 +1,40 @@
# Production / self-host stack (Coolify-deployable as a Docker Compose resource).
# For production, prefer a Coolify-managed Postgres (automatic backups — see
# docs/adr/0003) by setting DATABASE_URL to it; the bundled `db` service is for
# self-hosting and local production testing.
# docker compose up --build
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://andon:andon@db:5432/andon
HOST: 0.0.0.0
DATABASE_URL: ${DATABASE_URL:-postgres://andon:andon@db:5432/andon}
NUXT_SESSION_SECRET: ${NUXT_SESSION_SECRET:-change-me}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:owner@theodo.com}
depends_on:
db:
condition: service_healthy
volumes:
- .:/app
- /app/node_modules
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: andon
POSTGRES_PASSWORD: andon
POSTGRES_DB: andon
ports:
- "5432:5432"
POSTGRES_USER: ${POSTGRES_USER:-andon}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-andon}
POSTGRES_DB: ${POSTGRES_DB:-andon}
volumes:
- andon_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U andon"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-andon}"]
interval: 5s
timeout: 5s
retries: 5

View File

@@ -18,5 +18,6 @@ switch to **Coolify-managed PostgreSQL** (via Drizzle's `pg` driver).
prod — one more moving part than a single file.
- **Supersedes** the SQLite choice in DESIGN.md (T5, F10) and the original
`/deep-design` decision.
- Unchanged: deploys must **not** auto-run from `main` (every merge would deploy);
use a tagged release or manual trigger.
- Autodeploy from `main` is enabled (Coolify watches the repo and deploys on
push); `main` must be protected with required PR review + CI so only vetted
commits reach production.

View File

@@ -2,6 +2,7 @@
"name": "andon",
"type": "module",
"private": true,
"packageManager": "pnpm@11.0.9",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
@@ -14,7 +15,10 @@
"typecheck": "nuxt typecheck",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx server/db/migrate.ts",
"db:verify": "tsx scripts/verify-db.ts"
"db:verify": "tsx scripts/verify-db.ts",
"docker:dev": "docker compose -f docker-compose.dev.yml up",
"docker:dev:build": "docker compose -f docker-compose.dev.yml up --build",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down"
},
"dependencies": {
"drizzle-orm": "^0.45.2",

13
server/plugins/migrate.ts Normal file
View File

@@ -0,0 +1,13 @@
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { getDb } from '../db/client'
// Apply pending migrations once when the server boots (dev and prod). Skipped
// when DATABASE_URL is unset (e.g. during build) so it never blocks the bundle.
export default defineNitroPlugin(async () => {
if (!process.env.DATABASE_URL) {
console.warn('[migrate] DATABASE_URL not set — skipping migrations')
return
}
await migrate(getDb(), { migrationsFolder: 'server/db/migrations' })
console.log('[migrate] migrations applied')
})

View File

@@ -313,7 +313,8 @@ section, project, and a verbatim snippet; log failures (no retry queue in v1).
**Description:** Production Dockerfile; provision Coolify-managed Postgres with
scheduled backups; configure the two domains and TLS on Coolify; wire
env/secrets (`DATABASE_URL`, Google client, VAPID, session secret); run the
migration on boot. Deploy from a tagged release/manual trigger, not auto-`main`.
migration on boot. Coolify autodeploys on push to `main`, with `main`
protected by PR review + CI.
**Acceptance criteria:**
- [ ] After a redeploy, previously filed defects are still present (0 data loss).
@@ -339,7 +340,7 @@ migration on boot. Deploy from a tagged release/manual trigger, not auto-`main`.
| Risk | Impact | Mitigation |
|------|--------|------------|
| Data lost on redeploy / disk failure | High | Coolify-managed Postgres with scheduled backups; redeploy durability test (T12, ADR 0003) |
| Auto-deploy from `main` ships every merge | Med | Deploy from tagged release / manual trigger only (T12) |
| Autodeploy from `main` ships every merge | Med | Protect `main` with required PR review + CI; migrations-on-boot + healthcheck catch failures; Coolify keeps rollback (T12) |
| Google `hd` claim spoofed by a custom client | High | Server-side verified-email-domain recheck (T9) |
| Google OAuth credentials/redirect URIs unavailable | Med | `getReporter()` seam lets Phases 13 proceed with dev auth (T5, T9) |
| Dot map unreadable on hot Sections | Med | Visible-dot cap + "+N more" (T7) |