Self-hosting af Next.js 15 med Docker: Komplet Produktionsguide (2026)

Praktisk gennemgang af self-hosting af Next.js 15 med Docker: standalone output, multi-stage Dockerfile, sharp til next/image, Nginx-proxy for streaming, Redis-baseret ISR-cache og de faldgruber der får de fleste deploys til at fejle.

Opdateret: 16. august 2026

Ja, du kan self-hoste Next.js 15 med Docker ved at aktivere output: 'standalone' i next.config.mjs, bygge et multi-stage image baseret på node:20-alpine og eksponere port 3000 bag en reverse proxy som Nginx eller Caddy. Denne guide viser hele produktionsopsætningen, fra Dockerfile og .dockerignore til next/image, ISR-caching, health checks og de faldgruber, der får de fleste self-hosting-forsøg til at fejle. I mit sidste projekt reducerede standalone output image-størrelsen fra 1,2 GB til ca. 200 MB (og gjorde det pludselig realistisk at deploye på en 5-dollar VPS).

  • output: 'standalone' pakker kun runtime-afhængigheder og reducerer image-størrelse med 70–97 % sammenlignet med at kopiere hele node_modules.
  • Et 3-stage Dockerfile (deps → builder → runner) med node:20-alpine lander typisk på 180–250 MB og starter på under 2 sekunder.
  • next/image kræver sharp i runner-stagen samt libc6-compat, ellers falder billedoptimering tilbage til uoptimeret output.
  • ISR-cache skal persistere via en volume-mount eller en delt CacheHandler (Redis), ellers mistes revalideret indhold ved hver redeploy.
  • Nginx skal have proxy_buffering off, hvis du bruger Suspense og streaming, ellers ser brugerne først indhold, når hele HTML er færdig.
  • Self-hosting koster typisk 10–30 USD/måned mod Vercels 20–100+ USD/måned ved moderat trafik, mod ekstra ansvar for updates, monitoring og skalering.

Kan man self-hoste Next.js?

Kort svar: ja. Next.js 15 er fuldt understøttet uden Vercel. Alt hvad frameworket eksekverer på serveren (App Router, Server Components, Server Actions, Route Handlers, ISR, streaming, middleware) kører i en almindelig Node.js-proces. Vercels værdi ligger i, at de har automatiseret de operationelle beslutninger som output-mode, delt cache, edge-distribution, invalidering og monitoring, men de er ikke tekniske forudsætninger. Ifølge de officielle Next.js deployment-docs er self-hosting en førsteklasses target sideløbende med serverless-adaptere.

Hvorfor så self-hoste overhovedet? De reelle drivere er data-residens (GDPR, sundhedsdata), omkostninger ved høj trafik, at co-lokere frontenden med interne API'er inde bag en firewall, eller at genbruge en eksisterende Kubernetes-flåde. Ulempen er, at du selv står for TLS-fornyelse, security patches, load balancing, image-optimering og cache-invalidering. For hobby- eller mellemstore SaaS-produkter er Docker den korteste vej mellem "det virker på min laptop" og "det kører hos en betalende kunde", uden at skulle lære Kubernetes først.

Edge Runtime er den eneste feature, der ikke kører 1:1 i en Node.js-container. Hvis din middleware bruger Edge Runtime til rutebeskyttelse, kører den i stedet på Node.js runtime i containeren, funktionelt identisk, blot uden edge-latency. Du taber altså den globale distribution, men beholder API'en.

Aktivér standalone output

Standalone output er den enkeltbeslutning, der har størst effekt på et self-hostet Next.js-image. Den tvinger buildet til at trace hver import og hver dynamisk require, og kopierer kun de filer, serveren faktisk bruger, ind i .next/standalone/. Din runtime-container behøver ikke at installere node_modules overhovedet.

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  // Aktivér kun de eksperimentelle features du har brug for
  experimental: {
    // ppr: 'incremental', // Partial Prerendering
  },
  images: {
    // Whitelist eksterne billeddomæner her
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.example.com' },
    ],
  },
};

export default nextConfig;

