feat(deploy): gör 0pt körbar utanför Vercel — standalone + bevisad Coolify-image #40

Merged
admin merged 1 commits from feat/coolify-standalone-deployable into main 2026-09-08 20:48:45 +00:00
3 changed files with 204 additions and 45 deletions
+19
View File
@@ -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
+134 -44
View File
@@ -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"]
+51 -1
View File
@@ -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"],
},
}