CI/CD Pipeline
Overview
push to develop → GitHub Actions CI → deploy to Atlas (staging)
push to master → GitHub Actions CI → deploy to Hermes (production)
All pipeline configuration is in .github/workflows/ci.yml.
CI Job (runs on ubuntu-latest)
Triggered on push or PR to master or develop.
Steps
- Checkout —
actions/checkout@v4 - Setup pnpm —
pnpm/action-setup@v4, version 10.32.1 - Setup Node —
actions/setup-node@v4, Node 20, pnpm cache - Install dependencies —
pnpm install --frozen-lockfile - Generate Prisma client —
pnpm --filter api exec prisma generate(required before typecheck) - Typecheck —
pnpm turbo typecheck - Lint —
pnpm turbo lint - Build —
pnpm turbo build
The CI job sets NEXT_PUBLIC_API_URL=http://localhost:3001 — required for the Next.js build to succeed (the variable is used at build time via next.config.ts).
Deploy Jobs
Both deploy jobs run on the self-hosted runner (which runs on the respective server).
Deploy jobs call the Coolify API locally (no network hop):
curl -v -X GET \
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}" \
"http://localhost:8000/api/v1/deploy?uuid=${{ secrets.COOLIFY_UUID_WEB }}&force=false"
http://localhost:8000 — Coolify listens on port 8000 on the server. The runner talks to Coolify over localhost, so the Coolify API token is not exposed over the network.
Production (deploy-production)
- Condition:
github.ref == 'refs/heads/master' - Needs:
cijob - Deploys: web (
COOLIFY_UUID_WEB) and API (COOLIFY_UUID_API) - Runner: self-hosted on Hermes
Staging (deploy-staging)
- Condition:
github.ref == 'refs/heads/develop' - Needs:
cijob - Deploys: web staging (
COOLIFY_UUID_WEB_STAGING) and API staging (COOLIFY_UUID_API_STAGING) - Runner: self-hosted on Atlas
GitHub Secrets
| Secret | Used by |
|---|---|
COOLIFY_API_TOKEN | All deploy jobs |
COOLIFY_UUID_WEB | deploy-production |
COOLIFY_UUID_API | deploy-production |
COOLIFY_UUID_WEB_STAGING | deploy-staging |
COOLIFY_UUID_API_STAGING | deploy-staging |
COOLIFY_DOCS_WEBHOOK_URL | deploy-docs |
Docs Deploy Job (deploy-docs)
The docs site deploys on both master and develop (no staging/prod split — there's one docs site on Iris):
deploy-docs:
name: Deploy Docs to Iris
runs-on: self-hosted
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop'
needs: [] # No dependency on CI job — docs don't need Prisma or full build
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm --filter=@pcmr/docs build
- name: Deploy to Coolify
run: curl -X POST "${{ secrets.COOLIFY_DOCS_WEBHOOK_URL }}"
needs: [] — docs don't depend on the CI job. Docs can deploy even if typecheck/lint fails (docs are content, not code). Adjust this if you want to block docs deploys on CI failures.
Coolify
Coolify UI: https://coolify.ctsolutions.gr (Cloudflare Access — email OTP).
App Configuration
Both apps use explicit Dockerfiles (not Nixpacks — pnpm monorepos aren't handled reliably by Nixpacks):
| Setting | Value |
|---|---|
| Build pack | Dockerfile |
| Base directory | / (Turborepo needs the full workspace) |
| Dockerfile path | apps/web/Dockerfile or apps/api/Dockerfile |
Environment Variables
All environment variables are set in the Coolify UI — never committed to the repository.
Container Logs
Access via Coolify UI → Application → Logs tab, or via:
sudo docker logs <container_name> --follow
Migration Automation
apps/api/start.sh runs before the NestJS server starts on every container boot:
#!/bin/sh
set -e
echo "Running database migrations..."
node /app/node_modules/.pnpm/.../prisma/build/index.js \
migrate deploy \
--config /app/prisma/prisma.config.deploy.js
echo "Migrations complete. Starting server..."
exec node dist/src/main.js
This means migrations run automatically on every deploy — no manual intervention needed for schema changes. migrate deploy applies only unapplied migrations (idempotent).
prisma.config.deploy.js uses absolute paths and module.exports format (not ESM) — required by Prisma v7's config file format:
module.exports = {
schema: "/app/prisma/schema.prisma",
migrations: "/app/prisma/migrations",
};
Baseline Migration Strategy
Migration 9 (20260419164134) is a full schema baseline — it contains the complete schema. Migrations 1–8 are superseded.
For new environments (fresh database):
- Drop and recreate the schema
- Mark migrations 1–8 as applied without running them:
for i in 1 2 3 4 5 6 7 8; doprisma migrate resolve --applied "<migration_name_$i>"done
- Run
prisma migrate deploy— only migration 9 executes against the empty schema