diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..338ba8a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,48 @@ +# Docker build context excludes. +# +# WITHOUT this file, the `COPY . .` in the builder stage ships the host's +# .next/ (2.1 GB, almost all of it the Turbopack dev cache) and node_modules/ +# (639 MB, with darwin-arm64 sharp binaries that are wrong for Alpine) into +# the build context. That is what filled the production disk. + +# Dependencies — reinstalled from the lockfile in the deps stage +node_modules +.pnp +.pnp.* +.yarn + +# Build output — regenerated by `npm run build` in the builder stage. +# .next/dev alone is 1.8 GB of dev-server cache that must never leave the host. +.next +out +build +dist + +# VCS + local tooling +.git +.gitignore +.github +.claude +.vscode +.idea + +# Incremental compiler state (253 KB and host-specific) +*.tsbuildinfo +next-env.d.ts + +# Secrets — injected at runtime, never baked into a layer +.env +.env.* +*.pem + +# Docs and infra that the build does not read +*.md +Dockerfile +.dockerignore +nginx.conf + +# Noise +.DS_Store +coverage +npm-debug.log* +yarn-error.log* diff --git a/.gitignore b/.gitignore index 5ef6a52..c5e8b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # production /build +/dist # misc .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 4c56ba4..f97cfcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,34 @@ Gray is the accent. The **only** colour permitted is semantic: success, warning, - `StyleOverrides` supports structural pseudo-classes, not just interaction ones — `':first-child'` emits correctly (used for the table first-column lead-in). +## Deployment — never ship `.next/` wholesale +This filled the production disk and took the server down once. A working tree's `.next/` +reaches **2+ GB**, but almost none of it is runtime state: + +| path | size (typical) | ships? | +|---|---|---| +| `.next/dev` | 1.8 GB | **no** — Turbopack dev-server cache from `npm run dev` | +| `.next/cache` | 120–160 MB | **no** — incremental build cache, build host only | +| `.next/server`, `types`, `trace` | ~24 MB | **no** — inputs to the standalone trace, not read at runtime | +| `.next/standalone` | 54 MB | **yes** — server.js + traced node_modules | +| `.next/static` | 3 MB | **yes** — hashed client assets (nginx serves at `/_next/static/`) | +| `public` | 344 KB | **yes** | + +Those last three are the entire payload: **~55 MB unpacked, 18 MB gzipped.** +- Non-Docker deploy: `npm run bundle` → `dist/loyaly-mer-login.tar.gz`. Never `scp -r .next`. +- Docker: `.dockerignore` excludes `node_modules` and `.next`. **Do not delete it** — without + it `COPY . .` pushes the host's 2 GB `.next` and 639 MB `node_modules` into the build + context, including darwin-arm64 sharp binaries that are wrong for Alpine. +- `npm run clean` drops `.next`, `dist` and the tsbuildinfo when the tree gets heavy. + +**`CI_BUILD=1` in container builds.** Next 16.3 enables `turbopackFileSystemCacheForBuild` +by default; it writes 117 MB to `.next/cache` that only pays off when that directory is +restored between builds. Docker/Dokploy starts from a clean layer every time, so it is +written and never read. The Dockerfile sets `CI_BUILD=1`, which flips the flag off in +`next.config.ts`: **~20% less build CPU** (32s vs 40–43s measured) and `.next` drops +202 MB → 86 MB. Leave it unset locally — repeat `npm run build` there does reuse the cache. +It changes no output: the deployable payload is 58 MB either way. + ## Conventions - Follow the Astryx rules above: no raw `
` for layout, no hardcoded hex/px, component props first then token-backed Tailwind utilities. diff --git a/Dockerfile b/Dockerfile index 41250eb..8360781 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,14 @@ +# syntax=docker/dockerfile:1 + # Stage 1: Install dependencies FROM node:22-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci +# devDeps are required to build (typescript, tailwind, eslint-config-next). +# This whole stage is discarded — none of it reaches the runner. +RUN npm ci --no-audit --no-fund # Stage 2: Build the Next.js application FROM node:22-alpine AS builder @@ -14,11 +18,16 @@ COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production +# Each Docker build starts from a clean layer, so Turbopack's .next/cache is +# written but never restored. Skipping it cuts ~20% of build CPU (the metric +# that matters on a 1-vCPU host) and 116 MB off this layer. +ENV CI_BUILD=1 RUN npm run build # Stage 3: Production runner with Next.js Standalone FROM node:22-alpine AS runner +RUN apk add --no-cache libc6-compat WORKDIR /app ENV NODE_ENV=production @@ -26,10 +35,18 @@ ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -# Copy public static assets and standalone build output -COPY --from=builder /app/public ./public -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static +# Run as a non-root user; nextjs owns nothing it does not need to write. +RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs + +# Copy public static assets and standalone build output. +# These three paths are the ENTIRE runtime payload (~57 MB). Never copy the +# whole .next/ directory here — .next/dev and .next/cache are build-host-only +# and account for ~1.96 GB. +COPY --from=builder --chown=nextjs:nodejs /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 diff --git a/eslint.config.mjs b/eslint.config.mjs index 4fe5a79..0aa8e8b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,8 @@ const eslintConfig = defineConfig([ ".next/**", "out/**", "build/**", + // Deploy artifact from `npm run bundle` — traced vendor code, not ours. + "dist/**", "next-env.d.ts", // Generated by `npm run theme:build` — edit src/theme/loyalyTheme.ts // instead. The emitted .d.ts uses a triple-slash reference we do not own. diff --git a/next.config.ts b/next.config.ts index 7356919..be4da9c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + experimental: { + // Turbopack's build cache lives in .next/cache and only pays off when that + // directory survives between builds. A Docker/Dokploy build starts from a + // clean layer every time, so the cache is written and never read — ~117 MB + // of pure write cost per build. CI_BUILD is set in the Dockerfile only, so + // local `npm run build` keeps its warm cache. + turbopackFileSystemCacheForBuild: process.env.CI_BUILD !== "1", + }, allowedDevOrigins: ["192.168.0.117", "192.168.0.*", "192.168.1.*", "localhost", "127.0.0.1"], images: { remotePatterns: [ diff --git a/package.json b/package.json index 5e8065a..f1e5e81 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build", "start": "next start -p 3100", "lint": "eslint", + "clean": "rm -rf .next dist tsconfig.tsbuildinfo", + "bundle": "bash scripts/bundle.sh", "theme:build": "astryx theme build src/theme/loyalyTheme.ts", "typecheck": "tsc --noEmit", "dev:preview": "next dev" diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/lyts.png b/public/lyts.png deleted file mode 100644 index 983627e..0000000 Binary files a/public/lyts.png and /dev/null differ diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/bundle.sh b/scripts/bundle.sh new file mode 100755 index 0000000..f103472 --- /dev/null +++ b/scripts/bundle.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Produce the deployable artifact for a non-Docker deploy. +# +# Copying .next/ wholesale is what filled the production disk: a working tree's +# .next/ reaches 2+ GB, but only three paths are read at runtime — +# .next/standalone the server + its traced node_modules +# .next/static hashed client assets (served by nginx at /_next/static/) +# public unhashed public assets +# Everything else (.next/cache, .next/dev, .next/server, .next/types) is +# build-host state and must never be shipped. +# +# Usage: npm run bundle -> dist/loyaly-mer-login.tar.gz + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +OUT="dist" +STAGE="$OUT/bundle" + +for p in .next/standalone .next/static public; do + if [ ! -d "$p" ]; then + echo "error: $p missing — run 'npm run build' first" >&2 + exit 1 + fi +done + +rm -rf "$STAGE" +mkdir -p "$STAGE" + +# standalone already contains server.js, package.json and traced node_modules, +# and expects static/ and public/ to sit beside it in the same layout. +cp -R .next/standalone/. "$STAGE/" +mkdir -p "$STAGE/.next" +cp -R .next/static "$STAGE/.next/static" +cp -R public "$STAGE/public" + +TARBALL="$OUT/loyaly-mer-login.tar.gz" +rm -f "$TARBALL" +tar -czf "$TARBALL" -C "$STAGE" . + +echo +echo "bundle: $(du -sh "$STAGE" | cut -f1) ($STAGE)" +echo "tarball: $(du -sh "$TARBALL" | cut -f1) ($TARBALL)" +echo +echo "deploy: scp $TARBALL :/srv/ && tar -xzf loyaly-mer-login.tar.gz -C /srv/app" +echo "run: PORT=3000 HOSTNAME=0.0.0.0 NODE_ENV=production node server.js"