Skip to content
Open
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
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Keep the build context small and avoid leaking local state into the image.
.git
.github
node_modules
.next
npm-debug.log*
.env
.env.*
!.env.example
.husky
.vscode
.idea
*.md
docs
Dockerfile
.dockerignore
98 changes: 98 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Build & publish image

# Build the site's Docker image OFF the Oracle box (GitHub's runners do the
# heavy 2-4GB build) and push it to GHCR. Dokploy then just *pulls* the
# prebuilt image and runs it — so the apps sharing the Oracle box don't fight
# over RAM/CPU during builds. See docs/deploy.md.

on:
push:
branches: [main]
# Only rebuild when something that affects the image changes.
paths:
- "app/**"
- "components/**"
- "lib/**"
- "sanity/**"
- "public/**"
- "sanity.config.ts"
- "sanity.cli.ts"
- "next.config.ts"
- "postcss.config.mjs"
- "tailwind.config.ts"
- "tsconfig.json"
- "Dockerfile"
- ".dockerignore"
- "package.json"
- "package-lock.json"
- ".github/workflows/deploy.yml"
workflow_dispatch: {} # allow manual "Run workflow"

# NOTE: the deploy image is ghcr.io/monashcoding/monashcoding:latest, which is
# deliberately NOT the repo name (monashcoding-site). It's set explicitly in the
# metadata step below; the first push creates that GHCR package and links it here.

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:
# Deploy target is ghcr.io/monashcoding/monashcoding:latest, which
# differs from the repo name (monashcoding-site), so set it explicitly.
images: ghcr.io/monashcoding/monashcoding
tags: |
type=raw,value=latest
type=sha,format=long

- name: Build & push (linux/arm64)
uses: docker/build-push-action@v6
with:
context: .
file: ./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
# NEXT_PUBLIC_* are inlined into the client bundle at build time and
# the Sanity Studio config reads them at module load. They're all
# browser-public values (a Sanity project id/dataset are not secret),
# so configure them as repo *Variables* (Settings → Secrets and
# variables → Actions → Variables). Fallbacks keep the build working
# before you set them.
build-args: |
NEXT_PUBLIC_SANITY_DATASET=${{ vars.NEXT_PUBLIC_SANITY_DATASET || 'production' }}
NEXT_PUBLIC_SANITY_API_VERSION=${{ vars.NEXT_PUBLIC_SANITY_API_VERSION || '2024-01-18' }}
NEXT_PUBLIC_SANITY_PROJECT_ID=${{ vars.NEXT_PUBLIC_SANITY_PROJECT_ID }}

# Tell Dokploy to pull the new image and redeploy. Create the app in
# Dokploy with provider "Docker" pointing at
# ghcr.io/monashcoding/monashcoding: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"
75 changes: 75 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# syntax=docker/dockerfile:1

# ---------------------------------------------------------------------------
# monashcoding.com — self-hosted image (Oracle Cloud + Dokploy).
#
# This is a SINGLE-package Next.js 16 app (not a monorepo like monmap), so the
# Dockerfile is the simple shape: no outputFileTracingRoot / transpilePackages,
# no workspace filter. `output: "standalone"` emits a self-contained server.
#
# `next build` is SSG here — every page sets `revalidate = false` and
# events/[slug] has generateStaticParams() — so the build QUERIES THE SANITY
# API. The runner therefore needs network access plus the public Sanity vars
# below. Those are NEXT_PUBLIC_* (project id + dataset are not secrets), passed
# as --build-args because Next inlines them into the client bundle and the
# Studio config reads them at module load.
#
# Secrets (RESEND_API_KEY, SANITY_WEBHOOK_SECRET) are used only by API routes
# at RUNTIME, never during build — set them in Dokploy, never as build args.
# ---------------------------------------------------------------------------

