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
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
interval: "monthly"
77 changes: 77 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 5 additions & 5 deletions .github/workflows/lint-checker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Frontend Lint Checker
on:
pull_request:
paths:
- 'frontend/**'
- "frontend/**"

jobs:
verify:
Expand All @@ -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
Expand All @@ -35,4 +35,4 @@ jobs:
run: npx prettier --check .

- name: Build
run: npm run build
run: npm run build
99 changes: 99 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
@@ -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://<user>:<pass>@<atlas-cluster>/<db>
MONGODB_DATABASE=default
NEXTAUTH_SECRET=<random secret>
NEXTAUTH_URL=https://jobs.monashcoding.com
GOOGLE_CLIENT_ID=<oauth client id>
GOOGLE_CLIENT_SECRET=<oauth client secret>
NOTION_API_KEY=<notion integration token>
NOTION_DATABASE_ID=<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.
14 changes: 14 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -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
77 changes: 39 additions & 38 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
8 changes: 8 additions & 0 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 0 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading