Skip to content

Commit 882b4a3

Browse files
Containerize webapp + deploy to Oracle/Dokploy via GHCR (#281)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e23f803 commit 882b4a3

8 files changed

Lines changed: 243 additions & 45 deletions

File tree

.github/dependabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ updates:
2222
- package-ecosystem: "github-actions"
2323
directory: "/"
2424
schedule:
25-
interval: "monthly"
25+
interval: "monthly"

.github/workflows/deploy.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Build & publish image
2+
3+
# Build the mploy webapp Docker image OFF the Oracle box (GitHub's runners do
4+
# the heavy build) and push it to GHCR. Dokploy then just *pulls* the prebuilt
5+
# image and runs it — so several apps can share one Oracle box without their
6+
# builds fighting over RAM/CPU. See docs/deploy.md.
7+
8+
on:
9+
push:
10+
branches: [production]
11+
# Only rebuild when something that affects the image changes.
12+
paths:
13+
- "frontend/**"
14+
- ".github/workflows/deploy.yml"
15+
workflow_dispatch: {} # allow manual "Run workflow"
16+
17+
env:
18+
# NOTE: not ${{ github.repository }} — that's monashcoding/mploy-app. The
19+
# published image name is deliberately just "mploy".
20+
IMAGE: ghcr.io/monashcoding/mploy
21+
22+
jobs:
23+
build:
24+
# Native ARM64 runner — Oracle Ampere is arm64, and this is free on public
25+
# repos. Avoids ~5x slower QEMU cross-compilation.
26+
runs-on: ubuntu-24.04-arm
27+
permissions:
28+
contents: read
29+
packages: write # push to GHCR
30+
steps:
31+
- uses: actions/checkout@v4
32+
33+
- uses: docker/setup-buildx-action@v3
34+
35+
- name: Log in to GHCR
36+
uses: docker/login-action@v3
37+
with:
38+
registry: ghcr.io
39+
username: ${{ github.actor }}
40+
password: ${{ secrets.GITHUB_TOKEN }}
41+
42+
- name: Image metadata (tags)
43+
id: meta
44+
uses: docker/metadata-action@v5
45+
with:
46+
images: ${{ env.IMAGE }}
47+
tags: |
48+
type=raw,value=latest
49+
type=sha,format=long
50+
51+
- name: Build & push (linux/arm64)
52+
uses: docker/build-push-action@v6
53+
with:
54+
# Build context is frontend/ — the webapp is a self-contained npm app
55+
# (not a workspace), so npm ci resolves from frontend/package-lock.json.
56+
context: ./frontend
57+
file: ./frontend/Dockerfile
58+
platforms: linux/arm64
59+
push: true
60+
tags: ${{ steps.meta.outputs.tags }}
61+
labels: ${{ steps.meta.outputs.labels }}
62+
cache-from: type=gha
63+
cache-to: type=gha,mode=max
64+
# No build-args: mploy has no NEXT_PUBLIC_* build-time vars, and
65+
# `next build` doesn't touch the DB. All secrets are runtime env in
66+
# Dokploy.
67+
68+
# Tell Dokploy to pull the new image and redeploy. Create the app in
69+
# Dokploy with provider "Docker" pointing at ghcr.io/monashcoding/mploy:latest,
70+
# then copy its deploy webhook URL into the repo secret
71+
# DOKPLOY_DEPLOY_WEBHOOK. Skipped automatically until that secret exists.
72+
- name: Trigger Dokploy redeploy
73+
env:
74+
# Secrets can't be used directly in `if:`, so hoist into env first.
75+
DOKPLOY_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }}
76+
if: ${{ env.DOKPLOY_DEPLOY_WEBHOOK != '' }}
77+
run: curl -fsSL -X POST "$DOKPLOY_DEPLOY_WEBHOOK"

.github/workflows/lint-checker.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: Frontend Lint Checker
33
on:
44
pull_request:
55
paths:
6-
- 'frontend/**'
6+
- "frontend/**"
77

88
jobs:
99
verify:
@@ -18,9 +18,9 @@ jobs:
1818
- name: Setup Node.js
1919
uses: actions/setup-node@v4
2020
with:
21-
node-version: '20'
22-
cache: 'npm'
23-
cache-dependency-path: './frontend/package-lock.json'
21+
node-version: "20"
22+
cache: "npm"
23+
cache-dependency-path: "./frontend/package-lock.json"
2424

2525
- name: Install dependencies
2626
run: npm ci
@@ -35,4 +35,4 @@ jobs:
3535
run: npx prettier --check .
3636

3737
- name: Build
38-
run: npm run build
38+
run: npm run build

