Skip to content
Merged
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
14 changes: 13 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -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
91 changes: 78 additions & 13 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
22 changes: 22 additions & 0 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -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"]
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ services:
app:
build:
context: .
dockerfile: Dockerfile
dockerfile: Dockerfile.dev
container_name: datashield-app
depends_on:
db:
Expand Down
26 changes: 26 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
10 changes: 10 additions & 0 deletions docker/prisma.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
3 changes: 3 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down