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 @@ -56,7 +56,8 @@ jobs:
MYSQL_USER: ci
MYSQL_PASSWORD: ci
run: |
docker compose -f ./docker-compose.yml -p "va-stats-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 -p "va-stats-pr-${PR_NUMBER}" up --build -d

echo "Waiting for the app to become healthy..."
for i in {1..60}; do
Expand Down
115 changes: 115 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Publish Image

# 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:
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, so one 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

# 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: |
{
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"

# 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 }}
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}"
7 changes: 2 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@ 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 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 ./
Expand Down
51 changes: 48 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`:<commit-sha>`, 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

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 up --build -d
services:
va-stats-app:
build:
context: .
dockerfile: Dockerfile
12 changes: 4 additions & 8 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
# 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.
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:
Expand All @@ -23,8 +20,7 @@ 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)
# Read at runtime, not baked in — see Dockerfile.
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-}
command: node server.js
healthcheck:
Expand Down
Loading