Efter et next build med denne konfiguration får du tre relevante mapper: .next/standalone/ (serveren + traced node_modules), .next/static/ (hashed statiske assets) og public/ (statiske filer du selv har lagt ind). Standalone kopierer ikke public/ eller .next/static/. De skal manuelt tilføjes i Dockerfile'en. Det er en klassisk begynderfejl at glemme dette; symptomet er 404 på alle billeder og en Next.js-app uden CSS. Jeg ramte selv præcis denne fejl første gang jeg deployede med standalone, og brugte en time på at debugge, før jeg opdagede den manglende COPY-linje.

Produktionsklar Dockerfile: 3-stage build

Multi-stage builds er ikke valgfrit. En naiv en-stage Dockerfile med devDependencies, byggecache og source maps ender på 1–2 GB. Det tre-lags mønster nedenfor lander konsekvent på 180–250 MB og er den anbefalede opskrift i de officielle Docker-guides for Next.js.

# syntax=docker/dockerfile:1.7

# --- Stage 1: Dependencies ---
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Kopier lockfiler først for at maksimere layer-cache
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev --ignore-scripts

# --- Stage 2: Builder ---
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1

# Kopier alle deps (inkl. dev) for at kunne bygge
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts

COPY . .
# Byg med standalone output aktiveret i next.config
RUN npm run build

# --- Stage 3: Runner ---
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

RUN apk add --no-cache libc6-compat \
  && addgroup --system --gid 1001 nodejs \
  && adduser --system --uid 1001 nextjs

# Kopier public og standalone med korrekt ejerskab
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

# server.js er entry point genereret af standalone output
CMD ["node", "server.js"]

Tre detaljer er ikke til forhandling. For det første HOSTNAME=0.0.0.0: uden dette lytter server.js kun på loopback, og containeren svarer ikke udenfor. For det andet libc6-compat: Alpine bruger musl, mens sharp og swc kræver glibc-symboler; uden pakken crasher SWC ved boot. Og til sidst --chown=nextjs:nodejs. Kopiering under root efterlader filer ejet af root, og non-root brugeren kan så ikke skrive til .next/cache/, hvilket bryder ISR.

.dockerignore og image-størrelse

En .dockerignore-fil er det billigste værktøj til at reducere buildtid og image-størrelse. Docker sender hele buildkonteksten til daemonen før første COPY, og en manglende ignore-fil betyder, at 500 MB node_modules og din .next-cache pakkes med hver gang. Buildtiden alene kan halveres.

# .dockerignore
node_modules
.next
.git
.gitignore
.env
.env.local
.env*.local
Dockerfile
.dockerignore
README.md
*.md
.vscode
.idea
coverage
.turbo
.vercel
npm-debug.log*
yarn-debug.log*

Vær særligt opmærksom på .env*.local. Uden denne linje risikerer du at bage produktionshemmeligheder ind i imaget, og image-lag er læsbare for alle med docker history. Reglen er enkel: hemmeligheder tilføres kun via runtime-environment (Docker Compose env_file, Kubernetes Secrets, Systemd EnvironmentFile), aldrig via COPY.

Efter build kan du verificere resultatet med docker images | grep myapp. Alt over 300 MB antyder, at enten output: 'standalone' mangler, eller at devDependencies er lækket ind i runner-stagen. Kør docker history --no-trunc <image> for at se hvilket lag der skyldes bloatet.

Environment variables ved build vs runtime

Det her er den mest almindelige kilde til produktionsfejl ved self-hostet Next.js. Frameworket har to helt forskellige typer environment variables, og de opløses på forskellige tidspunkter:

  • NEXT_PUBLIC_* indlejres i client bundlen på next build-tidspunktet. De kan ikke ændres uden at genbygge imaget.
  • Alle andre variabler læses af Node.js på boot og kan ændres frit mellem deploys af samme image.

Konsekvensen er, at et build-argument som NEXT_PUBLIC_API_URL skal sendes ind ved docker build, ikke ved docker run:

# Dockerfile (builder-stagen)
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
RUN npm run build
# Build-kommando
docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
  -t myapp:latest .

