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..07d508b --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,57 @@ +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 + # 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 + + - 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..60a5f5c 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,32 @@ 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). 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 - [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