FROM node:22-slim AS base
# Corepack reads the `packageManager` field in package.json to pin the exact
# npm version, so CI resolves the lockfile the same way it was generated.
# Auto-download it without an interactive prompt.
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable
WORKDIR /app

# ---- deps + build -------------------------------------------------------
FROM base AS build

# The `prepare` script runs husky, which needs a .git dir we don't ship.
# HUSKY=0 makes it a no-op during the image build.
ENV HUSKY=0

# Install against the committed lockfile first (better layer caching).
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

# Now the source.
COPY . .

# NEXT_PUBLIC_* must exist at build time — inlined into the client bundle and
# read by sanity.config.ts. Pass via --build-arg (Dokploy: Build → Build Args).
ARG NEXT_PUBLIC_SANITY_PROJECT_ID
ARG NEXT_PUBLIC_SANITY_DATASET
ARG NEXT_PUBLIC_SANITY_API_VERSION
ENV NEXT_PUBLIC_SANITY_PROJECT_ID=$NEXT_PUBLIC_SANITY_PROJECT_ID \
NEXT_PUBLIC_SANITY_DATASET=$NEXT_PUBLIC_SANITY_DATASET \
NEXT_PUBLIC_SANITY_API_VERSION=$NEXT_PUBLIC_SANITY_API_VERSION \
NEXT_TELEMETRY_DISABLED=1

RUN npm run build

# ---- runtime ------------------------------------------------------------
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0

# Run as a non-root user.
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs

# standalone/ ships server.js + a pruned node_modules. Static assets and
# public/ aren't traced into it, 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

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
139 changes: 139 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Deploying monashcoding.com (Oracle Cloud + Dokploy)