# Runtime-hemmeligheder ved docker run
docker run -d \
  -e DATABASE_URL="postgres://..." \
  -e AUTH_SECRET="..." \
  -p 3000:3000 \
  myapp:latest

Advarsel: hvis dit CI/CD-system bygger ét image og promoverer det gennem dev → staging → prod, kan du ikke have miljø-specifikke NEXT_PUBLIC_*-værdier med denne opskrift. Løsningen er enten at bygge separate images per miljø, eller at bruge en runtime-substitutionsstrategi (som @next/env med __NEXT_PUBLIC_*-placeholders erstattet ved container-start). Ærligt talt: byg separate images per miljø, medmindre du absolut har brug for pipeline-promotion. Runtime-substitution er en fejlkilde, du ikke vil debugge kl. 02.

Fungerer next/image i Docker?

Ja, men kun hvis du installerer sharp i runner-stagen. Next.js bruger sharp til at generere WebP/AVIF-varianter on-demand. Uden sharp falder next/image-optimering tilbage til at levere det oprindelige uoptimerede billede, Lighthouse-scoren dropper mærkbart, og båndbredden stiger med 3–5x.

# I runner-stagen
RUN apk add --no-cache libc6-compat vips-dev
# sharp inkluderes typisk via standalone tracing hvis det er i dependencies,
# men installer eksplicit hvis image-optimering ikke virker:
# RUN npm install --no-save sharp

Alternativt kan du deaktivere runtime image-optimering helt ved at sætte unoptimized: true i next.config.mjs og lade en CDN eller en dedikeret image-tjeneste (Cloudinary, imgproxy) klare optimeringen. Det er ofte en klogere arkitektur ved højtrafik-sites. Sharp bruger CPU på hver request, og en Node.js-container skalerer dårligere end en tunet image-proxy.

For statiske assets under public/ er den bedste strategi at lade Nginx serve dem direkte forbi Next.js-serveren. Det sparer round-trips gennem Node.js og lader Nginx sætte lange cache-headers uden fortolkning i frameworket.

Docker Compose med PostgreSQL og Redis

Til lokal produktions-simulering og til single-node deploys på en VPS er Docker Compose det korteste svar. Nedenstående compose.yml stiller en fuld stack op: Next.js + PostgreSQL + Redis + Nginx.

# compose.yml
services:
  web:
    build:
      context: .
      args:
        NEXT_PUBLIC_API_URL: https://example.com
    restart: unless-stopped
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_started }
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
      REDIS_URL: redis://redis:6379
      AUTH_SECRET: ${AUTH_SECRET}
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redisdata:/data

  proxy:
    image: nginx:alpine
    restart: unless-stopped
    depends_on: [web]
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro

volumes:
  pgdata:
  redisdata:

Læg mærke til depends_on med condition: service_healthy. Uden det startes web-containeren, før PostgreSQL kan modtage forbindelser, og din Drizzle ORM-forbindelse crasher ved boot. Health checks er ikke pynt. De er det, der gør en stack forudsigelig ved reboot.

Nginx reverse proxy og streaming

Next.js 15's default rendering-model streamer HTML fra serveren, når du bruger Suspense boundaries eller React Server Components med langsomme data-fetches. Standard-Nginx buffrer responses, hvilket betyder, at brugeren først ser noget, når hele svaret er færdigt, og dermed neutraliserer streaming-fordelen fuldstændigt. Jeg brugte selv et par timer på at debugge dette, før jeg indså at min Nginx-config aktivt gjorde min RSC-optimering usynlig for brugerne.

