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
42 changes: 27 additions & 15 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,18 @@
# Optional:
# VPS_SSH_PORT defaults to 22
#
# The five images are published as:
# codeheist/spidder-{http-api,ws-server,judge-worker,web,db-init}
# The six images are published as:
# codeheist/spidder-{http-api,ws-server,judge-worker,web,admin,db-init}
#
# DOCKERHUB_USERNAME supplies that namespace when PUSHING here; the compose
# file on the server writes `codeheist` literally when PULLING. Change one and
# you must change the other, or CI pushes somewhere the server never looks.
#
# Docker Hub's free tier gives ONE private repository. Five private images
# means a paid plan; otherwise create the five repos as public. They contain
# Docker Hub's free tier gives ONE private repository. Six private images
# means a paid plan; otherwise create the six repos as public. They contain
# only compiled application code — no secrets, since every credential is
# injected at runtime from the server's .env. The one exception is the `web`
# image: NEXT_PUBLIC_* are compiled into its browser bundle, and those are
# injected at runtime from the server's .env. The exceptions are `web` and
# `admin`: NEXT_PUBLIC_* are compiled into their browser bundles, and those are
# public URLs by definition, visible in any visitor's devtools anyway.
###############################################################################

Expand Down Expand Up @@ -119,9 +119,9 @@ jobs:
run: pnpm --filter @repo/game test

# ---------------------------------------------------------------------------
# Build the five images in parallel and push them to Docker Hub.
# Build the six images in parallel and push them to Docker Hub.
#
# A matrix rather than five copies of the same block: the only thing that
# A matrix rather than six copies of the same block: the only thing that
# differs is the Dockerfile path and the image name.
# ---------------------------------------------------------------------------
build:
Expand All @@ -135,7 +135,7 @@ jobs:
# GITHUB_TOKEN push to GHCR — would be an unused grant.
contents: read
strategy:
# One image failing should not cancel the other four — seeing every
# One image failing should not cancel the other five — seeing every
# failure in one run beats fixing them one deploy at a time.
fail-fast: false
matrix:
Expand All @@ -148,6 +148,8 @@ jobs:
dockerfile: apps/judge-worker/Dockerfile.prod
- name: web
dockerfile: apps/web/Dockerfile.prod
- name: admin
dockerfile: apps/admin/Dockerfile.prod
- name: db-init
dockerfile: docker/db-init/Dockerfile
steps:
Expand Down Expand Up @@ -201,7 +203,7 @@ jobs:
# NEXT_PUBLIC_* are inlined into the CLIENT bundle at build time, so
# the real public origins have to be present HERE. Passing them at
# runtime would leave the browser calling localhost in production.
# Harmless for the other four images, which ignore them.
# web and admin both consume them; harmless for the rest.
build-args: |
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_WS_URL=${{ secrets.NEXT_PUBLIC_WS_URL }}
Expand Down Expand Up @@ -286,11 +288,21 @@ jobs:
docker compose -f docker-compose.prod.yml pull
echo "::endgroup::"

echo "::group::Applying schema"
# Runs to completion before any app starts. Idempotent, so a deploy
# with no schema change is a no-op rather than a risk.
docker compose -f docker-compose.prod.yml up --no-deps --exit-code-from db-init db-init
echo "::endgroup::"
# NO SCHEMA STEP HERE, deliberately.
#
# A deploy pulls images and restarts containers. It must not alter the
# database as a side effect: the old step ran db-init on every push to
# main, and db-init re-runs a seed that DELETES every problem and test
# case before re-inserting three samples.
#
# Apply a schema change yourself, on the server, when a release
# actually carries one:
#
# docker compose -f docker-compose.prod.yml run --rm db-init
#
# The tradeoff is explicit: forget it and the apps run against the old
# schema until you remember. A visible failure beats a deploy that can
# quietly rewrite data.

echo "::group::Restarting services"
docker compose -f docker-compose.prod.yml up -d --remove-orphans
Expand Down
86 changes: 86 additions & 0 deletions apps/admin/Dockerfile.prod
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# syntax=docker/dockerfile:1.7
###############################################################################
# admin (Next.js) — production image.
#
# Stages: base -> pruner -> deps -> build -> runner
# Uses Next's `output: "standalone"` bundle, so the runtime layer carries no
# node_modules and no pnpm store — just a traced server + static assets.
#
# NOTE: NEXT_PUBLIC_* values are inlined into the client bundle at BUILD time,
# so the API URL is a build arg, not runtime env. Admin talks only to the REST
# API; it holds no WebSocket connection, so there is no NEXT_PUBLIC_WS_URL here.
#
# Built from the MONOREPO ROOT as context:
# docker build -f apps/admin/Dockerfile.prod .
###############################################################################

ARG NODE_VERSION=22-alpine
# Keep in lockstep with the root package.json "packageManager" field.
ARG PNPM_VERSION=10.19.0

# ---------------------------------------------------------------------------
# base — the exact pinned pnpm, shared by every later stage.
# ---------------------------------------------------------------------------
FROM node:${NODE_VERSION} AS base
ARG PNPM_VERSION
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME/bin:$PNPM_HOME:$PATH
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app

# ---------------------------------------------------------------------------
# pruner — keep only admin and its workspace deps.
# ---------------------------------------------------------------------------
FROM base AS pruner
COPY . .
RUN pnpm dlx turbo@^2 prune admin --docker

# ---------------------------------------------------------------------------
# deps — install against the pruned lockfile.
# ---------------------------------------------------------------------------
FROM base AS deps
COPY --from=pruner /app/out/json/ ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile

# ---------------------------------------------------------------------------
# build — compile workspace deps, then `next build` into .next/standalone.
# ---------------------------------------------------------------------------
FROM base AS build
ENV NEXT_TELEMETRY_DISABLED=1

# Baked into the client bundle. Override for non-localhost deployments.
ARG NEXT_PUBLIC_API_URL=http://localhost:4001
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

COPY --from=deps /app/ ./
COPY --from=pruner /app/out/full/ ./
RUN pnpm dlx turbo@^2 run build --filter=admin

# ---------------------------------------------------------------------------
# runner — non-root, standalone server only.
# ---------------------------------------------------------------------------
FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup -g 1001 -S nodejs && adduser -S -u 1001 -G nodejs nextjs

# Because outputFileTracingRoot is the monorepo root, the standalone tree keeps
# the workspace layout: server.js lands at apps/admin/server.js with a hoisted
# node_modules at the root. Copy the tree as-is, then slot static assets back in
# (Next deliberately omits .next/static and public/ from the traced output).
COPY --from=build --chown=nextjs:nodejs /app/apps/admin/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/apps/admin/.next/static ./apps/admin/.next/static
COPY --from=build --chown=nextjs:nodejs /app/apps/admin/public ./apps/admin/public

USER nextjs

ENV HOSTNAME=0.0.0.0
ENV PORT=3002
EXPOSE 3002

HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3002)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["node", "apps/admin/server.js"]
Loading
Loading