The club's main site runs as a Docker container on an Oracle Cloud VM, fronted
by [Dokploy](https://dokploy.com). It moved off Vercel when we consolidated all
the club's apps onto the one box.

## 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 of those racing on one box
is how you OOM production. So **we never build on the Oracle box**:

```
push to main ──▶ GitHub Actions (ubuntu-24.04-arm)
│ builds linux/arm64 image
GHCR: ghcr.io/monashcoding/monashcoding:latest
│ Dokploy pulls (webhook-triggered)
Oracle VM: `node server.js` (~200–400 MB idle)
```

This is a **single-package Next.js 16 app** (App Router) with `output:
"standalone"`. Unlike monmap it has no workspace/monorepo layout, so the
Dockerfile is the simple shape. Content comes from **Sanity** (hosted CMS) and
the contact form sends via **Resend** (hosted) — there is **no database on the
Oracle box** for this app.

## Build-time vs runtime env

`next build` is **SSG** here: every page sets `revalidate = false` and
`events/[slug]` has `generateStaticParams()`, so the build **queries the Sanity
API**. That means:

- **Build-time (baked into the image, set as GitHub Variables → build-args):**
the public Sanity vars. A Sanity project id + dataset are not secrets — they
ship in the client bundle regardless — so they live in repo *Variables*, not
Secrets.
- `NEXT_PUBLIC_SANITY_PROJECT_ID`
- `NEXT_PUBLIC_SANITY_DATASET` (default `production`)
- `NEXT_PUBLIC_SANITY_API_VERSION` (default `2024-01-18`)
- **Runtime only (set in Dokploy, never build args):** the actual secrets, used
only by API routes.
- `RESEND_API_KEY` — contact form email (`/api/send`, `/api/event-reminder`)
- `SANITY_WEBHOOK_SECRET` — verifies the on-demand revalidate webhook
(`/api/revalidate`)

## One-time GitHub setup

1. **Repo Variables** (Settings → Secrets and variables → Actions → _Variables_).
All browser-public, so Variables not Secrets:
- `NEXT_PUBLIC_SANITY_PROJECT_ID` = _(the Sanity project id)_
- `NEXT_PUBLIC_SANITY_DATASET` = `production`
- `NEXT_PUBLIC_SANITY_API_VERSION` = `2024-01-18`
The workflow has fallbacks for dataset + API version; set the project id or
the build falls back to an empty id and fails (`sanity.config.ts` throws).
2. **Repo Secret**: `DOKPLOY_DEPLOY_WEBHOOK` = the deploy webhook URL Dokploy
generates for the app (added after the Dokploy setup below). Until it exists,
the workflow builds/pushes but skips the redeploy trigger.
3. **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. (The package is named `monashcoding`, not
`monashcoding-site`.)

## One-time Dokploy setup

Dokploy panel: <https://dokploy.monashcoding.com>

1. **Create Application** → Provider: **Docker**.
- Image: `ghcr.io/monashcoding/monashcoding:latest`
- (If you kept the package private: add GHCR registry credentials — a GitHub
PAT with `read:packages`.)
2. **Environment** (runtime vars):
```
RESEND_API_KEY=<resend api key>
SANITY_WEBHOOK_SECRET=<sanity webhook secret>
NEXT_PUBLIC_SANITY_PROJECT_ID=<sanity project id>
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2024-01-18
```
(The `NEXT_PUBLIC_*` are already baked into the image at build; setting them
here too is harmless and keeps server-side reads consistent.)
3. **Port**: container listens on `3000`.
4. **Domains**: serve the apex as canonical, www as a redirect.
- `monashcoding.com` → container port `3000` → enable HTTPS (Let's Encrypt).
- `www.monashcoding.com` → 301 redirect to `monashcoding.com`.
5. **Deploy webhook**: copy the app's deploy webhook URL into the GitHub repo
secret `DOKPLOY_DEPLOY_WEBHOOK` so each pushed image auto-redeploys.

## DNS + cutover (apex domain — do this carefully)

This is the club's highest-visibility site. **Verify the container is healthy
before repointing DNS** — don't swap a working site for a 502:

1. In Dokploy, watch the pull/restart logs until the container is up.
2. From the Oracle box, confirm the app answers on the apex Host header:
```
curl -I -H "Host: monashcoding.com" http://localhost
```
Expect a `200` (or a Next redirect), not a `502`/`connection refused`.
3. Only then touch DNS. On Cloudflare, the apex needs an **A record** (not a
CNAME):
- `A @ <VM public IP>` — **grey-cloud (DNS-only) first** so Dokploy can
complete the Let's Encrypt HTTP-01 challenge and issue the cert.
- `CNAME www monashcoding.com`
- Once the cert is issued and the site loads over HTTPS, **orange-cloud
(proxy)** both records.

## Cutting the cord with Vercel

`vercel.json` (`{ "github": { "enabled": false, "silent": true } }`) stops
Vercel's GitHub integration from running deploy checks on push. For a complete
cut, also:

- **Disconnect the Git integration** in the Vercel dashboard (Project →
Settings → Git → Disconnect).
- **Remove any required "Vercel" status check** in the GitHub branch-protection
rule for `main`, or PRs will hang waiting on a check that no longer runs.

## Deploying a change

Just push to `main`. 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

- Only `NEXT_PUBLIC_*` are baked in at build time (client-bundle values) — hence
build-args. The real secrets are runtime-only.
- The build reaches out to the Sanity API (SSG). If Sanity is unreachable or the
project id is wrong, `next build` fails in CI — that's the fast feedback, not
a broken deploy.
- Content edited in Sanity Studio (`/studio`) shows up via the on-demand
revalidate webhook (`/api/revalidate`, guarded by `SANITY_WEBHOOK_SECRET`).
Point the Sanity webhook at `https://monashcoding.com/api/revalidate`.
3 changes: 3 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { NextConfig } from "next";
import withBundleAnalyzer from "@next/bundle-analyzer";

const nextConfig: NextConfig = {
// Emit a self-contained server bundle (.next/standalone/server.js) so the
// Docker runtime image doesn't need node_modules or the package manager.
output: "standalone",
images: {
remotePatterns: [
{
Expand Down
Loading