diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ae77a..3037d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ ## [Unreleased] +- `feat(deploy)`: appen är nu faktiskt byggbar och körbar utanför Vercel. + `next.config.mjs` sätter `output: "standalone"`; Dockerfilen är omskriven + till samma form som `sax3l/siax-io` (som redan körs självhostat på flottan): + fyra steg, `node:22-alpine` (matchar CI:s nodversion, var 24), `libc6-compat`, + icke-root, HEALTHCHECK mot `/api/health`, npm/npx borttaget ur runtime-imagen. + Uppmätt: image 1,33 GB -> 423 MB. `--ignore-workspace` borttaget (dess + motivering var inaktuell sedan P1-21) så containerinstallationen är samma + kommando som CI kör; `--prod=false` tillagt eftersom Coolify sätter + NODE_ENV=production vid byggtid och pnpm då hoppar över devDependencies. +- `fix(images)`: `images.unoptimized: true` borttaget — det var v0-generatorns + standardvärde, inte ett val (`next.config.mjs` var orörd sedan initial commit). + Självhostad bildoptimering kräver att `sharp` faktiskt finns i runtime-imagen: + `next build` spårar den inte in i `.next/standalone`, och + `outputFileTracingIncludes` får Turbopack att panika på pnpms symlänkar. Sharp + levereras därför i ett eget npm-steg i Dockerfile, pinnat till versionen i + `pnpm-lock.yaml`. Verifierat i containern: `/_next/image` svarar 200 med + `image/avif` (1,46 MB PNG -> 6,7 kB). +- `chore(deploy)`: `poweredByHeader: false` (ingen `X-Powered-By: Next.js`). + - fix(security): security headers (CSP/HSTS/X-Frame-Options/etc.) + CI security gates actually enforce (Trivy `--exit-code 1`, gitleaks) instead of advisory-only. Actions pinned to commit SHA. SBOM + license inventory diff --git a/Dockerfile b/Dockerfile index 2746587..982688c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,71 +1,161 @@ # ============================================================================= -# 0pt web console -- Dockerfile for Coolify (SIAX Cloud) +# 0pt web console — Dockerfile för Coolify (SIAX Cloud) # ============================================================================= -# This repo's deployable service is the Next.js 16 app at the repo root -# (package.json `start` == `next start`, liveness at GET /api/health). Unlike -# the tsx backend services elsewhere in the fleet (aud0/n0d/api0), a Next app -# needs a real build step, so this is a multi-stage build: install+build, then -# a slim runtime that runs the built output as the non-root `node` user. +# Referensimplementation: sax3l/siax-io, som redan körs självhostat på flottan +# (Hetzner + Coolify + Traefik). Samma form återanvänds här i stället för att +# uppfinna ett eget mönster: flerstegsbygge, node:22-alpine, libc6-compat för +# sharp, icke-root, HEALTHCHECK, och npm/npx borttaget ur runtime-imagen. # -# node:24-alpine matches the tag already proven in ../aud0 and ../api0. `wget` -# is added to the runtime stage so Coolify's container healthcheck has a binary -# to hit /api/health (bare node:*-alpine ships neither curl nor wget). +# Den deployerbara tjänsten i det här repot är Next.js 16-appen i repo-roten. +# Liveness: GET /api/health (svarar alltid 200 så länge processen lever). +# Readiness: GET /api/ready (503 tills DATABASE_URL pekar på en nåbar Postgres) +# — peka Coolifys readiness-probe dit, INTE HEALTHCHECK nedan. # -# next.config.mjs does NOT set `output: "standalone"` (that would be a source -# change, out of Wave-1 scope), so the runtime stage carries node_modules + the -# .next build output and runs `next start` directly. +# VAD SOM ÄNDRADES MOT FÖREGÅENDE VERSION AV DEN HÄR FILEN, och varför: +# +# 1. next.config.mjs sätter nu `output: "standalone"`. Runtime-steget bär +# därför .next/standalone (server.js + spårade moduler) i stället för hela +# node_modules. Mätt på det här repot: den gamla imagen var 1,33 GB på disk +# / 315 MB komprimerat innehåll. +# 2. node:24-alpine -> node:22-alpine. Båda CI-workflowsen (.gitea/ och +# .github/workflows/ci.yml) kör `next build` på node 22; siax-io kör 22 i +# produktion. Att bygga och köra på samma major som grinden testar tar bort +# en version-skew utan uppsida. +# 3. `--ignore-workspace` borttaget. Kommentaren som motiverade flaggan +# ("pnpm-workspace.yaml ... carries only an `allowBuilds:` block and NO +# `packages:` field") är inaktuell: filen har ett giltigt `packages:`-fält +# och packages/schemas är en riktig workspace-medlem med egen package.json +# sedan BACKLOG P1-21. Utan flaggan blir installationen här BIT FÖR BIT +# samma kommando som CI kör (`pnpm install --frozen-lockfile`). +# 4. `--prod=false` tillagt. Coolify injicerar NODE_ENV=production vid byggtid, +# vilket får pnpm att hoppa över devDependencies (typescript, tailwindcss) +# som `next build` kräver. Samma fallgrop dokumenterad i siax-io:s +# Dockerfile för npm's `--include=dev`. +# 5. Eget `sharp`-steg, se nedan. +# 6. npm/npx strippat ur runtime-imagen (SIO-019-mönstret från siax-io). # ============================================================================= -# ---- build stage ------------------------------------------------------------ -FROM node:24-alpine AS builder +# ---- beroenden --------------------------------------------------------------- +FROM node:22-alpine AS deps WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 -# Enable corepack (pnpm@9 pinned below to match pnpm-lock.yaml lockfileVersion 9.0). -RUN corepack enable +# sharps förbyggda binär och libvips kräver libc-kompatibilitet på Alpine. +RUN apk add --no-cache libc6-compat -# Install deps against the frozen lockfile first for layer caching. -# --ignore-workspace: the repo's pnpm-workspace.yaml is a v0-export artifact that -# carries only an `allowBuilds:` block and NO `packages:` field, which makes pnpm -# abort with "packages field missing or empty". There are no real workspace -# members (only the root package.json), so ignoring the workspace file is -# semantically a no-op and lets the frozen install resolve the root deps cleanly. -COPY package.json pnpm-lock.yaml .npmrc ./ -RUN corepack prepare pnpm@9 --activate && pnpm install --frozen-lockfile --ignore-workspace +RUN corepack enable && corepack prepare pnpm@9 --activate -# Build the app. The lib/* clients (db, stripe, ...) are lazy + env-guarded, so -# `next build` succeeds without any runtime secrets present. -# --ignore-workspace again here: `COPY . .` reintroduces the v0 pnpm-workspace.yaml, -# and `pnpm run` re-parses it (same "packages field missing" abort) unless ignored. -COPY . . -RUN pnpm --ignore-workspace run build +# Manifesten först, för lagercache. pnpm-workspace.yaml OCH +# packages/schemas/package.json måste med: pnpm-lock.yaml har två importers +# (`.` och `packages/schemas`), och --frozen-lockfile vägrar om medlemmens +# manifest saknas på disk. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY packages/schemas/package.json ./packages/schemas/package.json -# ---- runtime stage ---------------------------------------------------------- -FROM node:24-alpine AS runtime +# --prod=false: se punkt 4 ovan. Utan den dör `next build` på saknad typescript +# så fort byggmiljön råkar ha NODE_ENV=production satt. +RUN pnpm install --frozen-lockfile --prod=false + +# ---- sharp (bildoptimering) -------------------------------------------------- +# Nexts bildoptimerare laddar `sharp` med en runtime-`require`, så `next build` +# spårar den INTE in i .next/standalone. Verifierat i det här repot: ett bygge +# med enbart `output: "standalone"` gav ett .next/standalone/node_modules som +# bara innehöll next/, react/ och react-dom/. +# +# `outputFileTracingIncludes` går inte att använda som lösning: globben +# "./node_modules/@img/**/*" får Turbopack att panika på pnpms katalogsymlänkar +# i node_modules/.pnpm ("Is a directory (os error 21)") och bygget faller med +# exit 1. Reproducerat, inte antaget. +# +# Därför ett eget steg med npm i stället för pnpm: npm lägger ett PLATT, +# symlänkfritt node_modules, vilket är precis vad runtime-steget kan kopiera rakt +# av. Versionen läses ur pnpm-lock.yaml så den aldrig kan glida ifrån vad resten +# av bygget löste ut, och steget faller hårt om utläsningen inte ger något. +# Eftersom det körs i en Alpine-image blir det musl-binärerna (@img/*-linuxmusl-*) +# som hamnar där, inte värdmaskinens. +FROM node:22-alpine AS sharp +WORKDIR /sharp +RUN apk add --no-cache libc6-compat +COPY pnpm-lock.yaml ./ +RUN set -eu; \ + SHARP_VERSION="$(sed -n 's/^ sharp@\([0-9][^(:]*\).*/\1/p' pnpm-lock.yaml | sort -u | head -1)"; \ + if [ -z "$SHARP_VERSION" ]; then \ + echo "FEL: hittade ingen sharp-version i pnpm-lock.yaml — vägrar bygga en image vars bildoptimering tyst skulle vara trasig." >&2; \ + exit 1; \ + fi; \ + echo "sharp pinnad från pnpm-lock.yaml: ${SHARP_VERSION}"; \ + npm install --no-save --no-audit --no-fund "sharp@${SHARP_VERSION}"; \ + node -e "require('sharp'); console.log('sharp laddar OK i byggsteget')" + +# ---- bygge ------------------------------------------------------------------- +FROM node:22-alpine AS build WORKDIR /app +RUN apk add --no-cache libc6-compat +RUN corepack enable && corepack prepare pnpm@9 --activate + +# Endast rot-projektets node_modules kopieras. packages/schemas har noll +# beroenden (den paketerar bara JSON Schema-filer som konsumeras via sökväg), +# så pnpm skapar aldrig något packages/schemas/node_modules att kopiera — +# verifierat med `ls` efter en frozen install. +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +# Måste vara production. devDependencies behövs inte som NODE_ENV här — de ligger +# redan i node_modules från deps-steget. NODE_ENV=development skulle dessutom +# dra in Reacts utvecklingsruntime under prerendering (se siax-io:s Dockerfile, +# där det är uppmätt åt båda hållen). +ENV NODE_ENV=production +# Bygget får inte kräva riktiga secrets: lib/db.ts och lib/stripe.ts är lata och +# env-skyddade, och middleware.ts:s startgrind (assertAuthConfigSafe) klagar bara +# om AUTH_ENFORCE uttryckligen sätts till "false" i produktion. Verifierat: hela +# bygget går igenom utan en enda env-variabel satt. +RUN pnpm run build + +# ---- runtime ----------------------------------------------------------------- +FROM node:22-alpine AS runtime +WORKDIR /app + +# `apk upgrade` hämtar Alpines senaste patchade paket (t.ex. libssl3/libcrypto3 — +# bastaggen släpar efter uppströms säkerhetsfixar) utan att röra den pinnade +# Node-versionen. wget behövs för HEALTHCHECK: bara node:*-alpine har varken +# curl eller wget. +RUN apk add --no-cache libc6-compat wget && apk upgrade --no-cache + ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -ENV HOSTNAME=0.0.0.0 ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 -RUN apk add --no-cache wget +# standalone-utdatan kopierar varken public/ eller .next/static — de måste med +# explicit, annars serveras appen utan statiska assets. +COPY --from=build --chown=node:node /app/public ./public +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static -# Built artifacts + resolved dependencies from the build stage. .next is chowned -# so `next start` can write its runtime cache as the non-root user. -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder --chown=node:node /app/.next ./.next -COPY --from=builder /app/public ./public -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/next.config.mjs ./next.config.mjs +# sharp läggs BREDVID standalone-utdatans egna node_modules (next/react/react-dom). +# Docker slår ihop katalogerna, så inget skrivs över. `require('sharp')` från +# next/dist/... löses ut genom att vandra uppåt till /app/node_modules/sharp. +COPY --from=sharp --chown=node:node /sharp/node_modules ./node_modules + +# Bas-imagen node:22-alpine skeppar npms egen globala installation +# (/usr/local/lib/node_modules/npm) med ett eget beroendeträd som regelbundet +# bär kända HIGH/CRITICAL-CVE:er, helt utan koppling till den här appens kod. +# Runtime-steget kör `node server.js` — varken RUN, CMD eller HEALTHCHECK här +# anropar npm eller npx — så trädet kan tas bort i stället för att accepteras. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx /usr/local/share/man/man1/npm* USER node EXPOSE 3000 -HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ +# LIVENESS. /api/health svarar 200 så länge processen kan svara — rätt signal för +# "starta om containern". Peka INTE hit en readiness-probe: /api/ready svarar 503 +# tills DATABASE_URL är satt och Postgres är nåbar, och en HEALTHCHECK mot den +# skulle få Coolify att döda en container som fungerar precis som avsett. +HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/api/health >/dev/null 2>&1 || exit 1 -# Invoke the next binary directly (pnpm/corepack are not set up in this stage). -CMD ["node_modules/.bin/next", "start"] +CMD ["node", "server.js"] diff --git a/next.config.mjs b/next.config.mjs index 4cd9948..1f0d4fd 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,10 +1,60 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + // Självhostad drift i SIAX Cloud (Coolify + Traefik) — ersätter Vercel. + // + // "standalone" får `next build` att packa en fristående Node-server i + // .next/standalone (server.js + endast de spårade modulerna). Runtime-imagen + // behöver då varken pnpm, corepack eller ett fullt node_modules-träd: den kör + // `node server.js`. Det är det som gör att Dockerfilens runtime-steg kan + // sluta släpa med npm/npx och hela dev-beroendeträdet. + // + // Inställningen är ofarlig på Vercel — plattformen ignorerar den — så den kan + // landa före DNS-cutovern utan att röra den nuvarande deployen. Samma mönster + // som sax3l/siax-io, som redan körs självhostat på flottan. + output: "standalone", + + // Skicka inte `X-Powered-By: Next.js`. Ingen funktionell skillnad, tar bara + // bort en gratis versionsupplysning. + poweredByHeader: false, + + // OFÖRÄNDRAD. Pre-existerande skuld i den v0-genererade app/ + components/- + // koden (se package.json's "//scripts"-not och `typecheck:app`). Den + // BLOCKERANDE grinden är `pnpm run typecheck` över src/ + tests/, som inte + // rörs av den här ändringen. Att slå på flaggan är sitt eget arbete. typescript: { ignoreBuildErrors: true, }, + images: { - unoptimized: true, + // `unoptimized: true` togs bort. Det var v0-generatorns standardvärde — + // next.config.mjs hade inte ändrats sedan "Initial commit from v0" + // (`git log -- next.config.mjs`) — alltså varken ett medvetet val eller en + // workaround för Vercels bildloader. Självhostat är rätt lösning att låta + // Next optimera själv. + // + // FÖRUTSÄTTNING, och den är inte gratis: Nexts bildoptimerare laddar + // `sharp` med en runtime-`require`, inte ett statiskt import. `next build` + // spårar därför INTE sharp in i .next/standalone — VERIFIERAT: ett bygge + // med enbart `output: "standalone"` gav ett .next/standalone/node_modules + // som bara innehöll next/, react/ och react-dom/. En container byggd så + // svarar fel på varje /_next/image-begäran trots att sharp finns i + // pnpm-lock.yaml (som optionalDependency till next@16.3.3). + // + // `outputFileTracingIncludes` är INTE lösningen här: globben + // "./node_modules/@img/**/*" får Turbopack att panika + // ("reading file ... @img/sharp-libvips-linux-x64 — Is a directory") när den + // träffar pnpms katalogsymlänkar inne i node_modules/.pnpm. Reproducerat i + // det här arbetet; bygget avslutades med exit 1. + // + // Sharp levereras i stället som ett eget, symlänkfritt npm-steg i + // Dockerfile (`FROM node:22-alpine AS sharp`), pinnat till exakt den version + // pnpm-lock.yaml redan löst ut. Se Dockerfilens kommentar. + // + // Ingen komponent renderar en fjärrbild i dag (`next/image` importeras på + // exakt ett ställe, components/fade-image.tsx; alla bilder ligger i + // public/), så ingen `remotePatterns` behövs ännu. Lägg till en sådan — + // aldrig den föråldrade `domains` — den dagen en extern bildvärd tillkommer. + formats: ["image/avif", "image/webp"], }, }