Skip to content

Build and publish container images on push to main - #8

Merged
theNEXlevel merged 6 commits into
mainfrom
ci/publish-container
Aug 8, 2026
Merged

Build and publish container images on push to main#8
theNEXlevel merged 6 commits into
mainfrom
ci/publish-container

Conversation

@theNEXlevel

Copy link
Copy Markdown
Contributor

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 to main (and workflow_dispatch): builds Dockerfile and Dockerfile.migrations, pushes :latest + :<sha> tags to ghcr.io/c4g/template-app and ghcr.io/c4g/template-migrations, then calls the Coolify deploy API.
  • docker-compose.yml — the two application services now reference the published images and have no build: key. That absence is what stops Coolify from compiling on the server; it only pulls and restarts.
  • docker-compose.build.yml (new) — restores the build: 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

  • Deploy is triggered from the workflow rather than by Coolify's git webhook, so Coolify cannot pull :latest before the push has finished. Auto-deploy is already disabled on the Coolify app (is_auto_deploy_enabled = false), same as metro-atlanta-saves.
  • The deploy step is guarded on both secrets.COOLIFY_TOKEN (org secret) and vars.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 to yg8wo0wc04gww0sog80gwww0 (confirmed via the Coolify API as the template app on C4G/template@main).
  • NEXT_PUBLIC_VAPID_PUBLIC_KEY is passed as a build arg because Next.js inlines NEXT_PUBLIC_* at build time. BETTER_AUTH_URL is 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.json on the server), so both packages must be set to public — matching the existing ghcr.io/c4g/metro-atlanta-saves-* and ghcr.io/c4g/va-dat packages — or the first deploy will fail to pull.

🤖 Generated with Claude Code

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>
@theNEXlevel

Copy link
Copy Markdown
Contributor Author

Follow-up: one image, many environments

Pushed 4b48724 so a test environment can be added later without repository changes.

As originally written this PR baked NEXT_PUBLIC_VAPID_PUBLIC_KEY into the image as a build arg, which tied a published build to one environment — standing up a test app later would have needed a second image built with that environment's VAPID key.

The Dockerfile now passes no build args at all. Next.js substitutes a NEXT_PUBLIC_* variable into the bundle only when it is present at build time, so leaving it unset keeps the expression 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 (Next 16.1.6 / Turbopack), same module in both builds:

Build Compiled form
var unset let r=process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY — runtime lookup
var set let r="BTestKeyAAA111" — baked literal

This is safe because the key is read server-side only (src/lib/web-push.ts); the browser fetches it from GET /api/notifications/subscribe (src/hooks/use-push-notifications.ts:93) rather than reading an inlined copy. The Dockerfile records that constraint, since baking a value back in to serve client code would re-tie the image to a single environment.

BETTER_AUTH_URL is dropped too — it was set on the builder stage, which the runner stage does not inherit, so it never reached the running app.

Consequences

  • Adding a test environment is now just a second Coolify application pointed at this same compose file. IMAGE_TAG selects the build: unset tracks latest, or pin a commit SHA to promote something already verified.
  • The publish workflow needs no application secrets to build. NEXT_PUBLIC_VAPID_PUBLIC_KEY is no longer required as a GitHub secret; it stays a Coolify runtime variable. The required-configuration table in the README is updated accordingly.

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>
@theNEXlevel

Copy link
Copy Markdown
Contributor Author

Follow-up: one image for both services

Pushed d6c0767. Dockerfile.migrations is deleted; template-migrations now runs the same image as the app with a different command.

Why

Image Size
template-app 358 MB
template-migrations 1.63 GB
...migrations actually carried 94 kB

Dockerfile.migrations ran pnpm install --prod — pulling Next, React, ag-grid, better-auth and everything else — then added the Prisma CLI, in one un-pruned 1.11 GB layer, to run a single prisma migrate deploy.

After

template-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 service_completed_successfully. The image is renamed ghcr.io/c4g/template (dropping the now-meaningless -app suffix) — nothing is published yet, so this costs nothing.

Two load-bearing details

Both were found by building, not by reasoning:

  1. The migrator stage installs with npm, not pnpm — pnpm's symlink farm points into .pnpm/ and does not survive a COPY between stages.
  2. It lands at /node_modules, not /app/node_modules. The first attempt merged into the standalone tree and failed outright:
    cannot copy to non-directory: .../app/node_modules/react
    
    Next's standalone output contains symlinked packages. /node_modules is the last place Node looks when resolving from /app, so prisma.config.ts still finds dotenv and prisma/config while the app's own resolution is untouched.

Verified locally against the real stack

  • All 4 migrations applied, migration container exited 0, app came up healthy — so prisma.config.ts resolved correctly (the prisma/migrations path comes from that file).
  • GET /api/health200.
  • GET /api/notifications/subscribe{"publicKey":"BTestKey"} — the value passed as a runtime env var to an image built with no build args. That is the "one image, many environments" change from 4b48724 confirmed end-to-end in a real container, not just in a bundle grep.

One thing I could not improve

252 MB of the image is the Prisma CLI tree, mostly @prisma/studio-core and its UI stack (effect, @electric-sql, react-dom, elkjs). I tested removing it — the CLI eagerly requires effect at startup and fails with Cannot find module 'effect', so it is not safely prunable. Left alone rather than shipping fragile deletions into a template; worth revisiting if Prisma ever splits the CLI.

theNEXlevel and others added 2 commits August 7, 2026 23:25
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>
@theNEXlevel
theNEXlevel merged commit ea547c7 into main Aug 8, 2026
1 check passed
@theNEXlevel
theNEXlevel deleted the ci/publish-container branch August 8, 2026 05:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant