diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 691c09e..5224ed0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,7 +52,8 @@ jobs: NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }} VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }} run: | - docker compose -f ./docker-compose.yml --profile production -p "template-pr-${PR_NUMBER}" up --build -d + # Build overlay, so the PR runs an image built from this branch. + docker compose -f ./docker-compose.yml -f ./docker-compose.build.yml --profile production -p "template-pr-${PR_NUMBER}" up --build -d # Wait for services to be healthy echo "Waiting for services to start..." diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..b8e8fa5 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,121 @@ +name: Publish Image + +# Builds the image in CI so the shared Coolify host only pulls and restarts. +# One image covers both compose services; the migration one just runs a different command. + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: true + +env: + # Must be lowercase — GHCR rejects mixed-case image names, and the org is "C4G". + # Keep in sync with the `image:` value in docker-compose.yml. + IMAGE: ghcr.io/c4g/template + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + # No build-args, so one image works in every environment. See Dockerfile. + cache-from: type=gha + cache-to: type=gha,mode=max + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ github.sha }} + + # CI only ever builds the PR head; the squashed commit on main is not + # exercised until here. Runs the production compose with no build overlay. + - name: Smoke test the published image + env: + IMAGE_TAG: ${{ github.sha }} + DATABASE_PW: smoke + DATABASE_USER: smoke + DATABASE_NAME: smoke + DATABASE_PORT: '' + APP_PORT: '' + AUTH_SECRET: smoke-only-not-a-real-secret-value + AUTH_GOOGLE_ID: '' + AUTH_GOOGLE_SECRET: '' + RESEND_API_KEY: '' + NEXT_PUBLIC_VAPID_PUBLIC_KEY: smoke-public-key + VAPID_PRIVATE_KEY: smoke-private-key + run: | + docker compose -f docker-compose.yml --profile production -p smoke up -d --pull always + + app=$(docker compose -p smoke ps -q --all template-app) + mig=$(docker compose -p smoke ps -q --all template-migrations) + + for i in $(seq 1 60); do + status=$(docker inspect -f '{{.State.Health.Status}}' "$app") + echo "attempt $i: $status" + case "$status" in healthy|unhealthy) break ;; esac + sleep 2 + done + + code=$(docker inspect -f '{{.State.ExitCode}}' "$mig") + if [ "$code" != "0" ]; then + echo "::error::migrations exited $code in the published image" + docker compose -p smoke logs + exit 1 + fi + + if [ "$(docker inspect -f '{{.State.Health.Status}}' "$app")" != "healthy" ]; then + echo "::error::published image never became healthy" + docker compose -p smoke logs + exit 1 + fi + + docker exec "$app" wget -qO- http://localhost:3000/api/health + echo "OK — pulled image migrated and served" + + - name: Stop smoke test stack + if: always() + run: docker compose -f docker-compose.yml --profile production -p smoke down --volumes --remove-orphans || true + + - name: Summary + run: | + { + echo "### Image published" + echo '' + echo '```' + echo "${IMAGE}:latest" + echo "${IMAGE}:${GITHUB_SHA}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # Deploy from here, not Coolify's git webhook, so it cannot pull `:latest` + # mid-push. Skipped until both values exist (secrets need `env` for `if:`). + - name: Trigger Coolify deployment + env: + COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} + COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }} + if: ${{ env.COOLIFY_TOKEN != '' && env.COOLIFY_APP_UUID != '' }} + run: | + curl -fsS -X GET \ + "https://coolify.c4g.dev/api/v1/deploy?uuid=${COOLIFY_APP_UUID}" \ + -H "Authorization: Bearer ${COOLIFY_TOKEN}" diff --git a/Dockerfile b/Dockerfile index ba08535..06bf5f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,13 +21,8 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ FROM base AS builder WORKDIR /app -# Declare build arguments for Next.js public variables -ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY -ARG BETTER_AUTH_URL - -# Set environment variables from build args -ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=$NEXT_PUBLIC_VAPID_PUBLIC_KEY -ENV BETTER_AUTH_URL=$BETTER_AUTH_URL +# No build args on purpose: Next.js only inlines NEXT_PUBLIC_* vars that exist +# at build time, so leaving them unset keeps one image usable in every environment. # Copy package files COPY package.json pnpm-lock.yaml ./ @@ -46,6 +41,17 @@ RUN pnpm exec prisma generate # Build Next.js application RUN pnpm run build +# Prisma CLI for `migrate deploy`. npm, not pnpm: pnpm's symlink farm does not +# survive a COPY between stages. `npm init -y` keeps it to just these packages. +FROM base AS migrator +WORKDIR /src +COPY package.json ./ +WORKDIR /migrator +RUN npm init -y > /dev/null && \ + npm install --no-audit --no-fund \ + "prisma@$(node -p "require('/src/package.json').devDependencies.prisma")" \ + "dotenv@$(node -p "require('/src/package.json').dependencies.dotenv")" + # Production image, copy all the files and run next FROM base AS runner WORKDIR /app @@ -63,6 +69,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Migration tooling, so the migration service can run this same image. At the +# root, not /app/node_modules, which has symlinks a directory COPY cannot cross. +COPY --from=migrator --chown=nextjs:nodejs /migrator/node_modules /node_modules +COPY --chown=nextjs:nodejs prisma ./prisma +COPY --chown=nextjs:nodejs prisma.config.ts ./prisma.config.ts + USER nextjs EXPOSE 3000 diff --git a/Dockerfile.migrations b/Dockerfile.migrations deleted file mode 100644 index cee6095..0000000 --- a/Dockerfile.migrations +++ /dev/null @@ -1,30 +0,0 @@ -# Dockerfile for running Prisma migrations -# This is a separate, lightweight container that runs migrations before the app starts - -FROM node:24-alpine - -# Enable corepack and prepare pnpm -RUN corepack enable && corepack prepare pnpm@latest --activate - -WORKDIR /app - -# Create nextjs user for consistency -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nextjs - -# Copy only files needed for migrations -COPY --chown=nextjs:nodejs package.json pnpm-lock.yaml ./ -COPY --chown=nextjs:nodejs prisma ./prisma -COPY --chown=nextjs:nodejs prisma.config.ts ./prisma.config.ts - -# Install production dependencies (skip postinstall to avoid prisma generate before CLI is available) -# Then install prisma CLI from devDependencies and generate client -RUN pnpm install --frozen-lockfile --prod --ignore-scripts && \ - pnpm add -D prisma && \ - pnpm exec prisma generate - -# Switch to non-root user -USER nextjs - -# Run migrations -CMD ["pnpm", "prisma", "migrate", "deploy"] diff --git a/README.md b/README.md index 51faf50..6b4fa63 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,10 @@ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-applic ## Manual Updates after cloning the template (by C4G staff) -1. Replace `template` in many files to your project name. +1. Replace `template` in many files to your project name. This includes the + `ghcr.io/c4g/template-*` image names in `docker-compose.yml` and `IMAGE_BASE` + in `.github/workflows/publish.yaml`, plus a `COOLIFY_APP_UUID` repository + variable pointing at the new project's Coolify application. 2. Setup oauth settings in [GCP](https://console.cloud.google.com/apis/credentials?project=c4g-template) 3. Setup nginx configuration, and re-run SSL cert on [C4G Server](https://c4g.dev). 4. Generate VAPID keys for PWA setup [Generator](https://vapidkeys.com/) @@ -113,15 +116,102 @@ The application uses Docker Compose for production deployments with an automated ### Architecture - **Database**: PostgreSQL 17 with persistent volume storage -- **Migrations**: Separate init container that runs database migrations before the app starts +- **Migrations**: Init container that runs database migrations before the app + starts, from the same image as the app - **Application**: Next.js standalone server with optimized production build +### Image Publishing (CD) + +`.github/workflows/publish.yaml` runs on every push to `main` (and on manual +dispatch). It builds one image, pushes it to GHCR, and then triggers a Coolify +deployment: + +- `ghcr.io/c4g/template:latest` and `:` + +`docker-compose.yml` references that published image and has **no `build:` +keys**, which is what keeps the shared Coolify host from compiling the +application on every deploy — it only pulls and restarts. The deploy is +triggered from the workflow rather than by Coolify's git webhook so that +Coolify cannot pull `:latest` before the new image has finished uploading. + +### One image, both services + +`template-migrations` and `template-app` run the **same image** with different +commands. The image ships the Prisma CLI (the `migrator` stage in `Dockerfile` +installs it on its own), so the migration step needs nothing extra: + +```yaml +template-migrations: + image: ghcr.io/c4g/template:${IMAGE_TAG:-latest} + command: ['node', '/node_modules/prisma/build/index.js', 'migrate', 'deploy'] +``` + +The ordering guarantee is unchanged — the app still waits on +`service_completed_successfully`, so it starts only after migrations exit 0. + +The migration tooling is installed with **npm**, not pnpm, and lands at +`/node_modules` rather than `/app/node_modules`. Both details are load-bearing: +pnpm's symlink farm does not survive a `COPY` between stages, and the Next.js +standalone output contains symlinked packages, so copying a directory over +`/app/node_modules` fails with `cannot copy to non-directory`. `/node_modules` +is the last place Node looks when resolving from `/app`, so `prisma.config.ts` +still finds `dotenv` and `prisma/config` while the application's own resolution +is untouched. + +A previous version built a second image from a `Dockerfile.migrations` that ran +`pnpm install --prod` — pulling Next, React and every other runtime dependency +in order to run one command. That image was 1.63 GB to carry 94 kB of +migrations. Publishing one image instead cut the total pulled per deploy from +about 2 GB to 685 MB, and halved the number of GHCR packages to keep public. + +Required repository/organization configuration: + +| Name | Kind | Purpose | +| ------------------ | -------- | --------------------------------------------- | +| `COOLIFY_TOKEN` | secret | Coolify API token (organization-level secret) | +| `COOLIFY_APP_UUID` | variable | UUID of the Coolify application to redeploy | + +The deploy step skips itself when either Coolify value is missing, so a copy of +this template publishes images without redeploying the template's own app. + +The build itself needs no application secrets — see below. + +### One image, many environments + +Nothing environment-specific is baked into the image, so the same build can back +several Coolify applications. `IMAGE_TAG` selects which build each one runs: +leave it unset to track `latest`, or pin it to a commit SHA in the application's +Coolify environment variables to promote a build that has already been verified +elsewhere. **Adding a test environment later is therefore just a second Coolify +application pointed at this same compose file** — no repository changes, no +second image. + +This requires that no `NEXT_PUBLIC_*` variable is present during the build. +Next.js substitutes those into the bundle only when they exist at build time, so +leaving them unset keeps `process.env.NEXT_PUBLIC_*` in the compiled server +output as a real runtime lookup, and each environment supplies its own value +through Coolify. + +It works for `NEXT_PUBLIC_VAPID_PUBLIC_KEY` because that value is read +server-side only (`src/lib/web-push.ts`); the browser fetches the key from +`GET /api/notifications/subscribe` rather than reading an inlined copy. If +client code ever needs a `NEXT_PUBLIC_*` value directly it will be `undefined` +in the browser, and baking it in to fix that would re-tie the image to a single +environment — serve it from an API route or a server component prop instead. + ### Deployment Commands -Build and start all services: +Pull the published image and start all services: + +```bash +docker compose --profile production up -d +``` + +Build the image from source instead (local verification, and what CI does): ```bash -docker compose --profile production up -d --build +docker compose -f docker-compose.yml -f docker-compose.build.yml \ + --profile production up -d --build ``` Check service status: diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 0000000..9083133 --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,7 @@ +# Adds the `build:` key that docker-compose.yml omits, for building locally: +# docker compose -f docker-compose.yml -f docker-compose.build.yml --profile production up --build -d +services: + template-app: + build: + context: . + dockerfile: Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 09392a6..1261251 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +# Production stack, deployed as a Coolify "Docker Compose" resource. No `build:` +# keys on purpose — .github/workflows/publish.yaml builds and publishes the image. services: template-db: image: postgres:17 @@ -17,11 +19,11 @@ services: start_period: 30s start_interval: 2s - # Database migrations (production only) + # Database migrations (production only). Same image as the app, different + # command — the image ships the Prisma CLI for exactly this. template-migrations: - build: - context: . - dockerfile: Dockerfile.migrations + image: ghcr.io/c4g/template:${IMAGE_TAG:-latest} + command: ["node", "/node_modules/prisma/build/index.js", "migrate", "deploy"] environment: - DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PW}@template-db:5432/${DATABASE_NAME} restart: "no" @@ -33,12 +35,7 @@ services: # Next.js application (production only) template-app: - build: - context: . - dockerfile: Dockerfile - args: - - NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY} - - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} + image: ghcr.io/c4g/template:${IMAGE_TAG:-latest} ports: - "${APP_PORT-3001:}3000" environment: diff --git a/src/test/setup.ts b/src/test/setup.ts index a8fca5f..bc0e16c 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -2,6 +2,18 @@ import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import { afterEach, beforeAll, vi } from 'vitest'; +// Every render goes through ImpersonationProvider (src/test/test-utils.tsx), +// which calls useSession. The real client leaves a nanostores timer that touches +// `window` after jsdom teardown, failing the run. Tests needing session values +// override this via src/test/mocks.tsx. +vi.mock('@/lib/auth-client', () => ({ + authClient: {}, + useSession: () => ({ data: null, isPending: false, refetch: vi.fn() }), + signOut: vi.fn(), + signIn: { email: vi.fn(), social: vi.fn() }, + signUp: { email: vi.fn() }, +})); + // Mock matchMedia Object.defineProperty(window, 'matchMedia', { writable: true,