From a2277ee1b358262617c71cf8238dde6e4c3698a7 Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Wed, 8 Jul 2026 22:12:58 +0200 Subject: [PATCH] feat: Docker production setup (Portainer + Traefik) Adds multi-stage Dockerfile (runner + worker targets), docker-compose.prod.yml with Traefik labels, auto-migration on deploy, and next.config.ts standalone output mode. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 49 +++++++++++++ docker-compose.prod.yml | 150 ++++++++++++++++++++++++++++++++++++++++ next.config.ts | 1 + 3 files changed, 200 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.prod.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd4a8a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +FROM node:22-alpine AS base +RUN corepack enable && corepack prepare pnpm@latest --activate + +# ── deps ───────────────────────────────────────────────────────────────────── +FROM base AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +# ── builder ─────────────────────────────────────────────────────────────────── +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Dummy env so Next.js build doesn't fail on missing vars at build time. +# Real values are injected at runtime via docker-compose env. +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/curio +ENV REDIS_URL=redis://localhost:6379 +ENV AUTH_SECRET=build-placeholder +RUN pnpm build + +# ── runner ──────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +CMD ["node", "server.js"] + +# ── worker (BullMQ — needs full source + tsx) ───────────────────────────────── +FROM base AS worker +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NODE_ENV=production +CMD ["node_modules/.bin/tsx", "jobs/bootstrap.ts"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..5573608 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,150 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + restart: unless-stopped + environment: + POSTGRES_DB: curio + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + - ./docker/init.sql:/docker-entrypoint-initdb.d/01_init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + migrate: + build: + context: . + target: worker + command: ["node_modules/.bin/drizzle-kit", "migrate"] + environment: + DATABASE_URL: ${DATABASE_URL} + depends_on: + postgres: + condition: service_healthy + restart: on-failure + + app: + build: + context: . + target: runner + restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + AUTH_SECRET: ${AUTH_SECRET} + AUTH_URL: ${AUTH_URL} + AUTH_GITHUB_ID: ${AUTH_GITHUB_ID:-} + AUTH_GITHUB_SECRET: ${AUTH_GITHUB_SECRET:-} + AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID:-} + AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET:-} + AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER:-} + AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID:-} + AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET:-} + AUTH_OIDC_DISPLAY_NAME: ${AUTH_OIDC_DISPLAY_NAME:-} + LLM_GENERATOR_PROVIDER: ${LLM_GENERATOR_PROVIDER} + LLM_GENERATOR_MODEL: ${LLM_GENERATOR_MODEL} + LLM_GRADER_PROVIDER: ${LLM_GRADER_PROVIDER} + LLM_GRADER_MODEL: ${LLM_GRADER_MODEL} + LLM_CHEAP_GRADER_PROVIDER: ${LLM_CHEAP_GRADER_PROVIDER:-} + LLM_CHEAP_GRADER_MODEL: ${LLM_CHEAP_GRADER_MODEL:-} + LLM_EMBED_PROVIDER: ${LLM_EMBED_PROVIDER:-} + LLM_EMBED_MODEL: ${LLM_EMBED_MODEL:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-} + GROQ_API_KEY: ${GROQ_API_KEY:-} + MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} + XAI_API_KEY: ${XAI_API_KEY:-} + AZURE_API_KEY: ${AZURE_API_KEY:-} + AZURE_OPENAI_RESOURCE_NAME: ${AZURE_OPENAI_RESOURCE_NAME:-} + COHERE_API_KEY: ${COHERE_API_KEY:-} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-} + LANGFUSE_HOST: ${LANGFUSE_HOST:-} + LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY:-} + LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY:-} + UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-} + UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-} + SESSION_TOKEN_BUDGET: ${SESSION_TOKEN_BUDGET:-} + CONTENT_SAFETY_MODE: ${CONTENT_SAFETY_MODE:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + EMAIL_FROM: ${EMAIL_FROM:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_REGION: ${AWS_REGION:-} + labels: + - "traefik.enable=true" + - "traefik.http.routers.curio.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.curio.entrypoints=websecure" + - "traefik.http.routers.curio.tls=true" + - "traefik.http.routers.curio.tls.certresolver=letsencrypt" + - "traefik.http.services.curio.loadbalancer.server.port=3000" + + worker: + build: + context: . + target: worker + restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + LLM_GENERATOR_PROVIDER: ${LLM_GENERATOR_PROVIDER} + LLM_GENERATOR_MODEL: ${LLM_GENERATOR_MODEL} + LLM_GRADER_PROVIDER: ${LLM_GRADER_PROVIDER} + LLM_GRADER_MODEL: ${LLM_GRADER_MODEL} + LLM_CHEAP_GRADER_PROVIDER: ${LLM_CHEAP_GRADER_PROVIDER:-} + LLM_CHEAP_GRADER_MODEL: ${LLM_CHEAP_GRADER_MODEL:-} + LLM_EMBED_PROVIDER: ${LLM_EMBED_PROVIDER:-} + LLM_EMBED_MODEL: ${LLM_EMBED_MODEL:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-} + GROQ_API_KEY: ${GROQ_API_KEY:-} + MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} + XAI_API_KEY: ${XAI_API_KEY:-} + AZURE_API_KEY: ${AZURE_API_KEY:-} + AZURE_OPENAI_RESOURCE_NAME: ${AZURE_OPENAI_RESOURCE_NAME:-} + COHERE_API_KEY: ${COHERE_API_KEY:-} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-} + LANGFUSE_HOST: ${LANGFUSE_HOST:-} + LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY:-} + LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY:-} + SESSION_TOKEN_BUDGET: ${SESSION_TOKEN_BUDGET:-} + CONTENT_SAFETY_MODE: ${CONTENT_SAFETY_MODE:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_REGION: ${AWS_REGION:-} + +volumes: + pgdata: + redisdata: + +networks: + default: + external: true + name: ${TRAEFIK_NETWORK:-traefik} diff --git a/next.config.ts b/next.config.ts index b4944bc..e3aefaf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,7 @@ import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { + output: 'standalone', serverExternalPackages: ['@react-pdf/renderer'], };