docs/deploy.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Deploying mploy (Oracle Cloud + Dokploy)
2+
3+
mploy (the MAC Jobs Board webapp) runs as a Docker container on an Oracle
4+
Cloud VM, fronted by [Dokploy](https://dokploy.com). This mirrors the setup
5+
used for our sibling repo `monmap`.
6+
7+
## Architecture: build off-box, run on-box
8+
9+
The Oracle VM is shared by several apps (monmap, mploy, monashcoding). A
10+
Docker build peaks at 2–4 GB RAM and pegs the CPU; several racing on one box
11+
is how you OOM production. So **we never build on the Oracle box**:
12+
13+
```
14+
push to production ──▶ GitHub Actions (ubuntu-24.04-arm)
15+
│ builds linux/arm64 image
16+
17+
GHCR: ghcr.io/monashcoding/mploy:latest
18+
│ Dokploy pulls (webhook-triggered)
19+
20+
Oracle VM: `node server.js` (~200–400 MB idle)
21+
```
22+
23+
The box only ever runs the finished containers.
24+
25+
## This app's shape (why the config looks the way it does)
26+
27+
- The webapp is `frontend/` — a **self-contained npm app** (not a pnpm
28+
workspace), so the Docker **build context is `frontend/`** and deps install
29+
with `npm ci` from `frontend/package-lock.json`. The Spring Boot `backend/`
30+
is a separate service, not part of this image.
31+
- Next.js is built with `output: "standalone"`; the runtime just runs
32+
`node server.js`. `outputFileTracingRoot` is pinned to `frontend/` so the
33+
standalone entrypoint always lands at `.next/standalone/server.js`.
34+
- **No build-time vars.** There are no `NEXT_PUBLIC_*` values (the Google
35+
Analytics id is hardcoded), and `next build` does **not** touch MongoDB —
36+
every DB-backed route is `force-dynamic` or reads `searchParams`, so nothing
37+
is prerendered against the database. Everything below is therefore a
38+
**runtime** env var set in Dokploy; nothing is baked into the image.
39+
40+
## One-time GitHub setup
41+
42+
1. **Repo Secret** (Settings → Secrets and variables → Actions → _Secrets_):
43+
- `DOKPLOY_DEPLOY_WEBHOOK` = the deploy webhook URL Dokploy generates for
44+
the app (added after the Dokploy step below). Until it exists, the
45+
workflow builds/pushes the image but skips the redeploy trigger.
46+
2. **Make the GHCR package public** (or give Dokploy a read token) so the VM
47+
can pull without auth: after the first push, open the package at
48+
`github.com/orgs/monashcoding/packages` → Package settings → change
49+
visibility to Public.
50+
51+
There are no repo _Variables_ to set — the build takes no build-args.
52+
53+
## One-time Dokploy setup
54+
55+
1. **Create Application** → Provider: **Docker**.
56+
- Image: `ghcr.io/monashcoding/mploy:latest`
57+
- (If you kept the package private: add GHCR registry credentials — a
58+
GitHub PAT with `read:packages`.)
59+
2. **Environment** (runtime vars):
60+
```
61+
MONGODB_URI=mongodb+srv://<user>:<pass>@<atlas-cluster>/<db>
62+
MONGODB_DATABASE=default
63+
NEXTAUTH_SECRET=<random secret>
64+
NEXTAUTH_URL=https://jobs.monashcoding.com
65+
GOOGLE_CLIENT_ID=<oauth client id>
66+
GOOGLE_CLIENT_SECRET=<oauth client secret>
67+
NOTION_API_KEY=<notion integration token>
68+
NOTION_DATABASE_ID=<notion database id>
69+
```
70+
MongoDB is hosted externally (Atlas), so `MONGODB_URI` is a normal
71+
`mongodb+srv://` connection string — no on-box private-IP caveat. Make sure
72+
the Atlas cluster's IP access list allows the Oracle VM's egress IP.
73+
3. **Port**: container listens on `3000`.
74+
4. **Domains**: add `jobs.monashcoding.com` → container port `3000` → enable
75+
HTTPS (Let's Encrypt).
76+
- DNS is on Cloudflare. **Grey-cloud (DNS-only)** the record first so
77+
Dokploy/Traefik can complete the Let's Encrypt HTTP-01 challenge and
78+
issue the cert, then switch it back to **orange-cloud (proxied)**
79+
afterwards.
80+
5. **Deploy webhook**: copy the app's deploy webhook URL into the GitHub repo
81+
secret `DOKPLOY_DEPLOY_WEBHOOK` (GitHub setup step 1) so each pushed image
82+
auto-redeploys.
83+
84+
## Deploying a change
85+
86+
Push to `production`. GitHub Actions builds + pushes the image, then hits the
87+
Dokploy webhook, which pulls and restarts the container. Watch the run under
88+
the repo's Actions tab; watch the pull/restart in Dokploy.
89+
90+
To deploy manually: Actions → **Build & publish image**_Run workflow_, then
91+
hit **Deploy** in Dokploy.
92+
93+
## Notes
94+
95+
- `next build` does **not** touch MongoDB, so `MONGODB_URI` is a runtime-only
96+
var. There are no build-args at all.
97+
- The container runs as a non-root user (`nextjs`, uid 1001).
98+
- The image is `linux/arm64` only — it runs on the Ampere A1 box and won't run
99+
on an x86 host without emulation.

frontend/.dockerignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Build context = frontend/. Keep the image lean and deterministic: never ship
2+
# local deps/build output/secrets into the build.
3+
node_modules
4+
.next
5+
npm-debug.log*
6+
.env
7+
.env.*
8+
.git
9+
.gitignore
10+
Dockerfile
11+
.dockerignore
12+
README.md
13+
tsconfig.tsbuildinfo
14+
.DS_Store

frontend/Dockerfile

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,53 @@
1-
# -------------------------
2-
# Base
3-
# -------------------------
4-
FROM node:20-alpine AS base
1+
# syntax=docker/dockerfile:1
2+
3+
# ---------------------------------------------------------------------------
4+
# mploy webapp — self-hosted image (Oracle Cloud + Dokploy).
5+
#
6+
# Build context is frontend/ (this directory). mploy's frontend is a
7+
# self-contained npm app — NOT a pnpm workspace — so npm ci resolves
8+
# everything from frontend/package-lock.json. The Spring Boot backend is a
9+
# separate service and is not part of this image.
10+
#
11+
# There are NO build-time vars: the app has no NEXT_PUBLIC_* values (the GA id
12+
# is hardcoded), and `next build` does NOT touch MongoDB (every DB route is
13+
# force-dynamic or reads searchParams — nothing is prerendered against the DB).
14+
# So everything secret (MONGODB_URI, NEXTAUTH_SECRET, OAuth creds, Notion keys)
15+
# is a *runtime* env var, set in Dokploy — never baked into the image.
16+
# ---------------------------------------------------------------------------
17+
18+
FROM node:22-slim AS base
519
WORKDIR /app
20+
ENV NEXT_TELEMETRY_DISABLED=1
621

7-
# -------------------------
8-
# Dependencies
9-
# -------------------------
22+
# ---- deps ---------------------------------------------------------------
1023
FROM base AS deps
11-
COPY package.json package-lock.json* ./
24+
COPY package.json package-lock.json ./
1225
RUN npm ci
1326

14-
# -------------------------
15-
# Builder
16-
# -------------------------
17-
FROM base AS builder
27+
# ---- build --------------------------------------------------------------
28+
FROM base AS build
1829
COPY --from=deps /app/node_modules ./node_modules
19-
COPY package.json package-lock.json* ./
20-
COPY tsconfig.json next.config.ts ./
21-
COPY postcss.config.mjs tailwind.config.ts ./
22-
COPY public ./public
23-
COPY src ./src
30+
COPY . .
2431
RUN npm run build
2532

26-
# -------------------------
27-
# Runner
28-
# -------------------------
29-
FROM base AS runner
33+
# ---- runtime ------------------------------------------------------------
34+
FROM node:22-slim AS runner
3035
WORKDIR /app
36+
ENV NODE_ENV=production \
37+
NEXT_TELEMETRY_DISABLED=1 \
38+
PORT=3000 \
39+
HOSTNAME=0.0.0.0
3140

32-
# Default: prod, can override with `-e NODE_ENV=development`
33-
ENV NODE_ENV=production
41+
# Run as a non-root user.
42+
RUN groupadd --system --gid 1001 nodejs \
43+
&& useradd --system --uid 1001 --gid nodejs nextjs
3444

35-
# Copy runtime deps
36-
COPY --from=deps /app/node_modules ./node_modules
37-
38-
# Copy built assets
39-
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
40-
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
41-
COPY --from=builder --chown=1001:1001 /app/public ./public
45+
# `output: "standalone"` bundles server.js + its traced node_modules. Static
46+
# assets and public/ aren't traced, so copy them alongside.
47+
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
48+
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
49+
COPY --from=build --chown=nextjs:nodejs /app/public ./public
4250

43-
# Create non-root user
44-
RUN addgroup --system --gid 1001 nodejs \
45-
&& adduser --system --uid 1001 nextjs
4651
USER nextjs
47-
4852
EXPOSE 3000
49-
ENV PORT=3000
50-
ENV HOSTNAME=0.0.0.0
51-
52-
CMD ["npm", "start"]
53+
CMD ["node", "server.js"]

frontend/next.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import type { NextConfig } from "next";
2+
import path from "path";
23

34
const nextConfig: NextConfig = {
5+
// Emit a self-contained server bundle for the Docker runtime image
6+
// (.next/standalone/server.js). See ../docs/deploy.md.
47
output: "standalone",
8+
// Pin file tracing to this app dir. Without it, the empty root
9+
// package-lock.json makes Next infer the repo root as the tracing root and
10+
// nest the standalone output under frontend/, breaking the Dockerfile CMD.
11+
outputFileTracingRoot: path.join(__dirname),
12+
serverExternalPackages: ["mongodb", "pino", "pino-pretty"],
513
};
614

715
export default nextConfig;

frontend/package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)