diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4bf823e..d89ad8b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,4 +22,4 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "monthly" \ No newline at end of file + interval: "monthly" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..9d20cbc --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,77 @@ +name: Build & publish image + +# Build the mploy webapp Docker image OFF the Oracle box (GitHub's runners do +# the heavy build) and push it to GHCR. Dokploy then just *pulls* the prebuilt +# image and runs it — so several apps can share one Oracle box without their +# builds fighting over RAM/CPU. See docs/deploy.md. + +on: + push: + branches: [production] + # Only rebuild when something that affects the image changes. + paths: + - "frontend/**" + - ".github/workflows/deploy.yml" + workflow_dispatch: {} # allow manual "Run workflow" + +env: + # NOTE: not ${{ github.repository }} — that's monashcoding/mploy-app. The + # published image name is deliberately just "mploy". + IMAGE: ghcr.io/monashcoding/mploy + +jobs: + build: + # Native ARM64 runner — Oracle Ampere is arm64, and this is free on public + # repos. Avoids ~5x slower QEMU cross-compilation. + runs-on: ubuntu-24.04-arm + permissions: + contents: read + packages: write # push to GHCR + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Image metadata (tags) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest + type=sha,format=long + + - name: Build & push (linux/arm64) + uses: docker/build-push-action@v6 + with: + # Build context is frontend/ — the webapp is a self-contained npm app + # (not a workspace), so npm ci resolves from frontend/package-lock.json. + context: ./frontend + file: ./frontend/Dockerfile + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + # No build-args: mploy has no NEXT_PUBLIC_* build-time vars, and + # `next build` doesn't touch the DB. All secrets are runtime env in + # Dokploy. + + # Tell Dokploy to pull the new image and redeploy. Create the app in + # Dokploy with provider "Docker" pointing at ghcr.io/monashcoding/mploy:latest, + # then copy its deploy webhook URL into the repo secret + # DOKPLOY_DEPLOY_WEBHOOK. Skipped automatically until that secret exists. + - name: Trigger Dokploy redeploy + env: + # Secrets can't be used directly in `if:`, so hoist into env first. + DOKPLOY_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }} + if: ${{ env.DOKPLOY_DEPLOY_WEBHOOK != '' }} + run: curl -fsSL -X POST "$DOKPLOY_DEPLOY_WEBHOOK" diff --git a/.github/workflows/lint-checker.yml b/.github/workflows/lint-checker.yml index 9ea83d6..43e7199 100644 --- a/.github/workflows/lint-checker.yml +++ b/.github/workflows/lint-checker.yml @@ -3,7 +3,7 @@ name: Frontend Lint Checker on: pull_request: paths: - - 'frontend/**' + - "frontend/**" jobs: verify: @@ -18,9 +18,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' - cache-dependency-path: './frontend/package-lock.json' + node-version: "20" + cache: "npm" + cache-dependency-path: "./frontend/package-lock.json" - name: Install dependencies run: npm ci @@ -35,4 +35,4 @@ jobs: run: npx prettier --check . - name: Build - run: npm run build \ No newline at end of file + run: npm run build diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..6d39cec --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,99 @@ +# Deploying mploy (Oracle Cloud + Dokploy) + +mploy (the MAC Jobs Board webapp) runs as a Docker container on an Oracle +Cloud VM, fronted by [Dokploy](https://dokploy.com). This mirrors the setup +used for our sibling repo `monmap`. + +## Architecture: build off-box, run on-box + +The Oracle VM is shared by several apps (monmap, mploy, monashcoding). A +Docker build peaks at 2–4 GB RAM and pegs the CPU; several racing on one box +is how you OOM production. So **we never build on the Oracle box**: + +``` +push to production ──▶ GitHub Actions (ubuntu-24.04-arm) + │ builds linux/arm64 image + ▼ + GHCR: ghcr.io/monashcoding/mploy:latest + │ Dokploy pulls (webhook-triggered) + ▼ + Oracle VM: `node server.js` (~200–400 MB idle) +``` + +The box only ever runs the finished containers. + +## This app's shape (why the config looks the way it does) + +- The webapp is `frontend/` — a **self-contained npm app** (not a pnpm + workspace), so the Docker **build context is `frontend/`** and deps install + with `npm ci` from `frontend/package-lock.json`. The Spring Boot `backend/` + is a separate service, not part of this image. +- Next.js is built with `output: "standalone"`; the runtime just runs + `node server.js`. `outputFileTracingRoot` is pinned to `frontend/` so the + standalone entrypoint always lands at `.next/standalone/server.js`. +- **No build-time vars.** There are no `NEXT_PUBLIC_*` values (the Google + Analytics id is hardcoded), and `next build` does **not** touch MongoDB — + every DB-backed route is `force-dynamic` or reads `searchParams`, so nothing + is prerendered against the database. Everything below is therefore a + **runtime** env var set in Dokploy; nothing is baked into the image. + +## One-time GitHub setup + +1. **Repo Secret** (Settings → Secrets and variables → Actions → _Secrets_): + - `DOKPLOY_DEPLOY_WEBHOOK` = the deploy webhook URL Dokploy generates for + the app (added after the Dokploy step below). Until it exists, the + workflow builds/pushes the image but skips the redeploy trigger. +2. **Make the GHCR package public** (or give Dokploy a read token) so the VM + can pull without auth: after the first push, open the package at + `github.com/orgs/monashcoding/packages` → Package settings → change + visibility to Public. + +There are no repo _Variables_ to set — the build takes no build-args. + +## One-time Dokploy setup + +1. **Create Application** → Provider: **Docker**. + - Image: `ghcr.io/monashcoding/mploy:latest` + - (If you kept the package private: add GHCR registry credentials — a + GitHub PAT with `read:packages`.) +2. **Environment** (runtime vars): + ``` + MONGODB_URI=mongodb+srv://:@/ + MONGODB_DATABASE=default + NEXTAUTH_SECRET= + NEXTAUTH_URL=https://jobs.monashcoding.com + GOOGLE_CLIENT_ID= + GOOGLE_CLIENT_SECRET= + NOTION_API_KEY= + NOTION_DATABASE_ID= + ``` + MongoDB is hosted externally (Atlas), so `MONGODB_URI` is a normal + `mongodb+srv://` connection string — no on-box private-IP caveat. Make sure + the Atlas cluster's IP access list allows the Oracle VM's egress IP. +3. **Port**: container listens on `3000`. +4. **Domains**: add `jobs.monashcoding.com` → container port `3000` → enable + HTTPS (Let's Encrypt). + - DNS is on Cloudflare. **Grey-cloud (DNS-only)** the record first so + Dokploy/Traefik can complete the Let's Encrypt HTTP-01 challenge and + issue the cert, then switch it back to **orange-cloud (proxied)** + afterwards. +5. **Deploy webhook**: copy the app's deploy webhook URL into the GitHub repo + secret `DOKPLOY_DEPLOY_WEBHOOK` (GitHub setup step 1) so each pushed image + auto-redeploys. + +## Deploying a change + +Push to `production`. GitHub Actions builds + pushes the image, then hits the +Dokploy webhook, which pulls and restarts the container. Watch the run under +the repo's Actions tab; watch the pull/restart in Dokploy. + +To deploy manually: Actions → **Build & publish image** → _Run workflow_, then +hit **Deploy** in Dokploy. + +## Notes + +- `next build` does **not** touch MongoDB, so `MONGODB_URI` is a runtime-only + var. There are no build-args at all. +- The container runs as a non-root user (`nextjs`, uid 1001). +- The image is `linux/arm64` only — it runs on the Ampere A1 box and won't run + on an x86 host without emulation. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a5ad00a --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,14 @@ +# Build context = frontend/. Keep the image lean and deterministic: never ship +# local deps/build output/secrets into the build. +node_modules +.next +npm-debug.log* +.env +.env.* +.git +.gitignore +Dockerfile +.dockerignore +README.md +tsconfig.tsbuildinfo +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 890ef68..1f4e07a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,52 +1,53 @@ -# ------------------------- -# Base -# ------------------------- -FROM node:20-alpine AS base +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# mploy webapp — self-hosted image (Oracle Cloud + Dokploy). +# +# Build context is frontend/ (this directory). mploy's frontend is a +# self-contained npm app — NOT a pnpm workspace — so npm ci resolves +# everything from frontend/package-lock.json. The Spring Boot backend is a +# separate service and is not part of this image. +# +# There are NO build-time vars: the app has no NEXT_PUBLIC_* values (the GA id +# is hardcoded), and `next build` does NOT touch MongoDB (every DB route is +# force-dynamic or reads searchParams — nothing is prerendered against the DB). +# So everything secret (MONGODB_URI, NEXTAUTH_SECRET, OAuth creds, Notion keys) +# is a *runtime* env var, set in Dokploy — never baked into the image. +# --------------------------------------------------------------------------- + +FROM node:22-slim AS base WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 -# ------------------------- -# Dependencies -# ------------------------- +# ---- deps --------------------------------------------------------------- FROM base AS deps -COPY package.json package-lock.json* ./ +COPY package.json package-lock.json ./ RUN npm ci -# ------------------------- -# Builder -# ------------------------- -FROM base AS builder +# ---- build -------------------------------------------------------------- +FROM base AS build COPY --from=deps /app/node_modules ./node_modules -COPY package.json package-lock.json* ./ -COPY tsconfig.json next.config.ts ./ -COPY postcss.config.mjs tailwind.config.ts ./ -COPY public ./public -COPY src ./src +COPY . . RUN npm run build -# ------------------------- -# Runner -# ------------------------- -FROM base AS runner +# ---- runtime ------------------------------------------------------------ +FROM node:22-slim AS runner WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 -# Default: prod, can override with `-e NODE_ENV=development` -ENV NODE_ENV=production +# Run as a non-root user. +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs -# Copy runtime deps -COPY --from=deps /app/node_modules ./node_modules - -# Copy built assets -COPY --from=builder --chown=1001:1001 /app/.next/standalone ./ -COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static -COPY --from=builder --chown=1001:1001 /app/public ./public +# `output: "standalone"` bundles server.js + its traced node_modules. Static +# assets and public/ aren't traced, so copy them alongside. +COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=build --chown=nextjs:nodejs /app/public ./public -# Create non-root user -RUN addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs USER nextjs - EXPOSE 3000 -ENV PORT=3000 -ENV HOSTNAME=0.0.0.0 - -CMD ["npm", "start"] +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 68a6c64..2338710 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,15 @@ import type { NextConfig } from "next"; +import path from "path"; const nextConfig: NextConfig = { + // Emit a self-contained server bundle for the Docker runtime image + // (.next/standalone/server.js). See ../docs/deploy.md. output: "standalone", + // Pin file tracing to this app dir. Without it, the empty root + // package-lock.json makes Next infer the repo root as the tracing root and + // nest the standalone output under frontend/, breaking the Dockerfile CMD. + outputFileTracingRoot: path.join(__dirname), + serverExternalPackages: ["mongodb", "pino", "pino-pretty"], }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbf255c..24c5cc4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,6 @@ "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", - "mongodb": "^6.14.2", "next": "15.1.7", "pino": "^9.11.0",