Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ jobs:
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }}
VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }}
run: |
docker compose -f ./docker-compose.yml --profile production -p "template-pr-${PR_NUMBER}" up --build -d
# Build overlay, so the PR runs an image built from this branch.
docker compose -f ./docker-compose.yml -f ./docker-compose.build.yml --profile production -p "template-pr-${PR_NUMBER}" up --build -d

# Wait for services to be healthy
echo "Waiting for services to start..."
Expand Down
121 changes: 121 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: Publish Image

# Builds the image in CI so the shared Coolify host only pulls and restarts.
# One image covers both compose services; the migration one just runs a different command.

on:
push:
branches: [main]
workflow_dispatch:

concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: true

env:
# Must be lowercase — GHCR rejects mixed-case image names, and the org is "C4G".
# Keep in sync with the `image:` value in docker-compose.yml.
IMAGE: ghcr.io/c4g/template

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
# No build-args, so one image works in every environment. See Dockerfile.
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}

# CI only ever builds the PR head; the squashed commit on main is not
# exercised until here. Runs the production compose with no build overlay.
- name: Smoke test the published image
env:
IMAGE_TAG: ${{ github.sha }}
DATABASE_PW: smoke
DATABASE_USER: smoke
DATABASE_NAME: smoke
DATABASE_PORT: ''
APP_PORT: ''
AUTH_SECRET: smoke-only-not-a-real-secret-value
AUTH_GOOGLE_ID: ''
AUTH_GOOGLE_SECRET: ''
RESEND_API_KEY: ''
NEXT_PUBLIC_VAPID_PUBLIC_KEY: smoke-public-key
VAPID_PRIVATE_KEY: smoke-private-key
run: |
docker compose -f docker-compose.yml --profile production -p smoke up -d --pull always

app=$(docker compose -p smoke ps -q --all template-app)
mig=$(docker compose -p smoke ps -q --all template-migrations)

for i in $(seq 1 60); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$app")
echo "attempt $i: $status"
case "$status" in healthy|unhealthy) break ;; esac
sleep 2
done

code=$(docker inspect -f '{{.State.ExitCode}}' "$mig")
if [ "$code" != "0" ]; then
echo "::error::migrations exited $code in the published image"
docker compose -p smoke logs
exit 1
fi

if [ "$(docker inspect -f '{{.State.Health.Status}}' "$app")" != "healthy" ]; then
echo "::error::published image never became healthy"
docker compose -p smoke logs
exit 1
fi

docker exec "$app" wget -qO- http://localhost:3000/api/health
echo "OK — pulled image migrated and served"

- name: Stop smoke test stack
if: always()
run: docker compose -f docker-compose.yml --profile production -p smoke down --volumes --remove-orphans || true

- name: Summary
run: |
{
echo "### Image published"
echo ''
echo '```'
echo "${IMAGE}:latest"
echo "${IMAGE}:${GITHUB_SHA}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

# Deploy from here, not Coolify's git webhook, so it cannot pull `:latest`
# mid-push. Skipped until both values exist (secrets need `env` for `if:`).
- name: Trigger Coolify deployment
env:
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }}
if: ${{ env.COOLIFY_TOKEN != '' && env.COOLIFY_APP_UUID != '' }}
run: |
curl -fsS -X GET \
"https://coolify.c4g.dev/api/v1/deploy?uuid=${COOLIFY_APP_UUID}" \
-H "Authorization: Bearer ${COOLIFY_TOKEN}"
26 changes: 19 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,8 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
FROM base AS builder
WORKDIR /app

# Declare build arguments for Next.js public variables
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
ARG BETTER_AUTH_URL

# Set environment variables from build args
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=$NEXT_PUBLIC_VAPID_PUBLIC_KEY
ENV BETTER_AUTH_URL=$BETTER_AUTH_URL
# No build args on purpose: Next.js only inlines NEXT_PUBLIC_* vars that exist
# at build time, so leaving them unset keeps one image usable in every environment.

# Copy package files
COPY package.json pnpm-lock.yaml ./
Expand All @@ -46,6 +41,17 @@ RUN pnpm exec prisma generate
# Build Next.js application
RUN pnpm run build

# Prisma CLI for `migrate deploy`. npm, not pnpm: pnpm's symlink farm does not
# survive a COPY between stages. `npm init -y` keeps it to just these packages.
FROM base AS migrator
WORKDIR /src
COPY package.json ./
WORKDIR /migrator
RUN npm init -y > /dev/null && \
npm install --no-audit --no-fund \
"prisma@$(node -p "require('/src/package.json').devDependencies.prisma")" \
"dotenv@$(node -p "require('/src/package.json').dependencies.dotenv")"

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
Expand All @@ -63,6 +69,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

# Migration tooling, so the migration service can run this same image. At the
# root, not /app/node_modules, which has symlinks a directory COPY cannot cross.
COPY --from=migrator --chown=nextjs:nodejs /migrator/node_modules /node_modules
COPY --chown=nextjs:nodejs prisma ./prisma
COPY --chown=nextjs:nodejs prisma.config.ts ./prisma.config.ts

USER nextjs

EXPOSE 3000
Expand Down
30 changes: 0 additions & 30 deletions Dockerfile.migrations

This file was deleted.

98 changes: 94 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down Expand Up @@ -113,15 +116,102 @@ The application uses Docker Compose for production deployments with an automated
### Architecture

