From 9be4a71d3267d3491556a0d62aa6fd12c05120c6 Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Fri, 7 Aug 2026 22:37:16 -0500 Subject: [PATCH 1/4] Build and publish the container image 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 image, pushes it to GHCR, and then calls the Coolify deploy API for va-stats-test. docker-compose.yml now references the published image and has no `build:` key — that absence is what keeps the build off the server. The build key moves to docker-compose.build.yml, which CI overlays so PR runs still verify an image built from the branch rather than the published :latest. One image has to serve both va-stats-test and production, which have different origins, so the Dockerfile stops baking NEXT_PUBLIC_BASE_URL in. Next.js only substitutes NEXT_PUBLIC_* variables that are present at build time; leaving it unset keeps process.env.NEXT_PUBLIC_BASE_URL in the compiled server output as a real runtime lookup, and compose already passes the value at runtime. This is safe only because the variable is read server-side (utils/auditLogger.js, used solely by pages/api/*), which the Dockerfile comment records. Production is promoted by pinning IMAGE_TAG to a tested commit SHA rather than tracking :latest, so it runs the exact image verified on va-stats-test. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 5 +- .github/workflows/publish.yaml | 89 ++++++++++++++++++++++++++++++++++ Dockerfile | 17 +++++-- README.md | 51 +++++++++++++++++-- docker-compose.build.yml | 16 ++++++ docker-compose.yml | 27 ++++++++--- 6 files changed, 188 insertions(+), 17 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 8e665c67..de4a96b1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -56,7 +56,10 @@ jobs: MYSQL_USER: ci MYSQL_PASSWORD: ci run: | - docker compose -f ./docker-compose.yml -p "va-stats-pr-${PR_NUMBER}" up --build -d + # docker-compose.build.yml restores the `build:` key 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 -p "va-stats-pr-${PR_NUMBER}" up --build -d echo "Waiting for the app to become healthy..." for i in {1..60}; do diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..8012ab10 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,89 @@ +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 reduces a +# deploy to a pull-and-restart. +# +# docker-compose.yml has no `build:` key on purpose — see the comment at the top +# of that file. +# +# On push to main this deploys va-stats-test only. Production is promoted +# manually: set IMAGE_TAG to the commit SHA published here on the `va-stats` +# Coolify application and redeploy it. + +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/va-stats + +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 }} + + # No build-args: NEXT_PUBLIC_BASE_URL must stay unset at build time so the + # same image works against both origins. See Dockerfile. + - name: Build and push app image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Summary + run: | + { + echo "### Image published" + echo '' + echo '```' + echo "${IMAGE}:latest" + echo "${IMAGE}:${GITHUB_SHA}" + echo '```' + echo '' + echo "To promote this build to production, set \`IMAGE_TAG=${GITHUB_SHA}\`" + echo "on the \`va-stats\` Coolify application and redeploy it." + } >> "$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. Coolify's own auto-deploy is switched off on + # va-stats-test for that reason. Skipped automatically until both values + # exist. The secret is surfaced through `env` because the `secrets` context + # is not available in a step-level `if:`. + - name: Trigger Coolify deployment (va-stats-test) + 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 ef1da776..321adedf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,18 @@ FROM base AS builder RUN apk add --no-cache libc6-compat WORKDIR /app -# Declare build arguments for Next.js public variables (inlined at build time) -ARG NEXT_PUBLIC_BASE_URL - -# Set environment variables from build args (baked into the client bundle) -ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL +# NEXT_PUBLIC_BASE_URL is deliberately NOT set here. +# +# 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_BASE_URL` in the compiled server output as a real +# runtime lookup, so one published image picks up each environment's own origin +# from Coolify — va-stats-test and production have different ones. +# +# This holds because the value is read on the server only (utils/auditLogger.js, +# imported solely by pages/api/*). If it is ever read from client code it will be +# undefined in the browser, and reintroducing the build arg to fix that would +# re-break the shared image. # Copy package files COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ diff --git a/README.md b/README.md index 056e0fe0..523048d5 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,56 @@ To learn more about Next.js, take a look at the following resources: You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! -## Deploy on Vercel +## Deployment (Coolify + GHCR) -This app is deployed on [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +`.github/workflows/publish.yaml` runs on every push to `main` (and on manual +dispatch). It builds the image, pushes `ghcr.io/c4g/va-stats:latest` and +`:`, then triggers a Coolify deployment of **va-stats-test**. -Check out [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +`docker-compose.yml` references that published image and has **no `build:` +key**, which is what keeps the shared Coolify host from compiling the +application on every deploy — it only pulls and restarts. Coolify's own git +auto-deploy is switched off so the workflow is the single trigger, which +prevents Coolify from pulling `:latest` before the push has finished. + +### Environments + +| Environment | Coolify app | Image tag | +| ----------------------- | --------------- | ----------------------------------------- | +| `va-stats-test.c4g.dev` | `va-stats-test` | `latest` (deployed automatically on main) | +| `va-stats.c4g.dev` | `va-stats` | `IMAGE_TAG` pinned to a commit SHA | + +**Promoting to production** is manual and explicit: set `IMAGE_TAG` to the +commit SHA of a build already verified on va-stats-test in the `va-stats` +application's Coolify environment variables, then redeploy it. Production then +runs the exact image that was tested — no rebuild. + +### One image, two origins + +`NEXT_PUBLIC_BASE_URL` differs per environment, and Next.js normally inlines +`NEXT_PUBLIC_*` variables at build time, which would tie an image to a single +origin. The Dockerfile deliberately leaves the variable unset during the build: +Next only substitutes `NEXT_PUBLIC_*` values that exist at build time, so +`process.env.NEXT_PUBLIC_BASE_URL` survives in the compiled server output as a +real runtime lookup and each environment supplies its own value through Coolify. + +This works only because the value is read server-side (`utils/auditLogger.js`, +imported solely by `pages/api/*`). Reading it from client code would yield +`undefined` in the browser — see the comment in the Dockerfile before changing +this. + +### Required repository/organization configuration + +| Name | Kind | Purpose | +| ------------------ | -------- | -------------------------------------- | +| `COOLIFY_TOKEN` | secret | Coolify API token (organization-level) | +| `COOLIFY_APP_UUID` | variable | UUID of the va-stats-test Coolify app | + +### Building locally + +```bash +docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d +``` ## Code modifications required when changing hosting provider diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 00000000..643ee463 --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,16 @@ +# 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 up --build -d +# +# 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 NEXT_PUBLIC_BASE_URL comment in Dockerfile. +services: + va-stats-app: + build: + context: . + dockerfile: Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index a1a1ba79..41fbd9ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,23 @@ +# Production stack — deployed as a Coolify "Docker Compose" resource by both +# va-stats-test and va-stats (production). +# +# The application service references a prebuilt GHCR image and deliberately has +# 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. +# +# One image serves both environments. `IMAGE_TAG` selects which: va-stats-test +# tracks `latest`, while production pins a specific commit SHA (set IMAGE_TAG in +# the Coolify application's environment variables) so promoting to production is +# an explicit choice of an already-tested image. +# +# To build the image locally (or in CI), overlay docker-compose.build.yml: +# docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d services: # Next.js application. The database is an external MySQL instance, so it is # NOT managed here — connection details are provided via environment variables. va-stats-app: - build: - context: . - dockerfile: Dockerfile - args: - # NEXT_PUBLIC_* vars are inlined into the bundle at build time - - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-} + image: ghcr.io/c4g/va-stats:${IMAGE_TAG:-latest} expose: - "3000" environment: @@ -23,8 +33,9 @@ services: - JWT_SECRET=${JWT_SECRET} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - # Public config (also passed as a build arg above, where it is inlined; - # kept here for the server-side runtime fallback) + # Public config. Read at runtime, NOT baked into the image — see the + # comment in Dockerfile. This is what lets one image serve both + # va-stats-test and production, which have different origins. - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-} command: node server.js healthcheck: From 658672f7de543cf3bb731c558bf3269f081fbba6 Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Fri, 7 Aug 2026 23:26:10 -0500 Subject: [PATCH 2/4] 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 | 24 +++++------------------- Dockerfile | 14 ++------------ docker-compose.build.yml | 11 +---------- docker-compose.yml | 21 +++------------------ 5 files changed, 12 insertions(+), 62 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index de4a96b1..9937034e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -56,9 +56,7 @@ jobs: MYSQL_USER: ci MYSQL_PASSWORD: ci run: | - # docker-compose.build.yml restores the `build:` key 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 -p "va-stats-pr-${PR_NUMBER}" up --build -d echo "Waiting for the app to become healthy..." diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 8012ab10..aaff1783 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,16 +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 reduces a -# deploy to a pull-and-restart. -# -# docker-compose.yml has no `build:` key on purpose — see the comment at the top -# of that file. -# -# On push to main this deploys va-stats-test only. Production is promoted -# manually: set IMAGE_TAG to the commit SHA published here on the `va-stats` -# Coolify application and redeploy it. +# Builds the image in CI so the shared Coolify host only pulls and restarts. +# Push to main deploys va-stats-test; production is promoted by pinning IMAGE_TAG. on: push: @@ -44,8 +35,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # No build-args: NEXT_PUBLIC_BASE_URL must stay unset at build time so the - # same image works against both origins. See Dockerfile. + # No build-args, so one image works against both origins. See Dockerfile. - name: Build and push app image uses: docker/build-push-action@v6 with: @@ -72,12 +62,8 @@ jobs: echo "on the \`va-stats\` Coolify application and redeploy it." } >> "$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. Coolify's own auto-deploy is switched off on - # va-stats-test for that reason. Skipped automatically until both values - # exist. 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 (switched off), so it cannot + # pull `:latest` mid-push. Secrets need `env` because `if:` cannot see them. - name: Trigger Coolify deployment (va-stats-test) env: COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} diff --git a/Dockerfile b/Dockerfile index 321adedf..c4e0ce7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,18 +9,8 @@ FROM base AS builder RUN apk add --no-cache libc6-compat WORKDIR /app -# NEXT_PUBLIC_BASE_URL is deliberately NOT set here. -# -# 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_BASE_URL` in the compiled server output as a real -# runtime lookup, so one published image picks up each environment's own origin -# from Coolify — va-stats-test and production have different ones. -# -# This holds because the value is read on the server only (utils/auditLogger.js, -# imported solely by pages/api/*). If it is ever read from client code it will be -# undefined in the browser, and reintroducing the build arg to fix that would -# re-break the shared image. +# NEXT_PUBLIC_BASE_URL deliberately unset: Next.js only inlines NEXT_PUBLIC_* vars +# that exist at build time, so one image works for both test and production. # Copy package files COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 643ee463..28df427a 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -1,14 +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: -# +# Adds the `build:` key that docker-compose.yml omits, for building locally: # docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d -# -# 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 NEXT_PUBLIC_BASE_URL comment in Dockerfile. services: va-stats-app: build: diff --git a/docker-compose.yml b/docker-compose.yml index 41fbd9ea..c64335cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,5 @@ -# Production stack — deployed as a Coolify "Docker Compose" resource by both -# va-stats-test and va-stats (production). -# -# The application service references a prebuilt GHCR image and deliberately has -# 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. -# -# One image serves both environments. `IMAGE_TAG` selects which: va-stats-test -# tracks `latest`, while production pins a specific commit SHA (set IMAGE_TAG in -# the Coolify application's environment variables) so promoting to production is -# an explicit choice of an already-tested image. -# -# To build the image locally (or in CI), overlay docker-compose.build.yml: -# docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d +# Production stack for both va-stats-test and va-stats. No `build:` key on purpose +# — publish.yaml builds the image; IMAGE_TAG picks which build each app runs. services: # Next.js application. The database is an external MySQL instance, so it is # NOT managed here — connection details are provided via environment variables. @@ -33,9 +20,7 @@ services: - JWT_SECRET=${JWT_SECRET} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - # Public config. Read at runtime, NOT baked into the image — see the - # comment in Dockerfile. This is what lets one image serve both - # va-stats-test and production, which have different origins. + # Read at runtime, not baked in — see Dockerfile. - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-} command: node server.js healthcheck: From be50e8cfca43e0e4a543b103f6d4eb74c72c89a5 Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Sat, 8 Aug 2026 00:17:22 -0500 Subject: [PATCH 3/4] 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. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yaml | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index aaff1783..f588a4c7 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -48,6 +48,46 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + # 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 }} + # External database; /api/health is DB-independent, so this checks the + # published image boots and serves, not that it can reach MySQL. + MYSQL_HOST: localhost + MYSQL_DATABASE: smoke + MYSQL_USER: smoke + MYSQL_PASSWORD: smoke + NEXTAUTH_SECRET: smoke-only-not-a-real-secret + JWT_SECRET: smoke-only-not-a-real-secret + GOOGLE_CLIENT_ID: '' + GOOGLE_CLIENT_SECRET: '' + run: | + docker compose -f docker-compose.yml -p smoke up -d --pull always + + app=$(docker compose -p smoke ps -q --all va-stats-app) + + 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 + + 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 served" + + - name: Stop smoke test stack + if: always() + run: docker compose -f docker-compose.yml -p smoke down --volumes --remove-orphans || true + - name: Summary run: | { From e32cb63ab867c0bc41d95fd1f8bbe7492f7402ed Mon Sep 17 00:00:00 2001 From: Justin McLellan Date: Sat, 8 Aug 2026 00:21:11 -0500 Subject: [PATCH 4/4] Format publish.yaml to prettier's quote style The repo's format check covers .github; lint-staged does not, so the workflow file slipped through the commit hook. --- .github/workflows/publish.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index f588a4c7..ee52b846 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -61,8 +61,8 @@ jobs: MYSQL_PASSWORD: smoke NEXTAUTH_SECRET: smoke-only-not-a-real-secret JWT_SECRET: smoke-only-not-a-real-secret - GOOGLE_CLIENT_ID: '' - GOOGLE_CLIENT_SECRET: '' + GOOGLE_CLIENT_ID: "" + GOOGLE_CLIENT_SECRET: "" run: | docker compose -f docker-compose.yml -p smoke up -d --pull always