Skip to main content

Docker

Both apps use multi-stage Dockerfiles on node:20-alpine. Both run as non-root users.

API Dockerfile (apps/api/Dockerfile)

Stage 1: builder

FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm

# Copy monorepo root files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY turbo.json ./
COPY packages ./packages
COPY apps/api ./apps/api

# Install all deps (including dev)
RUN pnpm install --frozen-lockfile

# Generate Prisma client
RUN pnpm --filter api exec prisma generate

# Build the API
RUN pnpm turbo build --filter=api

# Create a clean production dependency tree
RUN pnpm deploy --filter=api --prod /prod/api --legacy

pnpm deploy --legacy — required because the monorepo uses a shared workspace lockfile. --legacy tells pnpm to use the workspace lockfile resolution. Without --legacy, pnpm may fail with lockfile compatibility errors.

pnpm deploy vs copying node_modulespnpm deploy produces a clean production tree without dev dependencies, resolving the pnpm virtual store correctly into a standard node_modules layout that the runner stage can use.

Stage 2: runner

FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app

# Create non-root user
RUN addgroup -S nodejs && adduser -S nestjs -G nodejs

# Copy from builder
COPY --from=builder /prod/api/node_modules ./node_modules
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/prisma/generated ./prisma/generated
COPY --from=builder /app/apps/api/prisma/schema.prisma ./prisma/schema.prisma
COPY --from=builder /app/apps/api/prisma/migrations ./prisma/migrations
COPY --from=builder /app/apps/api/prisma/prisma.config.deploy.js ./prisma/prisma.config.deploy.js

# CRITICAL: fonts for PDF generation
COPY --from=builder /app/apps/api/fonts ./fonts

# CRITICAL: startup script (must have LF line endings)
COPY --from=builder /app/apps/api/start.sh ./start.sh
RUN chmod +x ./start.sh

# Upload directory
RUN mkdir -p /app/uploads && chown nestjs:nodejs /app/uploads

USER nestjs
EXPOSE 3001
CMD ["./start.sh"]

Critical Files

FileWhy critical
apps/api/fonts/NotoSans font files for PDF generation. Missing = booklets silently fail
apps/api/start.shRuns migrations before starting server. Must have LF line endings
apps/api/prisma/prisma.config.deploy.jsPrisma v7 migration config with absolute paths
apps/api/prisma/migrations/All migration files for migrate deploy

start.sh Line Ending Requirement

start.sh must have Unix LF line endings (\n), not Windows CRLF (\r\n). Alpine's sh cannot execute scripts with CRLF endings — it throws:

/bin/sh: ./start.sh: not found

(This error message is misleading — the file exists but sh can't parse it due to \r in the shebang line.)

Fix if corrupted:

# In the repo
sed -i 's/\r$//' apps/api/start.sh
# Or configure git
git config core.autocrlf input

Set .gitattributes to force LF for shell scripts:

apps/api/start.sh text eol=lf

Web Dockerfile (apps/web/Dockerfile)

Stage 1: builder

FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm

COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY turbo.json ./
COPY packages ./packages
COPY apps/web ./apps/web

# NEXT_PUBLIC_API_URL must be set at build time
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

RUN pnpm install --frozen-lockfile
RUN pnpm turbo build --filter=web

NEXT_PUBLIC_* vars at build time — Next.js inlines NEXT_PUBLIC_* variables at build time. NEXT_PUBLIC_API_URL must be passed as a Docker build arg (set in Coolify build environment).

Stage 2: runner

FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app

# Install curl for health check
RUN apk add --no-cache curl

RUN addgroup -S nodejs && adduser -S nextjs -G nodejs

COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static

USER nextjs
ENV HOSTNAME="0.0.0.0"
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

Next.js Standalone Output

next.config.ts sets output: 'standalone'. This bundles only the files needed to run the app (no dev dependencies, no full node_modules). The standalone output includes a server.js entry point.

The path for server.js is apps/web/server.js because the monorepo base directory is / and Next.js generates the standalone at the app's location within the workspace.


Build Args vs Runtime Env Vars

VariableTypeNotes
NEXT_PUBLIC_API_URLBuild argInlined at build time; set in Coolify build settings
NEXT_PUBLIC_GLITCHTIP_DSNBuild argClient-side error tracking; inlined at build time
API_URLRuntime envServer-side only; set in Coolify environment
BETTER_AUTH_SECRETRuntime envServer-side only
BETTER_AUTH_URLRuntime envServer-side only

All runtime env vars are set in Coolify UI — not in the Dockerfile.


Local Development

For local development, Docker is not used. Run directly:

# From monorepo root
pnpm dev # starts both API (port 3001) and web (port 3000) via Turborepo

# Or individually
pnpm --filter api dev
pnpm --filter web dev

No .env file is committed. Create apps/api/.env and apps/web/.env.local from the .env.example files.