Build and publish container images on push to main - #8
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Follow-up: one image, many environmentsPushed 4b48724 so a test environment can be added later without repository changes. As originally written this PR baked The Dockerfile now passes no build args at all. Next.js substitutes a Verified against the compiled output of
This is safe because the key is read server-side only (
Consequences
|
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) <noreply@anthropic.com>
Follow-up: one image for both servicesPushed d6c0767. Why
Aftertemplate-migrations:
image: ghcr.io/c4g/template:${IMAGE_TAG:-latest}
command: ['node', '/node_modules/prisma/build/index.js', 'migrate', 'deploy']Per deploy: ~2 GB across two images → 685 MB in one, and two GHCR packages → one. The ordering guarantee is unchanged; the app still waits on Two load-bearing detailsBoth were found by building, not by reasoning:
Verified locally against the real stack
One thing I could not improve252 MB of the image is the Prisma CLI tree, mostly |
Cut the explanatory blocks down to the load-bearing line or two. The reasoning they carried lives in README.md and the PR discussion.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Summary
Moves the production image build off the shared Coolify host and into GitHub Actions, mirroring the setup already running for
C4G/metro-atlanta-saves..github/workflows/publish.yaml(new) — on push tomain(andworkflow_dispatch): buildsDockerfileandDockerfile.migrations, pushes:latest+:<sha>tags toghcr.io/c4g/template-appandghcr.io/c4g/template-migrations, then calls the Coolify deploy API.docker-compose.yml— the two application services now reference the published images and have nobuild:key. That absence is what stops Coolify from compiling on the server; it only pulls and restarts.docker-compose.build.yml(new) — restores thebuild:keys for local builds..github/workflows/ci.yaml— PR runs overlay the build file, so they still verify an image built from the branch rather than the published:latest.README.md— documents the publish flow, the required secrets/variables, and what to change when copying the template.Notes
:latestbefore the push has finished. Auto-deploy is already disabled on the Coolify app (is_auto_deploy_enabled = false), same as metro-atlanta-saves.secrets.COOLIFY_TOKEN(org secret) andvars.COOLIFY_APP_UUID, so a copy of this template publishes images without redeploying the template's own application. The variable has been set on this repo toyg8wo0wc04gww0sog80gwww0(confirmed via the Coolify API as thetemplateapp onC4G/template@main).NEXT_PUBLIC_VAPID_PUBLIC_KEYis passed as a build arg because Next.js inlinesNEXT_PUBLIC_*at build time.BETTER_AUTH_URLis read at runtime by better-auth and stays a Coolify environment variable.Follow-up required after the first run
The two new GHCR packages are created private. The Coolify host pulls anonymously (no
~/.docker/config.jsonon the server), so both packages must be set to public — matching the existingghcr.io/c4g/metro-atlanta-saves-*andghcr.io/c4g/va-datpackages — or the first deploy will fail to pull.🤖 Generated with Claude Code