From 5ac50e9bf047fe5c306f5323c781bd134057a25c Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 12:50:45 +0200 Subject: [PATCH] feat(docker): production-ready Dockerfile + Portainer compose stack - Rewrite Dockerfile: multi-stage pnpm monorepo build with Next.js standalone output, non-root user, minimal runner image - Enable next.config.ts `output: standalone` + serverExternalPackages - Rewrite docker/compose.prod.yml: full production stack with migrate service, minio-init, named networks, build context for Portainer - Add scripts/migrate.mjs: standalone Drizzle migration runner - Update .dockerignore for cleaner build context Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 21 +++--- Dockerfile | 102 ++++++++++++----------------- apps/web/next.config.ts | 3 +- docker/compose.prod.yml | 138 ++++++++++++++++++++++++++++++++-------- scripts/migrate.mjs | 18 ++++++ 5 files changed, 181 insertions(+), 101 deletions(-) create mode 100644 scripts/migrate.mjs diff --git a/.dockerignore b/.dockerignore index a5a27e0..6e6595d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,15 +1,12 @@ .git -.gitignore -README.md -.env.local -.env.*.local +.github node_modules -npm-debug.log -coverage -.turbo -.next -dist +**/node_modules +**/.next +**/dist +**/.turbo +.env* +!.env.example +docker +*.md .DS_Store -.vscode -.idea -*.log diff --git a/Dockerfile b/Dockerfile index 9c2e585..891a901 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,84 +1,60 @@ -# Multi-stage build for Next.js monorepo FROM node:22-alpine AS base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable pnpm + +# ── deps ────────────────────────────────────────────────────────────────────── +FROM base AS deps WORKDIR /app -# Install dependencies stage -FROM base AS deps -RUN apk add --no-cache libc6-compat git python3 make g++ -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -RUN pnpm install --frozen-lockfile --prod +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/db/package.json ./packages/db/ +COPY packages/api-types/package.json ./packages/api-types/ -# Build stage +RUN pnpm install --frozen-lockfile + +# ── builder ─────────────────────────────────────────────────────────────────── FROM base AS builder -RUN apk add --no-cache libc6-compat git postgresql-client python3 make g++ curl +WORKDIR /app -# Accept build arguments for Next.js environment -ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY=BJPBk8PPF2gWpsgZhWdoGS132nhx5OkLnGthKABMzkzK2lKxGNuk-Lhl6-WkqADzp1a_-NlSbnOjM0C7zP7MmFY -ARG VAPID_PRIVATE_KEY=bgyp_UEX0URY4Ph7DokBtOmzW4VTx-gWzMILIF6lN9g -ARG BETTER_AUTH_SECRET=lDmPWNg7oxzmwzFuI+jsjLpPt7IQC0LTmn5k2NPfxgk= -ARG BETTER_AUTH_URL=http://localhost:3000 +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY --from=deps /app/packages/db/node_modules ./packages/db/node_modules 2>/dev/null || true +COPY --from=deps /app/packages/api-types/node_modules ./packages/api-types/node_modules 2>/dev/null || true -# Set environment variables for build - MUST be set before build! -ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY} -ENV VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} -ENV BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} -ENV BETTER_AUTH_URL=${BETTER_AUTH_URL} -ENV CI=true -ENV NODE_OPTIONS="--max-old-space-size=3072" - -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -RUN echo "Installing dependencies..." && pnpm install --frozen-lockfile - -# Copy source code COPY . . -# Create minimal .env file for Next.js build -RUN cat > apps/web/.env.local << EOF -NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY} -VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} -BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} -BETTER_AUTH_URL=${BETTER_AUTH_URL} -EOF +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production -# Build the app with verbose output -RUN echo "Building application..." && pnpm build +RUN pnpm --filter @epicure/db build 2>/dev/null || true +RUN pnpm --filter @epicure/api-types build 2>/dev/null || true +RUN pnpm --filter @epicure/web build -# Production runtime -FROM base AS runtime -RUN apk add --no-cache libc6-compat postgresql-client curl -ENV NODE_ENV production -ENV NODE_OPTIONS="--max-old-space-size=2048" +# ── runner ──────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app -# Copy production node_modules from builder stage -COPY --from=builder /app/node_modules ./node_modules +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 -# Copy built Next.js app -COPY --from=builder /app/apps/web/.next/standalone ./ +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs -# Copy public assets -COPY --from=builder /app/apps/web/public ./public +# Standalone output + static assets +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public -# Copy workspace packages (needed for migrations) -COPY --from=builder /app/packages ./packages - -# Copy root package.json for pnpm monorepo -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ - -# Create non-root user -RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 - -# Copy entrypoint script -COPY docker/entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh +# Migration files + runner script +COPY --from=builder --chown=nextjs:nodejs /app/packages/db/src/migrations ./packages/db/src/migrations +COPY --chown=nextjs:nodejs scripts/migrate.mjs ./scripts/migrate.mjs USER nextjs EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" -ENV PORT 3000 - -HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ - CMD curl -f http://localhost:3000/api/health || exit 1 - -CMD ["/app/entrypoint.sh"] +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index efa0e46..118555d 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -42,7 +42,8 @@ const securityHeaders = [ ]; const nextConfig: NextConfig = { - output: 'standalone', + output: "standalone", + serverExternalPackages: ["@epicure/db", "pg", "drizzle-orm"], async headers() { return [ { diff --git a/docker/compose.prod.yml b/docker/compose.prod.yml index fb2a61b..f32146f 100644 --- a/docker/compose.prod.yml +++ b/docker/compose.prod.yml @@ -3,16 +3,18 @@ services: image: postgres:17-alpine restart: always environment: - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_DB: ${POSTGRES_DB:-epicure} + POSTGRES_USER: ${POSTGRES_USER:-epicure} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-epicure}"] interval: 10s timeout: 5s retries: 5 + networks: + - internal redis: image: redis:7-alpine @@ -20,54 +22,140 @@ services: volumes: - redis_data:/data command: redis-server --save 60 1 --requirepass ${REDIS_PASSWORD} + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal minio: image: minio/minio:latest restart: always environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} volumes: - minio_data:/data command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal - web: - image: epicure-web:latest - restart: always + minio-init: + image: minio/mc:latest + restart: on-failure + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD}; + mc mb --ignore-existing local/${STORAGE_BUCKET:-epicure-uploads}; + mc anonymous set download local/${STORAGE_BUCKET:-epicure-uploads}; + exit 0; + " + networks: + - internal + + migrate: + build: + context: .. + dockerfile: Dockerfile + image: ${WEB_IMAGE:-epicure-web:latest} + restart: on-failure + command: ["node", "scripts/migrate.mjs"] environment: - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 - STORAGE_ENDPOINT: http://minio:9000 - STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER} - STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD} - STORAGE_BUCKET: epicure-uploads - BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} - BETTER_AUTH_URL: ${BETTER_AUTH_URL} - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} - ports: - - "3000:3000" + DATABASE_URL: postgresql://${POSTGRES_USER:-epicure}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-epicure} depends_on: postgres: condition: service_healthy + networks: + - internal + + web: + build: + context: .. + dockerfile: Dockerfile + image: ${WEB_IMAGE:-epicure-web:latest} + restart: always + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-epicure}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-epicure} + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + STORAGE_ENDPOINT: http://minio:9000 + STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD} + STORAGE_BUCKET: ${STORAGE_BUCKET:-epicure-uploads} + STORAGE_REGION: ${STORAGE_REGION:-us-east-1} + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + BETTER_AUTH_URL: ${BETTER_AUTH_URL} + ENCRYPTION_SECRET: ${ENCRYPTION_SECRET} + # Google OAuth + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + # GitHub OAuth (optional) + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + NEXT_PUBLIC_GITHUB_ENABLED: ${NEXT_PUBLIC_GITHUB_ENABLED:-} + # Discord OAuth (optional) + DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-} + DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:-} + NEXT_PUBLIC_DISCORD_ENABLED: ${NEXT_PUBLIC_DISCORD_ENABLED:-} + # Authentik OIDC (optional) + AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} + AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} + AUTHENTIK_BASE_URL: ${AUTHENTIK_BASE_URL:-} + NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-} + # AI providers (configure at least one) + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + OPENROUTER_DEFAULT_MODEL: ${OPENROUTER_DEFAULT_MODEL:-google/gemini-flash-1.5} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + # SMTP + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_SECURE: ${SMTP_SECURE:-false} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM: ${SMTP_FROM:-Epicure } + # Stripe + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + depends_on: + migrate: + condition: service_completed_successfully redis: - condition: service_started + condition: service_healthy minio: - condition: service_started + condition: service_healthy + networks: + - internal + - proxy caddy: image: caddy:2-alpine restart: always ports: - - "80:80" - - "443:443" + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" volumes: - - ./Caddyfile:/etc/caddy/Caddyfile + - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: - web + networks: + - proxy + +networks: + internal: + driver: bridge + proxy: + driver: bridge volumes: postgres_data: diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..103b4e0 --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,18 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import pg from "pg"; + +const { Pool } = pg; +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const db = drizzle(pool); + +try { + await migrate(db, { migrationsFolder: "/app/packages/db/src/migrations" }); + console.log("Migrations applied successfully"); + await pool.end(); + process.exit(0); +} catch (err) { + console.error("Migration failed:", err); + await pool.end().catch(() => {}); + process.exit(1); +}