Container image registry

This commit is contained in:
adilallo
2026-05-23 13:30:34 -06:00
parent c663e051da
commit 2fd20d5b2a
6 changed files with 230 additions and 19 deletions
+20
View File
@@ -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"
}
+42 -12
View File
@@ -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"]
+104 -6
View File
@@ -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:<tag>`. 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:<tag>
```
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 <app-id>
```
`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.
+2 -1
View File
@@ -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",
+38
View File
@@ -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 <app-id>"
+24
View File
@@ -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'