# nginx.conf (uddrag)
server {
  listen 443 ssl http2;
  server_name example.com;

  ssl_certificate     /etc/nginx/certs/fullchain.pem;
  ssl_certificate_key /etc/nginx/certs/privkey.pem;

  # Server statiske assets direkte forbi Next.js
  location /_next/static/ {
    alias /var/www/static/;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  location / {
    proxy_pass http://web:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # KRITISK for React streaming og Suspense
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header X-Accel-Buffering no;
    proxy_read_timeout 300s;
  }
}

De tre proxy_buffering off-linjer er obligatoriske, hvis du bruger streaming og Suspense i App Router. Uden dem er hele investeringen i React Server Components spildt fra brugerens perspektiv. Caddy har samme adfærd som default og kræver ingen konfiguration. Det er et argument for Caddy over Nginx, hvis du starter fra scratch.

Health checks og non-root user

En produktions-container skal have både en HEALTHCHECK og en dedikeret ikke-privilegeret bruger. Docker og Kubernetes bruger health checks til at afgøre, om en container skal genstartes eller trækkes ud af load balanceren. Non-root er en fundamental security-baseline: hvis en angriber får RCE i din Node.js-proces, er skaden markant større, hvis processen kører som root.

// app/api/health/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

export async function GET() {
  try {
    // Verificer database-forbindelse
    await db.execute('SELECT 1');
    return NextResponse.json({
      status: 'ok',
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    return NextResponse.json(
      { status: 'error', error: String(error) },
      { status: 503 },
    );
  }
}

Bemærk export const dynamic = 'force-dynamic'. Uden dette forsøger Next.js at prerender endpointet ved build og cache resultatet. Health checks skal altid ramme live-tilstanden.

For graceful shutdown skal Node.js modtage SIGTERM korrekt. Alpine-baserede images kan have problemer med PID 1 signal-forwarding; brug enten tini (RUN apk add --no-cache tini og ENTRYPOINT ["/sbin/tini", "--"]) eller sørg for at køre Node direkte som PID 1 uden shell-wrapper. Uden dette får du hængende containere ved docker stop og går glip af den 30-sekunders grace period Kubernetes tildeler.

ISR og cache-persistering på tværs af containere

Incremental Static Regeneration er en af Next.js' mest værdifulde features, men den er også den mest fragile ved self-hosting. Default gemmes ISR-cachen på lokalt filsystem i .next/cache/. Det betyder to problemer: (1) cachen tabes ved hver docker restart eller redeploy, og (2) hvis du kører flere containere bag en load balancer, har hver container sin egen cache, og revalidering i én container ses ikke af de andre.

Den mest holdbare løsning er en custom CacheHandler med Redis som backend:

// next.config.mjs
const nextConfig = {
  output: 'standalone',
  cacheHandler: require.resolve('./cache-handler.mjs'),
  cacheMaxMemorySize: 0, // Deaktivér in-memory cache når vi bruger Redis
};

// cache-handler.mjs (skitse)
import { createClient } from 'redis';

const client = createClient({ url: process.env.REDIS_URL });
await client.connect();

export default class RedisCacheHandler {
  async get(key) {
    const data = await client.get(key);
    return data ? JSON.parse(data) : null;
  }
  async set(key, data, ctx) {
    await client.set(key, JSON.stringify({ value: data, lastModified: Date.now() }));
    if (ctx?.tags) {
      for (const tag of ctx.tags) {
        await client.sAdd(`tag:${tag}`, key);
      }
    }
  }
  async revalidateTag(tag) {
    const keys = await client.sMembers(`tag:${tag}`);
    if (keys.length) await client.del(keys);
    await client.del(`tag:${tag}`);
  }
}

Denne opsætning gør, at revalidatePath() og revalidateTag() virker konsistent på tværs af containere. Se vores dybere gennemgang af use cache-direktivet og moderne caching for hvordan det spiller sammen med App Routers cache-tags. Alternativet, en delt volume mellem containere, virker på én maskine, men fejler så snart du skalerer horisontalt.

Self-hosting vs Vercel: pris og trade-offs

Beslutningen er sjældent teknisk, men økonomisk og organisatorisk. Nedenstående tabel opsummerer de dimensioner, teams typisk vejer:

DimensionSelf-hosting (Docker)Vercel
Månedlige omkostninger (moderat trafik)10–30 USD (VPS)20–100+ USD (Pro-plan)
Time-to-first-deploy2–6 timer setup< 5 minutter
Global edge-distributionManuel (CDN foran Nginx)Inkluderet
ISR og cache-invalideringKræver Redis + CacheHandlerAutomatisk
TLS-håndteringCertbot / Caddy autoAutomatisk
Preview deployments per PRKræver CI/CD-pipelineAutomatisk
Data-residens (EU-only)Fuld kontrolKræver Enterprise-plan
Skalering ved trafikspidserManuel (horizontal pod autoscaling)Automatisk serverless
Operationelt ansvarHøjt (patches, monitoring, backups)Lavt

Tommelfingerregel: hvis månedlig regning på Vercel overstiger prisen på 4 timers DevOps-arbejde, begynder self-hosting at give økonomisk mening. Under den grænse er tiden bedre brugt på produktet. Compliance-krav og eksisterende infrastruktur kan tippe balancen uafhængigt af pris.

Almindelige Docker-deployment fejl

Fejl: "Cannot find module 'server.js'"

Du glemte output: 'standalone' i next.config.mjs. Uden det genereres server.js ikke. Verificer med ls .next/standalone/ efter build.

Fejl: Applikationen svarer ikke fra host, kun fra container

Manglende ENV HOSTNAME=0.0.0.0. Uden dette binder Next.js kun til localhost inde i containeren, og port-mapping virker ikke.

Fejl: 404 på alle statiske assets og billeder

Du glemte at kopiere .next/static og public/ ind i runner-stagen. Standalone kopierer dem ikke automatisk.

Fejl: "sharp module not found" ved image-optimering

Sharp blev tracet, men libc6-compat mangler. Tilføj RUN apk add --no-cache libc6-compat i runner-stagen.

Fejl: ISR-siderne genopfrisker ikke i produktion

Standard fil-baseret cache går tabt ved hver redeploy. Implementér en Redis CacheHandler som vist ovenfor, eller mount en persistent volume til .next/cache/.

Ofte stillede spørgsmål

Hvad er output: 'standalone' i Next.js?

Standalone output er en build-mode, hvor Next.js kompilerer din app til en selvstændig .next/standalone/-mappe, der kun indeholder de filer, serveren rent faktisk bruger ved runtime. Det reducerer Docker-image-størrelsen med 70–97 % og eliminerer behovet for at installere node_modules i den endelige container.

Kan jeg deploye Next.js uden Vercel?

Ja, Next.js er fuldt selvhostbart. Alle App Router-features (Server Components, Server Actions, ISR, middleware, streaming) kører i en almindelig Node.js-proces. Docker er den enkleste standardvej, men du kan også køre direkte som Node.js-service via PM2 eller systemd.

Fungerer next/image i en Docker-container?

Ja, forudsat at sharp er installeret og libc6-compat er tilgængeligt i Alpine-baserede images. Uden sharp falder next/image tilbage til uoptimerede originaler, hvilket ødelægger Lighthouse-scoren. Alternativt kan du sætte unoptimized: true og lade en CDN klare optimeringen.

Hvor stort skal et Next.js Docker-image være?

Et korrekt konfigureret produktions-image med output: 'standalone', multi-stage build og node:20-alpine lander typisk mellem 180 og 250 MB. Er dit image over 400 MB, mangler enten standalone-mode eller din runner-stage har lækket devDependencies fra buildet.

Hvordan persisterer jeg ISR-cachen mellem redeploys?

Konfigurér en custom CacheHandler i next.config.mjs, der skriver til Redis eller et andet delt datalager. Fil-baseret cache i .next/cache/ tabes ved hver container-restart og deles ikke mellem instanser bag en load balancer, hvilket bryder revalidateTag-konsistens.

Skal jeg bruge Alpine eller Debian som base image?

Alpine (node:20-alpine) er anbefalet for de fleste use cases på grund af markant mindre image-størrelse (~50 MB vs ~350 MB). Kombinér altid med libc6-compat for at undgå musl/glibc-inkompatibiliteter med sharp og SWC. Debian-baserede images (node:20-slim) er sikrere til native modules, men kommer med en størrelses-omkostning.

Editorial Team
Om Forfatteren Editorial Team

Our team of expert writers and editors.