diff --git a/CloudronManifest.json b/CloudronManifest.json new file mode 100644 index 0000000..e828a64 --- /dev/null +++ b/CloudronManifest.json @@ -0,0 +1,20 @@ +{ + "manifestVersion": 2, + "id": "com.medlab.communityrule", + "title": "Community Rule", + "author": "MEDLab", + "description": "Community governance and rule-building app", + "version": "0.1.0", + "httpPort": 3000, + "healthCheckPath": "/api/health", + "memoryLimit": 805306368, + "minBoxVersion": "9.0.0", + "addons": { + "postgresql": {}, + "sendmail": {}, + "localstorage": {} + }, + "dockerimage": "git.medlab.host/communityrule/community-rule:0.1.0", + "website": "https://communityrule.info", + "contactEmail": "hello@communityrule.info" +} diff --git a/Dockerfile b/Dockerfile index b9c544c..f0a6d8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ -# Optional production image (Next.js standalone output + Prisma). -# Build: docker build -t community-rule . -# Run: pass DATABASE_URL, SESSION_SECRET, etc. at runtime (see .env.example). +# Production image: Next.js standalone output + Prisma, packaged for Cloudron. +# Build / push: ./scripts/docker-release.sh +# Install: cloudron install (reads CloudronManifest.json from repo root) +# See docs/guides/ops-backend-deploy.md §9. FROM node:20-bookworm-slim AS base WORKDIR /app @@ -9,7 +10,19 @@ ENV NEXT_TELEMETRY_DISABLED=1 FROM base AS deps RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json ./ -RUN npm ci --no-audit --fund=false +# --legacy-peer-deps: tolerates two pre-existing peer-dependency mismatches +# that local `npm install` papers over but container `npm ci` (npm 10.8.x) +# refuses: +# 1. next-intl@3.26.5 declares peer next "^10..^15" while the project is +# on next@16. Upgrading to next-intl@4 supports next 16 cleanly. +# 2. @storybook/addon-interactions@8 vs storybook@10 (devDep only; +# the addon was merged into Storybook 8 core and can be removed). +# Drop this flag in the follow-up that lands next-intl@4 + Storybook +# cleanup together. +# --ignore-scripts: skips the project `postinstall` (`npm rebuild lightningcss +# && prisma generate`). The Prisma schema is not yet present in this stage; +# the builder stage runs `prisma generate` after `COPY . .`. +RUN npm ci --no-audit --fund=false --legacy-peer-deps --ignore-scripts FROM base AS builder RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* @@ -19,17 +32,34 @@ RUN npx prisma generate RUN npm run build FROM base AS runner -RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +# openssl: Prisma engines. gosu: privilege drop in start.sh after chown. +RUN apt-get update -y && apt-get install -y openssl gosu && rm -rf /var/lib/apt/lists/* ENV NODE_ENV=production -RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 nextjs -COPY --from=builder /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -COPY --from=builder /app/prisma ./prisma +# Reuse the `node` user (uid/gid 1000) shipped in node:20-bookworm-slim. +# Cloudron's localstorage addon mounts /app/data with root:root ownership at +# runtime; start.sh chowns it to node:node before dropping privileges. + +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/prisma ./prisma + +# Prisma CLI is in devDependencies and is not included in the Next.js +# standalone output. Copy it explicitly so start.sh can run migrations. +COPY --from=builder --chown=node:node /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder --chown=node:node /app/node_modules/.bin/prisma ./node_modules/.bin/prisma + +# Cloudron's runtime rootfs is read-only except /tmp, /run, /app/data. +# Three marketing routes use ISR (`revalidate`) and write to .next/cache; +# redirect that path to /tmp/next-cache via a baked-in symlink so writes land +# on a writable mount at runtime. +RUN mkdir -p .next && ln -sfn /tmp/next-cache .next/cache + +COPY --chown=node:node scripts/start.sh /start.sh +RUN chmod +x /start.sh -USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] +CMD ["/start.sh"] diff --git a/docs/guides/ops-backend-deploy.md b/docs/guides/ops-backend-deploy.md index 468a354..c8ca56f 100644 --- a/docs/guides/ops-backend-deploy.md +++ b/docs/guides/ops-backend-deploy.md @@ -3,7 +3,8 @@ This doc captures everything needed to deploy the new CommunityRule (Next.js + Postgres) onto MEDLab's Cloudron and replace the legacy LAMP-packaged service at `communityrule.info`. Cloudron admin access -has been granted; remaining open item is the registry decision in §6. +has been granted and the container registry is wired up (§6, §9); the +remaining gates are CR-96 (env bridging) and CR-98 (staging install). > **For a plain-language summary to hand to MEDLab's Cloudron admin, > see [`../relaunch-brief.md`](../relaunch-brief.md).** This doc is the @@ -148,11 +149,21 @@ Product decisions (closed): part of the CR-99 pre-cutover backup. Tracked in [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export). -Infra decision still open: +Infra decision closed: -3. **Container registry** — GHCR (under your personal / org account, - lowest friction), Docker Hub, or MEDLab self-hosted. Tracked in - [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push). +3. **Container registry — Gitea Container Registry on `git.medlab.host`.** + Same host as Cloudron (`193.46.198.90`); container package is set + **public** to sidestep the [same-host docker-login "socket hangup" + bug](https://forum.cloudron.io/topic/14572/private-docker-registry-in-cloudron), + so Cloudron pulls without credentials. Push auth from operator + laptops uses a Gitea personal access token (`read:package` + + `write:package`). Canonical image ref: + `git.medlab.host/communityrule/community-rule:`. Operator + build/push workflow lives in [§9](#9-build-and-push-image-workflow). + Tracked in [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push). + Fallback if same-host pull ever breaks: install the **Cloudron + Container Registry** app and re-tag against its hostname; no other + changes required. ## 7. Old vs new deltas @@ -192,7 +203,10 @@ All filed in Linear, titled `[Backend] …`, assigned to me, in the blockers; can land now. 2. [**CR-97**](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) — `[Backend] Container image registry: choose, build, push`. - Blocked by registry decision (§6.3). + Registry decided (§6.3); packaging + build/push workflow shipped + (§9). Closes after the first verified `docker pull` of the pushed + image (no Cloudron-side install required to close this ticket; + that's CR-98). 3. [**CR-98**](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) — `[Backend] Cloudron staging install + smoke` at `staging.communityrule.info`. Blocked by CR-96 + CR-97. @@ -213,6 +227,90 @@ All filed in Linear, titled `[Backend] …`, assigned to me, in the Count rows + decide whether to publish a static archive before CR-99 uninstalls the legacy MySQL. Priority: Low. +## 9. Build and push image workflow + +The repo is packaged as a Cloudron app via +[`CloudronManifest.json`](../../CloudronManifest.json), +[`Dockerfile`](../../Dockerfile), +[`scripts/start.sh`](../../scripts/start.sh), and +[`scripts/docker-release.sh`](../../scripts/docker-release.sh). The +manifest declares `httpPort 3000`, `healthCheckPath /api/health`, +`memoryLimit 768 MiB`, `minBoxVersion 9.0.0`, and the +`postgresql + sendmail + localstorage` addons. The Dockerfile reuses +the base image's `node` user (uid 1000), installs `gosu` for the +privilege drop, and symlinks `.next/cache → /tmp/next-cache` so +Next.js ISR works on Cloudron's read-only rootfs. `start.sh` runs as +root to chown `/app/data` (localstorage mount), then drops to +`node:node`, applies `prisma migrate deploy`, and execs the Next.js +standalone server. + +### One-time setup (per operator) + +1. **Generate a Gitea PAT.** In Gitea web UI: avatar → Settings → + Applications → Manage Access Tokens → Generate New Token. Check + `read:package` and `write:package`. Save in 1Password. +2. **`docker login git.medlab.host`** with your Gitea username and the + PAT as password. Expect `Login Succeeded`. +3. Confirm you have package-write rights on the `CommunityRule` org + (you do if you can push commits to the repo). + +### Per-release workflow + +1. **Bump the manifest version.** Edit + [`CloudronManifest.json`](../../CloudronManifest.json): + - increment `version` (e.g. `0.1.0` → `0.1.1`) — Cloudron requires + it to **increase** for `cloudron update --image` to be accepted; + - update `dockerimage` to the tag you're about to push (default tag + is the git short SHA). +2. **Run the release script** from the repo root: + + ```bash + ./scripts/docker-release.sh + # or, equivalently: + npm run docker:release + ``` + + Override the tag with `TAG=v0.1.1 ./scripts/docker-release.sh` for + semver releases. The script prints the exact `dockerimage` line to + paste back into the manifest. +3. **First push only:** in Gitea, navigate to the `CommunityRule` org + → Packages → `community-rule` → Settings → set **Visibility: Public**. +4. **Verify the pull works without credentials** (simulates Cloudron's + anonymous pull): + + ```bash + docker logout git.medlab.host + docker pull git.medlab.host/communityrule/community-rule: + ``` + +5. **Commit the manifest change** alongside any code changes that + shipped in this build, so the manifest and image stay in lockstep. + +### Install / update on Cloudron + +From the repo dir on the operator's machine, with `cloudron` CLI +logged in to `cloud.medlab.host`: + +```bash +# First install (staging): +cloudron install --location staging.communityrule.info + +# Subsequent updates: +cloudron update --app +``` + +`cloudron install` reads `dockerimage` from +[`CloudronManifest.json`](../../CloudronManifest.json); no `--image` +flag needed. + +### CI — deferred (stretch goal) + +CR-97 acceptance lists a stretch goal of building and pushing on merge +to `main` via Gitea Actions. Deferred: no hosted runners are available +today, and the manual workflow above is acceptable for v1 staging and +production. Revisit when runners return or when release cadence +justifies the runner cost. + ## 10. Rate limiting (single-instance deploys) The app uses an **in-memory** rate limiter in [`lib/server/rateLimit.ts`](../../lib/server/rateLimit.ts) (magic-link requests, organizer inquiry, etc.). This is sufficient for the current **single Cloudron container** per environment. diff --git a/package.json b/package.json index 5d11fca..57a3113 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "analyze:browser": "BUNDLE_ANALYZE=true npm run build", "bundle:analyze": "node scripts/bundle-analyzer.js", "db:deploy": "prisma migrate deploy", - "migrate:smoke": "./scripts/migrate-smoke-local.sh" + "migrate:smoke": "./scripts/migrate-smoke-local.sh", + "docker:release": "./scripts/docker-release.sh" }, "dependencies": { "@mdx-js/loader": "^3.1.1", diff --git a/scripts/docker-release.sh b/scripts/docker-release.sh new file mode 100755 index 0000000..cd44310 --- /dev/null +++ b/scripts/docker-release.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Build, tag, and push the community-rule image to the Gitea container +# registry on git.medlab.host. See docs/guides/ops-backend-deploy.md §9. +# +# Usage: +# ./scripts/docker-release.sh # tag = git short SHA +# TAG=v0.1.1 ./scripts/docker-release.sh # explicit tag +# +# Builds for linux/amd64 explicitly so the image runs on the Cloudron host +# (x86_64) even when this script runs on an Apple Silicon laptop (aarch64). +# buildx pushes directly to the registry — no intermediate local image. +# +# Prerequisites: +# - docker login git.medlab.host (Gitea PAT with read+write:package) +# - Push permission to the CommunityRule org's packages +# - docker buildx (ships with Docker Desktop) + +set -e + +IMAGE="${IMAGE:-git.medlab.host/communityrule/community-rule}" +TAG="${TAG:-$(git rev-parse --short HEAD)}" +PLATFORM="${PLATFORM:-linux/amd64}" + +docker buildx build \ + --platform "$PLATFORM" \ + --tag "$IMAGE:$TAG" \ + --push \ + . + +echo +echo "Pushed: $IMAGE:$TAG ($PLATFORM)" +echo +echo "Next steps:" +echo " 1. Update CloudronManifest.json 'version' (must increase) and" +echo " 'dockerimage' to:" +echo " \"dockerimage\": \"$IMAGE:$TAG\"" +echo " 2. First install: cloudron install" +echo " Subsequent: cloudron update --app " diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..9d55806 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Container entrypoint for Cloudron. +# Runs as root so we can chown the runtime volume mount, then drops to the +# node user (uid 1000) for the application process. + +set -e + +# Bridge Cloudron's env name to Prisma's expected name so `prisma migrate +# deploy` works before CR-96 lands the in-app DATABASE_URL bridging. +export DATABASE_URL="${DATABASE_URL:-$CLOUDRON_POSTGRESQL_URL}" + +# /app/data is created at runtime by Cloudron's localstorage addon as +# root:root; chown so the node user can write uploads. +chown -R node:node /app/data + +# Next.js ISR cache lives at /app/.next/cache via a symlink baked into the +# Dockerfile. The target on /tmp is writable on Cloudron's read-only rootfs. +mkdir -p /tmp/next-cache +chown -R node:node /tmp/next-cache + +# Drop privileges, apply any pending migrations, then exec the server. +# Inner `exec` ensures SIGTERM from Cloudron reaches node for clean shutdown. +exec gosu node:node sh -c \ + './node_modules/.bin/prisma migrate deploy && exec node server.js'