- **Database**: PostgreSQL 17 with persistent volume storage
- **Migrations**: Separate init container that runs database migrations before the app starts
- **Migrations**: Init container that runs database migrations before the app
starts, from the same image as the app
- **Application**: Next.js standalone server with optimized production build

### Image Publishing (CD)

`.github/workflows/publish.yaml` runs on every push to `main` (and on manual
dispatch). It builds one image, pushes it to GHCR, and then triggers a Coolify
deployment:

- `ghcr.io/c4g/template:latest` and `:<commit-sha>`

`docker-compose.yml` references that published image and has **no `build:`
keys**, which is what keeps the shared Coolify host from compiling the
application on every deploy — it only pulls and restarts. The deploy is
triggered from the workflow rather than by Coolify's git webhook so that
Coolify cannot pull `:latest` before the new image has finished uploading.

### One image, both services

`template-migrations` and `template-app` run the **same image** with different
commands. The image ships the Prisma CLI (the `migrator` stage in `Dockerfile`
installs it on its own), so the migration step needs nothing extra:

```yaml
template-migrations:
image: ghcr.io/c4g/template:${IMAGE_TAG:-latest}
command: ['node', '/node_modules/prisma/build/index.js', 'migrate', 'deploy']
```

The ordering guarantee is unchanged — the app still waits on
`service_completed_successfully`, so it starts only after migrations exit 0.

The migration tooling is installed with **npm**, not pnpm, and lands at
`/node_modules` rather than `/app/node_modules`. Both details are load-bearing:
pnpm's symlink farm does not survive a `COPY` between stages, and the Next.js
standalone output contains symlinked packages, so copying a directory over
`/app/node_modules` fails with `cannot copy to non-directory`. `/node_modules`
is the last place Node looks when resolving from `/app`, so `prisma.config.ts`
still finds `dotenv` and `prisma/config` while the application's own resolution
is untouched.

A previous version built a second image from a `Dockerfile.migrations` that ran
`pnpm install --prod` — pulling Next, React and every other runtime dependency
in order to run one command. That image was 1.63 GB to carry 94 kB of
migrations. Publishing one image instead cut the total pulled per deploy from
about 2 GB to 685 MB, and halved the number of GHCR packages to keep public.

Required repository/organization configuration:

| Name | Kind | Purpose |
| ------------------ | -------- | --------------------------------------------- |
| `COOLIFY_TOKEN` | secret | Coolify API token (organization-level secret) |
| `COOLIFY_APP_UUID` | variable | UUID of the Coolify application to redeploy |

The deploy step skips itself when either Coolify value is missing, so a copy of
this template publishes images without redeploying the template's own app.

The build itself needs no application secrets — see below.

### One image, many environments

Nothing environment-specific is baked into the image, so the same build can back
several Coolify applications. `IMAGE_TAG` selects which build each one runs:
leave it unset to track `latest`, or pin it to a commit SHA in the application's
Coolify environment variables to promote a build that has already been verified
elsewhere. **Adding a test environment later is therefore just a second Coolify
application pointed at this same compose file** — no repository changes, no
second image.

This requires that no `NEXT_PUBLIC_*` variable is present during the build.
Next.js substitutes those into the bundle only when they exist at build time, so
leaving them unset keeps `process.env.NEXT_PUBLIC_*` in the compiled server
output as a real runtime lookup, and each environment supplies its own value
through Coolify.

It works for `NEXT_PUBLIC_VAPID_PUBLIC_KEY` because that value is read
server-side only (`src/lib/web-push.ts`); the browser fetches the key from
`GET /api/notifications/subscribe` rather than reading an inlined copy. If
client code ever needs a `NEXT_PUBLIC_*` value directly it will be `undefined`
in the browser, and baking it in to fix that would re-tie the image to a single
environment — serve it from an API route or a server component prop instead.

### Deployment Commands

Build and start all services:
Pull the published image and start all services:

```bash
docker compose --profile production up -d
```

Build the image from source instead (local verification, and what CI does):

```bash
docker compose --profile production up -d --build
docker compose -f docker-compose.yml -f docker-compose.build.yml \
--profile production up -d --build
```

Check service status:
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Adds the `build:` key that docker-compose.yml omits, for building locally:
# docker compose -f docker-compose.yml -f docker-compose.build.yml --profile production up --build -d
services:
template-app:
build:
context: .
dockerfile: Dockerfile
17 changes: 7 additions & 10 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Production stack, deployed as a Coolify "Docker Compose" resource. No `build:`
# keys on purpose — .github/workflows/publish.yaml builds and publishes the image.
services:
template-db:
image: postgres:17
Expand All @@ -17,11 +19,11 @@ services:
start_period: 30s
start_interval: 2s

# Database migrations (production only)
# Database migrations (production only). Same image as the app, different
# command — the image ships the Prisma CLI for exactly this.
template-migrations:
build:
context: .
dockerfile: Dockerfile.migrations
image: ghcr.io/c4g/template:${IMAGE_TAG:-latest}
command: ["node", "/node_modules/prisma/build/index.js", "migrate", "deploy"]
environment:
- DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PW}@template-db:5432/${DATABASE_NAME}
restart: "no"
Expand All @@ -33,12 +35,7 @@ services:

# Next.js application (production only)
template-app:
build:
context: .
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
image: ghcr.io/c4g/template:${IMAGE_TAG:-latest}
ports:
- "${APP_PORT-3001:}3000"
environment:
Expand Down
12 changes: 12 additions & 0 deletions src/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading