From 41965fcda63720ecb5ce469ab443cb32324feb00 Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Fri, 7 Aug 2026 22:13:22 -0500 Subject: [PATCH 1/6] Build and publish container images on push to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coolify compiles the application on the shared host on every deploy, which ties up CPU and disk for the other applications on the box. Move the build to GitHub Actions: publish.yaml builds the app and migration images, pushes them to GHCR, and then calls the Coolify deploy API so the host only pulls and restarts. docker-compose.yml now references the published images and has no `build:` keys — that absence is what keeps the build off the server. The build keys move to docker-compose.build.yml, which CI overlays so PR runs still verify an image built from the branch rather than the published :latest. The deploy step is guarded on COOLIFY_TOKEN and the COOLIFY_APP_UUID variable so a copy of this template publishes images without redeploying the template's own application. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 5 +- .github/workflows/publish.yaml | 98 ++++++++++++++++++++++++++++++++++ README.md | 46 ++++++++++++++-- docker-compose.build.yml | 28 ++++++++++ docker-compose.yml | 24 +++++---- 5 files changed, 188 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/publish.yaml create mode 100644 docker-compose.build.yml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 691c09e..d1eb448 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,7 +52,10 @@ 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 + # docker-compose.build.yml restores the `build:` keys that the + # production file omits, so the PR is verified against an image built + # from this branch rather than the published :latest one. + 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..9490df0 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,98 @@ +name: Publish Images + +# Builds the application and migration images and pushes them to GHCR so the +# Coolify host pulls finished images instead of compiling them. Building on the +# shared server ties up CPU and disk for every other application on the box; +# doing it here keeps deploys to a pull-and-restart. +# +# docker-compose.yml has no `build:` keys for these services on purpose — see +# the comment at the top of that file. + +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:` values in docker-compose.yml. + IMAGE_BASE: 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 migrations image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile.migrations + push: true + tags: | + ${{ env.IMAGE_BASE }}-migrations:latest + ${{ env.IMAGE_BASE }}-migrations:${{ github.sha }} + cache-from: type=gha,scope=migrations + cache-to: type=gha,mode=max,scope=migrations + + - name: Build and push app image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + # Next.js inlines NEXT_PUBLIC_* at build time, so the VAPID public key + # has to be baked in here rather than supplied by Coolify at runtime. + build-args: | + NEXT_PUBLIC_VAPID_PUBLIC_KEY=${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }} + cache-from: type=gha,scope=app + cache-to: type=gha,mode=max,scope=app + tags: | + ${{ env.IMAGE_BASE }}-app:latest + ${{ env.IMAGE_BASE }}-app:${{ github.sha }} + + - name: Summary + run: | + { + echo "### Images published" + echo '' + echo '```' + echo "${IMAGE_BASE}-app:latest" + echo "${IMAGE_BASE}-app:${GITHUB_SHA}" + echo "${IMAGE_BASE}-migrations:latest" + echo "${IMAGE_BASE}-migrations:${GITHUB_SHA}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # Deploying from here (rather than letting Coolify redeploy on git push) + # avoids a race where Coolify pulls `:latest` before this workflow has + # finished pushing it. Skipped automatically until both values exist, so a + # copy of this template does not redeploy the template's own application. + # The secret is surfaced through `env` because the `secrets` context is + # not available in a step-level `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/README.md b/README.md index 51faf50..6a9d4f6 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/) @@ -116,12 +119,49 @@ The application uses Docker Compose for production deployments with an automated - **Migrations**: Separate init container that runs database migrations before the app starts - **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 both images, pushes them to GHCR, and then triggers a +Coolify deployment: + +- `ghcr.io/c4g/template-app:latest` and `:` +- `ghcr.io/c4g/template-migrations:latest` and `:` + +`docker-compose.yml` references those published images 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. + +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 | +| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | secret | Inlined into the app bundle at build time | + +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. + +Because `NEXT_PUBLIC_*` variables are inlined by Next.js at build time, the +VAPID public key must be supplied to the build here; every other variable is +read at runtime and is configured in Coolify. + ### Deployment Commands -Build and start all services: +Pull the published images and start all services: + +```bash +docker compose --profile production up -d +``` + +Build the images 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..c2c9ad5 --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,28 @@ +# Local/CI build overlay. +# +# docker-compose.yml deliberately references prebuilt GHCR images so the +# deployment host never compiles the app. This file adds the `build:` keys back +# so the same images can be built from source: +# +# docker compose -f docker-compose.yml -f docker-compose.build.yml \ +# --profile production up --build -d +# +# The built images are tagged with the `image:` values from the base file, so +# the stack runs exactly as it does in production. +services: + template-migrations: + build: + context: . + dockerfile: Dockerfile.migrations + + template-app: + build: + context: . + dockerfile: Dockerfile + args: + # BETTER_AUTH_URL is read at runtime by better-auth, not inlined at + # build time — it is passed here only to mirror the Dockerfile's ARG. + # NEXT_PUBLIC_VAPID_PUBLIC_KEY *is* inlined by Next.js and must be + # correct at build time. + - NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY} + - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} diff --git a/docker-compose.yml b/docker-compose.yml index 09392a6..6ace422 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,16 @@ +# Production stack — deployed as a single Coolify "Docker Compose" resource. +# +# The two application services reference prebuilt GHCR images and deliberately +# have no `build:` key: that is what stops the shared Coolify host from +# compiling the app on every deploy. The images are produced by +# .github/workflows/publish.yaml on every push to main. +# +# To build the same images locally (or in CI), overlay docker-compose.build.yml: +# docker compose -f docker-compose.yml -f docker-compose.build.yml \ +# --profile production up --build -d +# +# When copying this template into a new project, change `ghcr.io/c4g/template` +# below and IMAGE_BASE in .github/workflows/publish.yaml to the new image name. services: template-db: image: postgres:17 @@ -19,9 +32,7 @@ services: # Database migrations (production only) template-migrations: - build: - context: . - dockerfile: Dockerfile.migrations + image: ghcr.io/c4g/template-migrations:${IMAGE_TAG:-latest} environment: - DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PW}@template-db:5432/${DATABASE_NAME} restart: "no" @@ -33,12 +44,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-app:${IMAGE_TAG:-latest} ports: - "${APP_PORT-3001:}3000" environment: From 4b4872461e8159ab802692352dfbcb9dba96206d Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Fri, 7 Aug 2026 23:06:00 -0500 Subject: [PATCH 2/6] Stop baking environment-specific values into the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image referenced NEXT_PUBLIC_VAPID_PUBLIC_KEY as a build arg, which tied a published build to one environment: standing up a test application later would have needed a second image built with that environment's VAPID key. Next.js substitutes a NEXT_PUBLIC_* variable into the bundle only when it is present at build time. Leaving it unset keeps process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY in the compiled server output as a real runtime lookup, and docker-compose.yml already passes the value at runtime. Verified against the compiled output of src/lib/web-push.ts: unset at build it compiles to `let r=process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY`, set it compiles to the literal. This is safe because the key is read server-side only — the browser fetches it from GET /api/notifications/subscribe rather than reading an inlined copy. The Dockerfile records that constraint. BETTER_AUTH_URL goes too: it was set on the builder stage, which the runner stage does not inherit, so it never reached the running app. One image now backs any number of Coolify applications, each selecting a build via IMAGE_TAG, so adding a test environment later needs no repository changes. The workflow also no longer needs any application secret to build. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yaml | 7 +++---- Dockerfile | 24 ++++++++++++++++------- README.md | 36 ++++++++++++++++++++++++++-------- docker-compose.build.yml | 10 ++-------- docker-compose.yml | 7 +++++++ 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 9490df0..ab78028 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -58,10 +58,9 @@ jobs: context: . file: ./Dockerfile push: true - # Next.js inlines NEXT_PUBLIC_* at build time, so the VAPID public key - # has to be baked in here rather than supplied by Coolify at runtime. - build-args: | - NEXT_PUBLIC_VAPID_PUBLIC_KEY=${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }} + # No build-args: nothing environment-specific may be baked into the + # image, so one build can serve production and any future test app. + # See the comment in Dockerfile. cache-from: type=gha,scope=app cache-to: type=gha,mode=max,scope=app tags: | diff --git a/Dockerfile b/Dockerfile index ba08535..6f58b41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,13 +21,23 @@ 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 — nothing environment-specific is baked in, so one +# published image serves every environment (production, a future test app, a +# preview) and each supplies its own values through Coolify at runtime. +# +# NEXT_PUBLIC_VAPID_PUBLIC_KEY in particular: Next.js only substitutes a +# NEXT_PUBLIC_* variable into the bundle when it is present in the environment +# at build time. Leaving it unset keeps +# `process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY` in the compiled server output as a +# real runtime lookup. That is safe because the value is read server-side only +# (src/lib/web-push.ts) — the browser fetches the key from +# GET /api/notifications/subscribe (see src/hooks/use-push-notifications.ts) +# rather than reading an inlined copy. If client code ever reads a +# NEXT_PUBLIC_* value directly it would be undefined in the browser, and baking +# it back in would re-tie the image to one environment. +# +# BETTER_AUTH_URL is read at runtime by better-auth and was never needed here: +# it was set on the builder stage only, which the runner stage does not inherit. # Copy package files COPY package.json pnpm-lock.yaml ./ diff --git a/README.md b/README.md index 6a9d4f6..283a63b 100644 --- a/README.md +++ b/README.md @@ -136,18 +136,38 @@ Coolify cannot pull `:latest` before the new image has finished uploading. 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 | -| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | secret | Inlined into the app bundle at build time | +| 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. -Because `NEXT_PUBLIC_*` variables are inlined by Next.js at build time, the -VAPID public key must be supplied to the build here; every other variable is -read at runtime and is configured in Coolify. +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 diff --git a/docker-compose.build.yml b/docker-compose.build.yml index c2c9ad5..f73af78 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -8,7 +8,8 @@ # --profile production up --build -d # # The built images are tagged with the `image:` values from the base file, so -# the stack runs exactly as it does in production. +# the stack runs exactly as it does in production. No build args are passed on +# purpose — see the comment in Dockerfile. services: template-migrations: build: @@ -19,10 +20,3 @@ services: build: context: . dockerfile: Dockerfile - args: - # BETTER_AUTH_URL is read at runtime by better-auth, not inlined at - # build time — it is passed here only to mirror the Dockerfile's ARG. - # NEXT_PUBLIC_VAPID_PUBLIC_KEY *is* inlined by Next.js and must be - # correct at build time. - - NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY} - - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} diff --git a/docker-compose.yml b/docker-compose.yml index 6ace422..ac60d46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,13 @@ # compiling the app on every deploy. The images are produced by # .github/workflows/publish.yaml on every push to main. # +# Nothing environment-specific is baked into the images, 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 (set +# IMAGE_TAG in the application's environment variables) to promote a build that +# has already been verified elsewhere. A future test environment is then a +# second Coolify application pointed at this same compose file. +# # To build the same images locally (or in CI), overlay docker-compose.build.yml: # docker compose -f docker-compose.yml -f docker-compose.build.yml \ # --profile production up --build -d From d6c0767e71753e15ed52fb1344c817320b76d2d5 Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Fri, 7 Aug 2026 23:19:53 -0500 Subject: [PATCH 3/6] Run migrations from the application image instead of a second one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile.migrations built a 1.63GB image to carry 94kB of migrations: it ran `pnpm install --prod`, pulling Next, React, ag-grid and every other runtime dependency, in order to run one `prisma migrate deploy`. Publishing it meant a second image to build, push, keep public on GHCR and pull on every deploy. The application image now ships the Prisma CLI, and template-migrations runs that same image with a different command. The ordering guarantee is unchanged — the app still waits on service_completed_successfully. Two details in the migrator stage are load-bearing. It installs with npm rather than pnpm because pnpm's symlink farm does not survive a COPY between stages. And it lands at /node_modules rather than /app/node_modules because the Next.js standalone output contains symlinked packages, so copying a directory over it 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. Verified by building and running the full production stack: all four migrations applied, the container exited 0, the app came up healthy, and GET /api/notifications/subscribe returned the VAPID key supplied as a runtime environment variable to an image built with no build args. Per deploy this goes from ~2GB across two images to 685MB in one, and from two GHCR packages to one. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yaml | 49 +++++++++++++--------------------- Dockerfile | 31 +++++++++++++++++++++ Dockerfile.migrations | 30 --------------------- README.md | 46 +++++++++++++++++++++++++------ docker-compose.build.yml | 19 ++++++------- docker-compose.yml | 22 +++++++++------ 6 files changed, 109 insertions(+), 88 deletions(-) delete mode 100644 Dockerfile.migrations diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index ab78028..24b76c6 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,12 +1,13 @@ -name: Publish Images +name: Publish Image -# Builds the application and migration images and pushes them to GHCR so the -# Coolify host pulls finished images instead of compiling them. Building on the -# shared server ties up CPU and disk for every other application on the box; -# doing it here keeps deploys to a pull-and-restart. +# Builds the application image and pushes it to GHCR so the Coolify host pulls a +# finished image instead of compiling it. Building on the shared server ties up +# CPU and disk for every other application on the box; doing it here keeps +# deploys to a pull-and-restart. # -# docker-compose.yml has no `build:` keys for these services on purpose — see -# the comment at the top of that file. +# One image covers both compose services: it ships the Prisma CLI, so the +# migration service runs it with a different command. docker-compose.yml has no +# `build:` keys on purpose — see the comment at the top of that file. on: push: @@ -19,8 +20,8 @@ concurrency: env: # Must be lowercase — GHCR rejects mixed-case image names, and the org is "C4G". - # Keep in sync with the `image:` values in docker-compose.yml. - IMAGE_BASE: ghcr.io/c4g/template + # Keep in sync with the `image:` value in docker-compose.yml. + IMAGE: ghcr.io/c4g/template jobs: publish: @@ -40,19 +41,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push migrations image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile.migrations - push: true - tags: | - ${{ env.IMAGE_BASE }}-migrations:latest - ${{ env.IMAGE_BASE }}-migrations:${{ github.sha }} - cache-from: type=gha,scope=migrations - cache-to: type=gha,mode=max,scope=migrations - - - name: Build and push app image + - name: Build and push image uses: docker/build-push-action@v6 with: context: . @@ -61,22 +50,20 @@ jobs: # No build-args: nothing environment-specific may be baked into the # image, so one build can serve production and any future test app. # See the comment in Dockerfile. - cache-from: type=gha,scope=app - cache-to: type=gha,mode=max,scope=app + cache-from: type=gha + cache-to: type=gha,mode=max tags: | - ${{ env.IMAGE_BASE }}-app:latest - ${{ env.IMAGE_BASE }}-app:${{ github.sha }} + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ github.sha }} - name: Summary run: | { - echo "### Images published" + echo "### Image published" echo '' echo '```' - echo "${IMAGE_BASE}-app:latest" - echo "${IMAGE_BASE}-app:${GITHUB_SHA}" - echo "${IMAGE_BASE}-migrations:latest" - echo "${IMAGE_BASE}-migrations:${GITHUB_SHA}" + echo "${IMAGE}:latest" + echo "${IMAGE}:${GITHUB_SHA}" echo '```' } >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile index 6f58b41..23863dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,24 @@ RUN pnpm exec prisma generate # Build Next.js application RUN pnpm run build +# Prisma CLI for `prisma migrate deploy`, resolved on its own so the runtime +# image carries the migration tooling without the rest of the dev toolchain. +# +# npm rather than pnpm on purpose: npm produces a flat node_modules of real +# directories that can be COPYed into the standalone output, whereas pnpm's +# symlink farm points into .pnpm/ and does not survive the copy. `npm init -y` +# gives an empty manifest first so npm installs only these two packages instead +# of the application's whole dependency tree; the versions are read from the +# real package.json so they cannot drift from it. +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 @@ -73,6 +91,19 @@ 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. This is what lets the migration service run from this same +# image (see docker-compose.yml) instead of needing a second one. +# +# It goes at the filesystem root rather than into /app/node_modules: the +# standalone output contains symlinked packages, so copying a directory over it +# fails outright ("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. +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 283a63b..6b4fa63 100644 --- a/README.md +++ b/README.md @@ -116,24 +116,54 @@ 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 both images, pushes them to GHCR, and then triggers a -Coolify deployment: +dispatch). It builds one image, pushes it to GHCR, and then triggers a Coolify +deployment: -- `ghcr.io/c4g/template-app:latest` and `:` -- `ghcr.io/c4g/template-migrations:latest` and `:` +- `ghcr.io/c4g/template:latest` and `:` -`docker-compose.yml` references those published images and has **no `build:` +`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 | @@ -171,13 +201,13 @@ environment — serve it from an API route or a server component prop instead. ### Deployment Commands -Pull the published images and start all services: +Pull the published image and start all services: ```bash docker compose --profile production up -d ``` -Build the images from source instead (local verification, and what CI does): +Build the image from source instead (local verification, and what CI does): ```bash docker compose -f docker-compose.yml -f docker-compose.build.yml \ diff --git a/docker-compose.build.yml b/docker-compose.build.yml index f73af78..0145301 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -1,21 +1,18 @@ # Local/CI build overlay. # -# docker-compose.yml deliberately references prebuilt GHCR images so the -# deployment host never compiles the app. This file adds the `build:` keys back -# so the same images can be built from source: +# docker-compose.yml deliberately references the prebuilt GHCR image so the +# deployment host never compiles the app. This file adds the `build:` key back +# so the same image can be built from source: # # docker compose -f docker-compose.yml -f docker-compose.build.yml \ # --profile production up --build -d # -# The built images are tagged with the `image:` values from the base file, so -# the stack runs exactly as it does in production. No build args are passed on -# purpose — see the comment in Dockerfile. +# Only template-app declares a build: the migration service runs the very same +# image with a different command, so building it once covers both. The built +# image is tagged with the `image:` value from the base file, so the stack runs +# exactly as it does in production. No build args are passed on purpose — see +# the comment in Dockerfile. services: - template-migrations: - build: - context: . - dockerfile: Dockerfile.migrations - template-app: build: context: . diff --git a/docker-compose.yml b/docker-compose.yml index ac60d46..42795be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,11 @@ # Production stack — deployed as a single Coolify "Docker Compose" resource. # -# The two application services reference prebuilt GHCR images and deliberately -# have no `build:` key: that is what stops the shared Coolify host from -# compiling the app on every deploy. The images are produced by +# The application and migration services share one prebuilt GHCR image and +# deliberately have no `build:` key: that is what stops the shared Coolify host +# from compiling the app on every deploy. The image is produced by # .github/workflows/publish.yaml on every push to main. # -# Nothing environment-specific is baked into the images, so the same build can +# 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 (set # IMAGE_TAG in the application's environment variables) to promote a build that @@ -17,7 +17,7 @@ # --profile production up --build -d # # When copying this template into a new project, change `ghcr.io/c4g/template` -# below and IMAGE_BASE in .github/workflows/publish.yaml to the new image name. +# below and IMAGE in .github/workflows/publish.yaml to the new image name. services: template-db: image: postgres:17 @@ -37,9 +37,15 @@ services: start_period: 30s start_interval: 2s - # Database migrations (production only) + # Database migrations (production only). + # + # Runs from the SAME image as the application, with a different command — the + # image ships the Prisma CLI for exactly this (see Dockerfile). A dedicated + # migration image would be a second thing to build, publish, keep public and + # pull, for one command. template-migrations: - image: ghcr.io/c4g/template-migrations:${IMAGE_TAG:-latest} + 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" @@ -51,7 +57,7 @@ services: # Next.js application (production only) template-app: - image: ghcr.io/c4g/template-app:${IMAGE_TAG:-latest} + image: ghcr.io/c4g/template:${IMAGE_TAG:-latest} ports: - "${APP_PORT-3001:}3000" environment: From 906c1afda08ecd4dd77272f51188f4f3bf10bcd6 Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Fri, 7 Aug 2026 23:25:52 -0500 Subject: [PATCH 4/6] Condense the comments added with the publish pipeline Cut the explanatory blocks down to the load-bearing line or two. The reasoning they carried lives in README.md and the PR discussion. --- .github/workflows/ci.yaml | 4 +--- .github/workflows/publish.yaml | 22 +++++------------- Dockerfile | 41 +++++----------------------------- docker-compose.build.yml | 16 ++----------- docker-compose.yml | 30 ++++--------------------- 5 files changed, 18 insertions(+), 95 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d1eb448..5224ed0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,9 +52,7 @@ jobs: NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }} VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }} run: | - # docker-compose.build.yml restores the `build:` keys that the - # production file omits, so the PR is verified against an image built - # from this branch rather than the published :latest one. + # 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 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 24b76c6..7f53581 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,13 +1,7 @@ name: Publish Image -# Builds the application image and pushes it to GHCR so the Coolify host pulls a -# finished image instead of compiling it. Building on the shared server ties up -# CPU and disk for every other application on the box; doing it here keeps -# deploys to a pull-and-restart. -# -# One image covers both compose services: it ships the Prisma CLI, so the -# migration service runs it with a different command. docker-compose.yml has no -# `build:` keys on purpose — see the comment at the top of that file. +# 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: @@ -47,9 +41,7 @@ jobs: context: . file: ./Dockerfile push: true - # No build-args: nothing environment-specific may be baked into the - # image, so one build can serve production and any future test app. - # See the comment in Dockerfile. + # No build-args, so one image works in every environment. See Dockerfile. cache-from: type=gha cache-to: type=gha,mode=max tags: | @@ -67,12 +59,8 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - # Deploying from here (rather than letting Coolify redeploy on git push) - # avoids a race where Coolify pulls `:latest` before this workflow has - # finished pushing it. Skipped automatically until both values exist, so a - # copy of this template does not redeploy the template's own application. - # The secret is surfaced through `env` because the `secrets` context is - # not available in a step-level `if:`. + # 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 }} diff --git a/Dockerfile b/Dockerfile index 23863dc..06bf5f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,23 +21,8 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ FROM base AS builder WORKDIR /app -# No build args on purpose — nothing environment-specific is baked in, so one -# published image serves every environment (production, a future test app, a -# preview) and each supplies its own values through Coolify at runtime. -# -# NEXT_PUBLIC_VAPID_PUBLIC_KEY in particular: Next.js only substitutes a -# NEXT_PUBLIC_* variable into the bundle when it is present in the environment -# at build time. Leaving it unset keeps -# `process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY` in the compiled server output as a -# real runtime lookup. That is safe because the value is read server-side only -# (src/lib/web-push.ts) — the browser fetches the key from -# GET /api/notifications/subscribe (see src/hooks/use-push-notifications.ts) -# rather than reading an inlined copy. If client code ever reads a -# NEXT_PUBLIC_* value directly it would be undefined in the browser, and baking -# it back in would re-tie the image to one environment. -# -# BETTER_AUTH_URL is read at runtime by better-auth and was never needed here: -# it was set on the builder stage only, which the runner stage does not inherit. +# 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 ./ @@ -56,15 +41,8 @@ RUN pnpm exec prisma generate # Build Next.js application RUN pnpm run build -# Prisma CLI for `prisma migrate deploy`, resolved on its own so the runtime -# image carries the migration tooling without the rest of the dev toolchain. -# -# npm rather than pnpm on purpose: npm produces a flat node_modules of real -# directories that can be COPYed into the standalone output, whereas pnpm's -# symlink farm points into .pnpm/ and does not survive the copy. `npm init -y` -# gives an empty manifest first so npm installs only these two packages instead -# of the application's whole dependency tree; the versions are read from the -# real package.json so they cannot drift from it. +# 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 ./ @@ -91,15 +69,8 @@ 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. This is what lets the migration service run from this same -# image (see docker-compose.yml) instead of needing a second one. -# -# It goes at the filesystem root rather than into /app/node_modules: the -# standalone output contains symlinked packages, so copying a directory over it -# fails outright ("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. +# 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 diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 0145301..9083133 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -1,17 +1,5 @@ -# Local/CI build overlay. -# -# docker-compose.yml deliberately references the prebuilt GHCR image so the -# deployment host never compiles the app. This file adds the `build:` key back -# so the same image can be built from source: -# -# docker compose -f docker-compose.yml -f docker-compose.build.yml \ -# --profile production up --build -d -# -# Only template-app declares a build: the migration service runs the very same -# image with a different command, so building it once covers both. The built -# image is tagged with the `image:` value from the base file, so the stack runs -# exactly as it does in production. No build args are passed on purpose — see -# the comment in Dockerfile. +# 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: diff --git a/docker-compose.yml b/docker-compose.yml index 42795be..1261251 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,5 @@ -# Production stack — deployed as a single Coolify "Docker Compose" resource. -# -# The application and migration services share one prebuilt GHCR image and -# deliberately have no `build:` key: that is what stops the shared Coolify host -# from compiling the app on every deploy. The image is produced by -# .github/workflows/publish.yaml on every push to main. -# -# 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 (set -# IMAGE_TAG in the application's environment variables) to promote a build that -# has already been verified elsewhere. A future test environment is then a -# second Coolify application pointed at this same compose file. -# -# To build the same images locally (or in CI), overlay docker-compose.build.yml: -# docker compose -f docker-compose.yml -f docker-compose.build.yml \ -# --profile production up --build -d -# -# When copying this template into a new project, change `ghcr.io/c4g/template` -# below and IMAGE in .github/workflows/publish.yaml to the new image name. +# 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 @@ -37,12 +19,8 @@ services: start_period: 30s start_interval: 2s - # Database migrations (production only). - # - # Runs from the SAME image as the application, with a different command — the - # image ships the Prisma CLI for exactly this (see Dockerfile). A dedicated - # migration image would be a second thing to build, publish, keep public and - # pull, for one command. + # Database migrations (production only). Same image as the app, different + # command — the image ships the Prisma CLI for exactly this. template-migrations: image: ghcr.io/c4g/template:${IMAGE_TAG:-latest} command: ["node", "/node_modules/prisma/build/index.js", "migrate", "deploy"] From 14eadf9e988abc26fa728e2b54d87117cb8f0a0a Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Fri, 7 Aug 2026 23:26:01 -0500 Subject: [PATCH 5/6] Mock the auth client for every test, not just three files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed with an unhandled `ReferenceError: window is not defined` from better-auth's cleanupBroadcastSetup while all 45 tests passed. Every render goes through ImpersonationProvider, which calls useSession; src/test/mocks.tsx already mocks @/lib/auth-client, but only the three test files that import it. The rest loaded the real client, which leaves a nanostores timer that fires after vitest tears the jsdom environment down — hence the error surfacing from EmailDialog.test.tsx, one of the files without the mock. Moving the mock into the global setup removes the real client, and its timer, from every test file. Tests that assert on session values still override it through mocks.tsx. This is pre-existing and timing-dependent, not caused by the publish pipeline: the same branch passed twice before failing on the third run. Kept as its own commit so it can be split out. Co-Authored-By: Claude Opus 5 (1M context) --- src/test/setup.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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, From 304baae744a150b07b033b9f5520b4c0144f2c4c Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Sat, 8 Aug 2026 00:17:10 -0500 Subject: [PATCH 6/6] Smoke test the published image before deploying CI builds the PR head; with squash merges the commit that lands on main is never exercised before its image is deployed. Run the production compose against the just-pushed tag with no build overlay, so the step also proves the image pulls and runs without anything being built. Asserts the migration container exited 0 and the app reached healthy. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yaml | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 7f53581..b8e8fa5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -48,6 +48,55 @@ jobs: ${{ 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: | {