diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 80864a29fd..db9a5acc46 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -86,6 +86,7 @@ jobs: # hebben geen outbound internet (npm/pnpm zouden hangen). DOCKER_BUILDKIT=1 docker build \ --network=host \ + --progress=plain \ --build-arg NODE_OPTIONS="--max-old-space-size=1536" \ --cache-from epicnext-cms:latest \ -t epicnext-cms:latest . @@ -160,18 +161,15 @@ jobs: docker compose -f /var/www/atom-nexst/docker-compose.yml up -d --no-build 2>&1 || true exit 1 - - name: Prune Docker cache + - name: Prune old Docker cache if: always() run: | - # Ruim de self-hosted runner op zodat de Docker daemon niet - # volloopt. Draaiende containers/images (o.a. de net-deployed - # epicnext-cms-app) worden nooit geraakt, alleen ongebruikte - # images, build-cache, volumes en networks verdwijnen. - docker image prune -af || true - docker container prune -f || true - docker volume prune -f || true - docker network prune -f || true - docker builder prune -af --keep-storage=2g || true + # Keep recently used layers and cache mounts for subsequent deploys. + # The age filter preserves the last 72 hours; keep-storage is a + # cleanup target, not a hard limit on recent cache disk usage. + docker builder prune -af --filter "until=72h" --keep-storage=2g || true + # Only old dangling images; application volumes and networks persist. + docker image prune -f --filter "until=168h" || true # ───────────────────────────────────────────── # E2E smoke against the freshly deployed container. diff --git a/Dockerfile b/Dockerfile index 252eb24392..ef30344992 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # ============================================================================== # EpicNext-CMS — Docker image (Node 26.8.1, multi-package-manager, Next.js standalone) # ============================================================================== @@ -19,21 +20,19 @@ RUN npm install -g pnpm@11.25.0 yarn WORKDIR /app -# First copy only the manifests so dependency layers are cached when using pnpm. -# For npm/yarn the full context is copied below before install. -COPY package.json pnpm-workspace.yaml .npmrc ./ - -# Copy the rest of the source (brings in whichever lockfile your project uses). -COPY . . +# Lockfiles and installer settings are the only inputs to the dependency layer. +# The wildcard supports pnpm-lock.yaml, package-lock.json and yarn.lock. +COPY package.json *lock* pnpm-workspace.yaml .npmrc ./ # --- Detect package manager & install dependencies --- # Priority: pnpm > yarn > npm # Build arg lets the user force a manager; otherwise it is auto-detected. ARG PACKAGE_MANAGER= -RUN if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \ +RUN --mount=type=cache,id=epicnext-pnpm,target=/pnpm/store,sharing=locked \ + if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \ echo ">> Using pnpm" && \ - pnpm install --frozen-lockfile --ignore-scripts; \ + pnpm install --frozen-lockfile --ignore-scripts --store-dir=/pnpm/store; \ elif [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \ echo ">> Using yarn" && \ yarn install --frozen-lockfile --ignore-scripts; \ @@ -45,12 +44,16 @@ RUN if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pn npm install --ignore-scripts; \ fi +# Source changes invalidate compilation, but keep the installed dependencies. +COPY . . + # Build the production bundle. # The .env file is loaded ONLY inside this RUN layer (not persisted as ENV, so no # secrets end up in the image) — Next.js needs NEXT_PUBLIC_* + validated build-time # values (HOTEL_NAME, DATABASE_URL, AUTH_SECRET, ...) at build time. ENV NODE_ENV=production -RUN if [ -f .env ]; then set -a && . ./.env && set +a; fi && \ +RUN --mount=type=cache,id=epicnext-next,target=/app/.next/cache,sharing=locked \ + if [ -f .env ]; then set -a && . ./.env && set +a; fi && \ if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \ yarn build; \ elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \ @@ -59,14 +62,8 @@ RUN if [ -f .env ]; then set -a && . ./.env && set +a; fi && \ pnpm build; \ fi -# Prune dev dependencies for the runtime image. -RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \ - yarn install --production --ignore-scripts && rm -rf node_modules/.cache; \ - elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \ - npm prune --production; \ - else \ - pnpm prune --prod; \ - fi +# Standalone output already contains the traced runtime dependencies. +# Pruning builder/node_modules here would not shrink the final image. # --- Runtime stage --- FROM node:26.8.1-bookworm-slim AS runner diff --git a/src/lib/deploy-workflow-contract.test.ts b/src/lib/deploy-workflow-contract.test.ts index a412b46434..f441d59373 100644 --- a/src/lib/deploy-workflow-contract.test.ts +++ b/src/lib/deploy-workflow-contract.test.ts @@ -79,13 +79,15 @@ describe("deploy job", () => { expect(deployJob).toContain('"database":true'); }); - it("prunes old images, containers, volumes, networks and build cache", () => { - expect(deployJob).toContain("docker image prune -af"); - expect(deployJob).toContain("docker container prune -f"); - expect(deployJob).toContain("docker volume prune -f"); - expect(deployJob).toContain("docker network prune -f"); - expect(deployJob).toContain("docker builder prune"); - expect(deployJob).toContain("Prune Docker cache"); + it("preserves recent build cache and avoids pruning application volumes", () => { + expect(deployJob).toContain( + 'docker builder prune -af --filter "until=72h" --keep-storage=2g', + ); + expect(deployJob).toContain('docker image prune -f --filter "until=168h"'); + expect(deployJob).not.toContain("docker volume prune"); + expect(deployJob).not.toContain("docker network prune"); + expect(deployJob).not.toContain("docker container prune"); + expect(deployJob).not.toContain("docker image prune -af"); }); it("does not run pnpm test in deploy", () => { diff --git a/src/lib/docker-build-contract.test.ts b/src/lib/docker-build-contract.test.ts new file mode 100644 index 0000000000..db8d718d81 --- /dev/null +++ b/src/lib/docker-build-contract.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dockerfile = readFileSync("Dockerfile", "utf8"); + +describe("Docker build cache", () => { + it("installs frozen dependencies before copying application source", () => { + const manifests = dockerfile.indexOf( + "COPY package.json *lock* pnpm-workspace.yaml .npmrc ./", + ); + const install = dockerfile.indexOf("pnpm install --frozen-lockfile"); + const source = dockerfile.indexOf("COPY . ."); + expect(manifests).toBeGreaterThan(-1); + expect(install).toBeGreaterThan(manifests); + expect(source).toBeGreaterThan(install); + }); + it("retains the package store and Next compiler cache across source changes", () => { + expect(dockerfile).toContain("id=epicnext-pnpm,target=/pnpm/store"); + expect(dockerfile).toContain("--store-dir=/pnpm/store"); + expect(dockerfile).toContain("id=epicnext-next,target=/app/.next/cache"); + }); + it("ships standalone output without a redundant dependency pruning step", () => { + expect(dockerfile).toContain("/app/.next/standalone ./"); + expect(dockerfile).not.toContain("pnpm prune --prod"); + expect(dockerfile).not.toContain("npm prune --production"); + expect(dockerfile).not.toContain("yarn install --production"); + }); +});