From a2068becd0b26c981a00c2f374976e320c40038b Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Wed, 2 Sep 2026 15:25:10 +0200 Subject: [PATCH 1/2] feat(docker): add production image and Docker Hub publishing The only image so far was the development one: it bind-mounts the source and runs `next dev`, so it cannot be distributed. This adds a real production image and the workflow that publishes it. The build is multi-stage. Next.js now emits a standalone server bundle, so the runner carries the traced runtime only instead of the full node_modules. It runs as the unprivileged `node` user and exposes a healthcheck against /api/health. Migrations are applied by the entrypoint before the server starts. The Prisma CLI could not simply be copied out of the app dependencies (its config loader pulls packages that the Next.js trace drops), so it gets its own install tree under /opt/prisma, pinned to the lockfile version. Set RUN_MIGRATIONS=false when a separate job owns the schema. The former Dockerfile becomes Dockerfile.dev and compose.yml points at it, so the local stack is unchanged. Verified by building the image and running it against the local database: migrations applied, /api/health returns db up, /login serves 200. --- .dockerignore | 14 ++++- .github/workflows/docker-publish.yml | 54 +++++++++++++++++ Dockerfile | 91 ++++++++++++++++++++++++---- Dockerfile.dev | 22 +++++++ README.md | 23 +++++++ compose.yml | 2 +- docker/entrypoint.sh | 26 ++++++++ docker/prisma.config.ts | 10 +++ next.config.ts | 3 + 9 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 Dockerfile.dev create mode 100755 docker/entrypoint.sh create mode 100644 docker/prisma.config.ts diff --git a/.dockerignore b/.dockerignore index 79fe22e..7106aa3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,18 @@ +.git +.github +.idea +.claude +.superpowers node_modules .next -.git +out +coverage +test-results +playwright-report +backups +docs +e2e *.log +tsconfig.tsbuildinfo .env.local .env*.local diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..3418365 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,54 @@ +name: Docker image + +# Publishes the production image to Docker Hub. Tagged releases produce the +# versioned tags plus `latest`; a manual run publishes `edge` so an image can +# be built from a branch without cutting a release. +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + name: Build and push + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Derive image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ vars.DOCKERHUB_IMAGE || 'whitemuush/datashield' }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + # Add linux/arm64 here once the extra emulated build time is + # acceptable: a QEMU arm64 `next build` roughly triples the run. + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index fdc254a..4169568 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,87 @@ -# Development image: runs `next dev` with the source bind-mounted from the host, -# so a machine only needs Docker, no Node or nvm. Not for production. -FROM node:26-bookworm-slim +# syntax=docker/dockerfile:1 -# Prisma needs openssl at runtime. -RUN apt-get update \ - && apt-get install -y --no-install-recommends openssl \ - && rm -rf /var/lib/apt/lists/* +# Production image for DataShield. Three stages: dependency install, Next.js +# standalone build, then a slim runner carrying only the server output plus the +# Prisma CLI needed to apply migrations on start. +# The development image (bind-mounted source, `next dev`) is Dockerfile.dev, +# which is what compose.yml builds. -WORKDIR /app +ARG NODE_VERSION=26-bookworm-slim -# Install deps inside the image (cached, shadowed from the host by an anonymous -# volume in compose). The prisma schema is needed by the postinstall generate. +FROM node:${NODE_VERSION} AS deps +WORKDIR /app COPY package.json package-lock.json ./ COPY prisma ./prisma -COPY .githooks ./.githooks -RUN npm ci +COPY docker/prisma.config.ts ./prisma.config.ts +# --ignore-scripts skips `prepare` (git hook install, meaningless in an image) +# and the postinstall generate, which is then run explicitly. +RUN npm ci --ignore-scripts \ + && npx prisma generate +# The Prisma CLI is not self-contained (its config loader pulls extra runtime +# packages), so it gets its own tiny install tree instead of being cherry-picked +# out of the app dependencies. Version comes from the lockfile. +FROM node:${NODE_VERSION} AS migrator +WORKDIR /opt/prisma +COPY package.json package-lock.json ./ +RUN PRISMA_VERSION=$(node -p "require('./package-lock.json').packages['node_modules/prisma'].version") \ + && rm package.json package-lock.json \ + && npm init -y > /dev/null \ + # Scripts stay enabled here on purpose: @prisma/engines fetches the schema + # engine binary in its postinstall, and migrate deploy needs it at runtime. + && npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" +COPY docker/prisma.config.ts ./prisma.config.ts + +FROM node:${NODE_VERSION} AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules COPY . . +# Placeholders so the build never fails on a module that reads these at import +# time. Nothing here is baked into the output: every value is re-read from the +# environment at runtime. +ENV DATABASE_URL=postgresql://build@127.0.0.1:5432/build \ + BETTER_AUTH_SECRET=build-time-placeholder-not-a-real-secret \ + BETTER_AUTH_URL=http://localhost:3000 \ + DIRECTORY_ENCRYPTION_KEY=build-time-placeholder-not-a-real-key-32 +RUN npm run build + +FROM node:${NODE_VERSION} AS runner +WORKDIR /app +# openssl is required by the Prisma CLI that applies migrations on start. +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +# Next.js standalone output: server.js plus the traced runtime dependencies. +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +# Migrations run at container start, so the runner needs the schema and the +# migration history alongside the isolated CLI tree. +COPY --from=builder /app/prisma ./prisma +COPY --from=migrator /opt/prisma /opt/prisma +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh + +# The server writes its incremental cache under .next/cache, and the Prisma CLI +# refuses to start unless its engines directory is writable, so both paths are +# handed to the unprivileged user the container runs as. +RUN chmod +x /usr/local/bin/entrypoint.sh \ + && mkdir -p .next/cache \ + && chown -R node:node /app/.next /opt/prisma + +USER node EXPOSE 3000 -CMD ["npm", "run", "dev", "--", "-H", "0.0.0.0"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..fdc254a --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,22 @@ +# Development image: runs `next dev` with the source bind-mounted from the host, +# so a machine only needs Docker, no Node or nvm. Not for production. +FROM node:26-bookworm-slim + +# Prisma needs openssl at runtime. +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install deps inside the image (cached, shadowed from the host by an anonymous +# volume in compose). The prisma schema is needed by the postinstall generate. +COPY package.json package-lock.json ./ +COPY prisma ./prisma +COPY .githooks ./.githooks +RUN npm ci + +COPY . . + +EXPOSE 3000 +CMD ["npm", "run", "dev", "--", "-H", "0.0.0.0"] diff --git a/README.md b/README.md index 95dcb01..ebd4075 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,29 @@ All variables live in `.env.local` (copied from `.env.example`). random values; the rest have working defaults for local development. Set `CRON_SECRET` too if you want the scheduler to run. +## Docker image + +Two images live in this repository. `Dockerfile.dev` is the development one +built by `compose.yml`: it bind-mounts the source and runs `next dev`. +`Dockerfile` is the production one, a multi-stage build that ships the Next.js +standalone server, runs as an unprivileged user and applies pending migrations +on start. + +```bash +docker build -t datashield:local . +docker run --rm -p 3000:3000 --env-file .env.local datashield:local +``` + +The container needs `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` and +`DIRECTORY_ENCRYPTION_KEY`; it refuses to start without a database URL. Set +`RUN_MIGRATIONS=false` when a separate job already applies the schema, for +instance when several replicas start at once. + +Tagged releases are published to Docker Hub by +[`docker-publish.yml`](.github/workflows/docker-publish.yml), which needs the +`DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets. Set the `DOCKERHUB_IMAGE` +repository variable to publish under a different name than the default. + ## Documentation - [Authentication and RBAC](docs/auth.md) diff --git a/compose.yml b/compose.yml index 0c74f90..4e23e1e 100644 --- a/compose.yml +++ b/compose.yml @@ -3,7 +3,7 @@ services: app: build: context: . - dockerfile: Dockerfile + dockerfile: Dockerfile.dev container_name: datashield-app depends_on: db: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..d216688 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Container entrypoint: validate the environment, bring the schema up to date, +# then hand over to the Next.js server (the image CMD). +set -e + +if [ -z "$DATABASE_URL" ]; then + echo "[entrypoint] DATABASE_URL is not set. Refusing to start." >&2 + exit 1 +fi + +if [ "$RUN_MIGRATIONS" = "false" ]; then + echo "[entrypoint] RUN_MIGRATIONS=false, skipping prisma migrate deploy." +else + echo "[entrypoint] Applying database migrations..." + # The CLI lives in its own tree under /opt/prisma with its own config file, + # so it has to run from there. The subshell keeps the app's working directory + # untouched. The entry path is read from the manifest so it survives a CLI + # release that moves its bundle. + ( + cd /opt/prisma + PRISMA_ENTRY=$(node -p "const b = require('./node_modules/prisma/package.json').bin; typeof b === 'string' ? b : b.prisma") + node "node_modules/prisma/$PRISMA_ENTRY" migrate deploy --schema /app/prisma/schema.prisma + ) +fi + +exec "$@" diff --git a/docker/prisma.config.ts b/docker/prisma.config.ts new file mode 100644 index 0000000..0653d1c --- /dev/null +++ b/docker/prisma.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "prisma/config" + +// Prisma config shipped inside the production image. Same as the repository +// prisma.config.ts minus the dotenv load: the container has no .env.local and +// the database URL always comes from the runtime environment. +export default defineConfig({ + datasource: { + url: process.env.DATABASE_URL, + }, +}) diff --git a/next.config.ts b/next.config.ts index e2bbdf3..ce38e84 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,6 +16,9 @@ const securityHeaders = [ ]; const nextConfig: NextConfig = { + // Emit a self-contained server bundle (.next/standalone) so the production + // Docker image can ship without node_modules. See Dockerfile. + output: "standalone", serverExternalPackages: ["pg", "@prisma/adapter-pg"], // Next 16.3 makes `next dev` append its own block to AGENTS.md on every run. // The block ships a non-ASCII character, which the pre-push ASCII gate From df40bdc9c92502e7ed693048f0967079b4c88b79 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Wed, 2 Sep 2026 15:34:42 +0200 Subject: [PATCH 2/2] ci(docker): gate the publish job behind a protected environment Without a gate, anyone able to push a `v*` tag triggers an immediate public image push. The job now runs in the `dockerhub` environment, which requires a manual approval and is restricted to `main`, `develop` and release tags. The Docker Hub credentials move to that environment as well. --- .github/workflows/docker-publish.yml | 3 +++ README.md | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3418365..07d508b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,6 +16,9 @@ jobs: publish: name: Build and push runs-on: ubuntu-latest + # Gated environment: the job waits for a manual approval before anything is + # pushed publicly, and it is where the Docker Hub credentials live. + environment: dockerhub timeout-minutes: 45 steps: - uses: actions/checkout@v7 diff --git a/README.md b/README.md index ebd4075..60a5f5c 100644 --- a/README.md +++ b/README.md @@ -197,9 +197,12 @@ The container needs `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` and instance when several replicas start at once. Tagged releases are published to Docker Hub by -[`docker-publish.yml`](.github/workflows/docker-publish.yml), which needs the -`DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets. Set the `DOCKERHUB_IMAGE` -repository variable to publish under a different name than the default. +[`docker-publish.yml`](.github/workflows/docker-publish.yml). The job runs in +the protected `dockerhub` environment, so a publication waits for a manual +approval and only runs from `main`, `develop` or a `v*.*.*` tag. The +`DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets belong to that environment. +Set the `DOCKERHUB_IMAGE` repository variable to publish under a different name +than the default. ## Documentation