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_modules — pnpm 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 /prod/api/node_modules ./node_modules
COPY /app/apps/api/dist ./dist
COPY /app/apps/api/prisma/generated ./prisma/generated
COPY /app/apps/api/prisma/schema.prisma ./prisma/schema.prisma
COPY /app/apps/api/prisma/migrations ./prisma/migrations
COPY /app/apps/api/prisma/prisma.config.deploy.js ./prisma/prisma.config.deploy.js
# CRITICAL: fonts for PDF generation
COPY /app/apps/api/fonts ./fonts
# CRITICAL: startup script (must have LF line endings)
COPY /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
| File | Why critical |
|---|---|
apps/api/fonts/ | NotoSans font files for PDF generation. Missing = booklets silently fail |
apps/api/start.sh | Runs migrations before starting server. Must have LF line endings |
apps/api/prisma/prisma.config.deploy.js | Prisma 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 /app/apps/web/public ./apps/web/public
COPY /app/apps/web/.next/standalone ./
COPY /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
| Variable | Type | Notes |
|---|---|---|
NEXT_PUBLIC_API_URL | Build arg | Inlined at build time; set in Coolify build settings |
NEXT_PUBLIC_GLITCHTIP_DSN | Build arg | Client-side error tracking; inlined at build time |
API_URL | Runtime env | Server-side only; set in Coolify environment |
BETTER_AUTH_SECRET | Runtime env | Server-side only |
BETTER_AUTH_URL | Runtime env | Server-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.