diff --git a/.dockerignore b/.dockerignore index e9fe83bf..c7e7afdf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,18 +1,62 @@ +# Environment .env +.env.* +!.env.example -docker-compose.override.yml - -Dockerfile -./**/*/Dockerfile - +# Docker +docker-compose*.yml .dockerignore +# Dependencies (rebuilt in Docker) node_modules -./**/*/node_modules +**/node_modules +# Build outputs (rebuilt in Docker) +**/.next +**/dist +**/out +**/.turbo +**/.cache + +# Git +.git +.gitignore +.gitattributes + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# CI/CD +.github +.husky +.changeset + +# Documentation (not needed in build) +*.md +!packages/db/migrations/** +LICENSE +CONTRIBUTING.md +AGENTS.md +CHANGELOG.md + +# Test files +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx +**/__tests__ +**/coverage + +# Logs pnpm-debug.log -./**/*/pnpm-debug.log +**/pnpm-debug.log -README.md -.next -# .git \ No newline at end of file +# Cloud compose (separate deployment) +cloud/ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index f4afe7b8..c9246517 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,25 +1,23 @@ -ARG NODE_VERSION=20 -ARG APP_DIRNAME=web -ARG PROJECT=@kan/web +# syntax=docker/dockerfile:1.7 -# 1. Alpine image +ARG NODE_VERSION=20 +ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12 + +# ============================================ +# Stage 1: Alpine base with pnpm and turbo +# ============================================ FROM node:${NODE_VERSION}-alpine AS alpine RUN apk update && \ - apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \ - rm -rf /var/cache/apk/* /tmp/* || true + apk add --no-cache libc6-compat && \ + rm -rf /var/cache/apk/* /tmp/* || true && \ + corepack enable && \ + npm install turbo@2.3.1 --global && \ + pnpm config set store-dir ~/.pnpm-store -# Setup pnpm and turbo on the alpine base -FROM alpine AS base -RUN corepack enable -# Replace with the major version installed in your repository. For example: -# RUN npm install turbo@2.1.3 --global -RUN npm install turbo@2.3.1 --global - -RUN pnpm config set store-dir ~/.pnpm-store - -# 2. Prune projects -FROM base AS pruner -ARG PROJECT +# ============================================ +# Stage 2: Prune the monorepo +# ============================================ +FROM alpine AS pruner RUN apk add --no-cache git @@ -35,53 +33,89 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || echo "unknown") && \ echo "$AUTO_VERSION" > /app/AUTO_VERSION -RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker - -# 3. Build the project -FROM base AS builder -ARG PROJECT -ARG APP_VERSION +RUN turbo prune --scope=@kan/web --scope=@kan/db --docker +# ============================================ +# Stage 3: Install dependencies +# ============================================ +FROM alpine AS deps WORKDIR /app COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml COPY --from=pruner /app/out/json/ . -COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION ENV CI=true - RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile - -COPY --from=pruner /app/out/full/ . -# Use provided APP_VERSION or auto-generated from pruner stage -RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \ - NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT} - -# # Copy static files to standalone directory -# RUN mkdir -p apps/web/.next/standalone/.next && \ -# mv apps/web/public apps/web/.next/standalone/ && \ -# mv apps/web/.next/static apps/web/.next/standalone/.next/ - -# 4. Final image - runner stage to run the application -FROM base AS runner -ARG APP_DIRNAME - -# Don't run production as root -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs -USER nextjs +# ============================================ +# Stage 4: Build the web application +# ============================================ +FROM alpine AS builder +ARG APP_VERSION WORKDIR /app +COPY --from=deps /app/ ./ +COPY --from=pruner /app/out/full/ . +COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION + +# Force standalone output for production Docker image +ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true +ENV CI=true + +RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \ + NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web + +# ============================================ +# Stage 5: Migration image (run-once container) +# ============================================ +FROM node:${NODE_VERSION}-alpine AS migrate +WORKDIR /db + +COPY packages/db/drizzle.config.ts ./drizzle.config.ts +COPY packages/db/migrations/ ./migrations/ + +RUN npm init -y && \ + npm install drizzle-kit drizzle-orm pg --save-exact && \ + # Strip unnecessary files from node_modules + find node_modules -type f \( \ + -name '*.d.ts' -o \ + -name '*.d.mts' -o \ + -name '*.d.cts' -o \ + -name '*.map' -o \ + -name '*.md' -o \ + -name '*.txt' -o \ + -name 'LICENSE*' -o \ + -name 'CHANGELOG*' -o \ + -name 'README*' -o \ + -name '.eslint*' -o \ + -name '.prettier*' -o \ + -name 'tsconfig*.json' \ + \) -delete && \ + find node_modules -type d -empty -delete && \ + rm -rf /root/.npm /tmp/* + +CMD ["npx", "drizzle-kit", "migrate"] + +# ============================================ +# Stage 6: Production web image (distroless) +# ============================================ +FROM ${DISTROLESS_NODE_IMAGE} AS web +WORKDIR /app + ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 -COPY --chown=nextjs:nodejs --from=builder /app/ ./ -WORKDIR /app/apps/${APP_DIRNAME} +# Copy the standalone Next.js server +COPY --from=builder /app/apps/web/.next/standalone/ ./ +# Copy static assets and public files +COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/ +COPY --from=builder /app/apps/web/public/ ./apps/web/public/ -ARG PORT=3000 -ENV PORT=${PORT} -EXPOSE ${PORT} +# Copy bootstrap script for runtime env var injection +COPY apps/web/bootstrap.cjs ./bootstrap.cjs -CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"] +EXPOSE 3000 +CMD ["bootstrap.cjs"] diff --git a/apps/web/bootstrap.cjs b/apps/web/bootstrap.cjs new file mode 100644 index 00000000..49ac321b --- /dev/null +++ b/apps/web/bootstrap.cjs @@ -0,0 +1,44 @@ +/** + * Bootstrap script for the distroless production image. + * + * Distroless images have no shell, so this Node.js script handles two tasks + * that would normally be done in an entrypoint.sh: + * + * 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_* + * environment variables. The file was originally created at build time by + * next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the + * env vars are provided at *run* time via docker-compose / docker run. + * + * 2. Start the Next.js standalone server. + */ + +const { writeFileSync, existsSync, mkdirSync } = require("fs"); +const path = require("path"); + +// --------------------------------------------------------------------------- +// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js +// --------------------------------------------------------------------------- + +const publicDir = path.join(__dirname, "apps", "web", "public"); + +if (!existsSync(publicDir)) { + mkdirSync(publicDir, { recursive: true }); +} + +const envVars = {}; +for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith("NEXT_PUBLIC_")) { + envVars[key] = value; + } +} + +writeFileSync( + path.join(publicDir, "__ENV.js"), + `self.__ENV = ${JSON.stringify(envVars)};`, +); + +// --------------------------------------------------------------------------- +// 2. Start the Next.js standalone server +// --------------------------------------------------------------------------- + +require("./apps/web/server.js"); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index ebdf42fe..89a52a49 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -16,6 +16,18 @@ const config = { : undefined, reactStrictMode: true, + /** Exclude build tools and dev-only packages from the standalone output */ + outputFileTracingExcludes: { + "**/*": [ + "@esbuild/**", + "esbuild/**", + "typescript/**", + "webpack/**", + "uglify-js/**", + "terser/**", + ], + }, + /** Enables hot reloading for local packages without a build step */ transpilePackages: [ "@kan/api", @@ -51,8 +63,8 @@ const config = { hostname: "*.googleusercontent.com", }, { - protocol: 'https', - hostname: 'cdn.discordapp.com', + protocol: "https", + hostname: "cdn.discordapp.com", }, ]; diff --git a/docker-compose.yml b/docker-compose.yml index 556d5a9a..0f494c37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,20 @@ services: + migrate: + image: ghcr.io/kanbn/kan-migrate:latest + container_name: ${CONTAINER_NAME:-kan-migrate} + networks: + - kan-network + build: + context: . + dockerfile: ./apps/web/Dockerfile + target: migrate + environment: + - POSTGRES_URL=${POSTGRES_URL} + depends_on: + postgres: + condition: service_healthy + restart: "no" + web: image: ghcr.io/kanbn/kan:latest container_name: ${CONTAINER_NAME:-kan-web} @@ -6,10 +22,10 @@ services: - "${WEB_PORT:-3000}:3000" networks: - kan-network - - dokploy-network build: context: . - dockerfile: ./apps/web/Dockerfile.new + dockerfile: ./apps/web/Dockerfile + target: web env_file: - .env environment: @@ -107,7 +123,8 @@ services: - APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET} - APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER} depends_on: - - postgres + migrate: + condition: service_completed_successfully restart: unless-stopped postgres: @@ -121,14 +138,17 @@ services: - 5432:5432 volumes: - kan_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"] + interval: 5s + timeout: 5s + retries: 10 restart: unless-stopped networks: - kan-network networks: kan-network: - dokploy-network: - external: true volumes: kan_postgres_data: diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index fed3b644..6a117ad7 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -12,15 +12,13 @@ import { configuredProviders } from "./providers"; export const initAuth = (db: dbClient) => { const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL"); - const trustedOrigins = env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []; + const trustedOrigins = + env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",").filter(Boolean) ?? []; return betterAuth({ secret: env("BETTER_AUTH_SECRET"), baseURL, - trustedOrigins: [ - ...(baseURL ? [baseURL] : []), - ...trustedOrigins, - ], + trustedOrigins: [...(baseURL ? [baseURL] : []), ...trustedOrigins], database: drizzleAdapter(db, { provider: "pg", schema: {