Next.js Zelf-hosten met Docker in 2026: Standalone Output, Multi-stage Builds en Migratie van Vercel

Werkende Docker-setup voor Next.js 15 en 16 met standalone output, multi-stage Dockerfile, sharp voor Image Optimization en een compleet migratieplan van Vercel in 3 tot 5 dagen.

Zelf-host Next.js met Docker (2026)

Bijgewerkt: 10 augustus 2026

Next.js 15 en 16 zelf-hosten met Docker vereist drie dingen: output: 'standalone' in je next.config.ts, een multi-stage Dockerfile op een Node 22-basis, en een reverse proxy (Caddy of nginx) voor HTTPS. Op een VPS van €5–12 per maand draai je een productie-Next.js-app met vergelijkbare prestaties als Vercel; je moet alleen Image Optimization, cron jobs en on-demand ISR zelf configureren. In deze gids loop ik je door een werkende setup, laat ik de exacte kostenverschillen zien en geef ik een migratieplan dat ik meerdere teams heb zien uitvoeren in 3 tot 5 werkdagen.

  • Met output: 'standalone' genereert Next.js een .next/standalone map van 120–180 MB in plaats van 800 MB+ aan node_modules.
  • Een multi-stage Dockerfile met een Alpine- of distroless-basis levert productie-images van 180–260 MB, klein genoeg voor snelle CI-deploys.
  • Sinds Next.js 15 moet je sharp expliciet installeren om Image Optimization werkend te krijgen bij zelf-hosten.
  • Een Hetzner CX22 (€4,15/maand, 2 vCPU / 4 GB RAM) draait comfortabel 3–5 middelgrote Next.js-apps achter Caddy.
  • Migratie van Vercel naar Docker duurt in mijn ervaring 3 tot 5 dagen: 1 dag Dockerfile, 1 dag omgevingsvariabelen en secrets, 1–2 dagen edge/ISR-features vervangen, 0,5 dag DNS-cutover.
  • Edge Runtime, On-Demand ISR en Preview Deployments zijn de drie features waar Vercel voorloopt. Al het andere kun je evenaren.

Waarom Next.js zelf-hosten in 2026?

Ik werk sinds 2020 met Next.js en heb inmiddels meer teams van Vercel af begeleid dan ernaartoe. De redenen zijn vrijwel altijd hetzelfde: een onvoorspelbare rekening bij verkeersschommelingen, data-residentie-eisen (met name in Nederland en Duitsland rond de EU Data Act 2026), en het feit dat een productieteam op een gegeven moment gewoon SSH-toegang wil tot de machine waar hun app draait. Vercel blijft een uitstekende hosting-oplossing, maar het is geen wet dat je daar moet blijven.

Zelf-hosten met Docker geeft je drie concrete voordelen. Om te beginnen worden je bandbreedte- en compute-kosten lineair en voorspelbaar, want een VPS bij Hetzner of Scaleway rekent per uur en niet per functie-invocatie. Je kunt op elke infrastructuur draaien: on-premises, Kubernetes, een enkele Docker Compose-stack of een fly.io-machine. En je hebt volledige controle over de Node-runtime, wat handig is als je bijvoorbeeld een custom SSL-certificaat of native modules zoals canvas nodig hebt.

Het nadeel? Je bent zelf verantwoordelijk voor uptime, patch management en de features die Vercel gratis meelevert (edge caching, image optimization, DDoS-bescherming). Voor een klein SaaS-team van 2 tot 5 mensen kost dat in mijn ervaring 4 tot 8 uur onderhoud per maand. Niets, als je Vercel-rekening €500+ was; veel, als die €20 was.

Vercel versus Docker zelf-hosten: kosten en features vergeleken

Voordat je gaat migreren wil je een eerlijk beeld van wat je opgeeft en wat je terugkrijgt. Onderstaande tabel is gebaseerd op de Vercel-prijslijst van juli 2026 (Pro-plan €20/gebruiker/maand + overage) en een Hetzner CX22 VPS (€4,15/maand) plus een Cloudflare R2-bucket voor assets. Ik heb bewust een middelgrote e-commerce-app als uitgangspunt genomen: 500k paginabezoeken per maand, 40 GB uitgaand verkeer, 12 GB image-uploads.

AspectVercel ProDocker zelf-hosten (Hetzner)
Maandelijkse basisprijs€20 per gebruiker€4,15 per VPS
Kosten bij 500k pageviews€80–180 (compute + bandbreedte)€5–15 (inclusief object storage)
Image OptimizationIngebouwd, 5k transformaties gratisZelf: sharp + eigen cache-map
Edge RuntimeWereldwijd, 300+ regio'sNode runtime; multi-regio via CDN
On-Demand ISRIngebouwd, per padWerkt met revalidateTag en gedeelde cache
Preview DeploymentsAutomatisch per PRHandmatig (GitHub Actions + subdomein)
DDoS-beschermingIngebouwdCloudflare gratis of BunnyShield
Deploy-tijd45–90 seconden60–180 seconden (afhankelijk van image-cache)
Uptime SLA99,99% (Enterprise)Provider SLA + je eigen setup

De belangrijkste conclusie: voor de meeste teams zit het break-even-punt rond de €80–100 aan maandelijkse Vercel-kosten. Zit je daaronder en heb je geen infrastructuur-engineer, blijf dan waar je bent. Zit je erboven en heb je iemand die comfortabel is met Docker en Linux, dan verdien je de migratie binnen twee tot drie maanden terug. Als je nog niet zeker weet op welk rendering-model je zwaar leunt, lees dan eerst mijn artikel over rendering-strategieën in Next.js App Router. SSG en ISR zijn goedkoop overal; puur SSR laat je Vercel-rekening pijn doen.

Wat is standalone output in Next.js?

output: 'standalone' is een build-optie die Next.js sinds versie 12 aanbiedt en die in Next.js 15 en 16 de aanbevolen manier is om je app te containeriseren. Zet je deze aan, dan produceert next build een aparte .next/standalone-map met een minimale server.js en alle strikt noodzakelijke Node-modules. In plaats van je hele node_modules-boom van 800 MB+ naar je container te kopiëren, kopieer je alleen deze standalone-map plus .next/static en public. Het resultaat is een productie-image van 180 tot 260 MB in plaats van 1 GB+.

Activeer het in next.config.ts:

import type { NextConfig } from 'next'

const config: NextConfig = {
  // Genereer .next/standalone met alleen productie-dependencies
  output: 'standalone',

  // Nodig als je in een Docker-container achter een reverse proxy draait
  // en de public/ map naar een CDN pusht:
  // assetPrefix: process.env.NEXT_PUBLIC_ASSET_PREFIX,

  images: {
    // Bij zelf-hosten moet je de toegestane image-hosts expliciet opgeven
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.mijnbedrijf.nl' },
      { protocol: 'https', hostname: 'r2.cloudflarestorage.com' },
    ],
  },
}

export default config

Onder de motorkap gebruikt standalone Vercels @vercel/nft (Node File Trace) om te bepalen welke bestanden je Node-server daadwerkelijk laadt. Alles wat niet wordt aangeraakt, wordt weggelaten. Dit werkt goed voor pure JavaScript-dependencies; native modules zoals sharp, @node-rs/argon2 of Prisma-clients moet je expliciet uitzonderen (zie verderop).

Multi-stage Dockerfile voor Next.js 15 en 16

Een multi-stage Dockerfile scheidt de build-omgeving van de runtime. Je installeert alle dev-dependencies, draait next build, en kopieert daarna alleen de output naar een schone image zonder npm, git of bouwgereedschap. Onderstaand is de setup die ik voor zowel Next.js 15 als 16 gebruik. Hij is gebaseerd op het officiële with-docker voorbeeld, met een paar aanpassingen voor kleinere images en snellere rebuilds.

# syntax=docker/dockerfile:1.7

# --- Stage 1: dependencies ---
FROM node:22-alpine AS deps
WORKDIR /app

# libc6-compat is nodig voor sommige Node-binaries op Alpine
RUN apk add --no-cache libc6-compat

# Kopieer alleen manifests -> deze laag wordt gecached tot je package.json wijzigt
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm i --frozen-lockfile; \
  else npm ci; \
  fi

# --- Stage 2: build ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Telemetry uit in CI
ENV NEXT_TELEMETRY_DISABLED=1

# BuildKit cache voor .next -> halveert cold-build tijden
RUN --mount=type=cache,target=/app/.next/cache npm run build

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

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

# Draai als niet-root gebruiker
RUN addgroup --system --gid 1001 nodejs \
 && adduser --system --uid 1001 nextjs

# Standalone-output kopiëren -> server.js en minimale node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

USER nextjs
EXPOSE 3000

# server.js is het instappunt dat standalone-output genereert
CMD ["node", "server.js"]

Belangrijk om te weten: de --mount=type=cache regel vereist BuildKit (standaard in Docker 23+). Zonder deze cache duurt een cold build van een middelgrote app ongeveer 3–4 minuten; met de cache 45–90 seconden. Bij GitHub Actions zet je in je workflow cache-from: type=gha en cache-to: type=gha,mode=max in docker/build-push-action om deze cache tussen runs te bewaren.

De grootte van de resulterende image is bij een lege Next.js-app ongeveer 180 MB; bij een app met Prisma, sharp en Auth.js kom je op 260–320 MB uit. Als je nog verder wilt strippen, vervang node:22-alpine in de runner-stage door gcr.io/distroless/nodejs22-debian12. Dat scheelt nog eens 40–60 MB, maar maakt debuggen lastiger omdat er geen shell in zit.

Omgevingsvariabelen: build-time versus runtime

Dit is de meest voorkomende migratieval waar teams intrappen. Ik ben er zelf ook een keer ingelopen tijdens een cutover op donderdagavond, dus let goed op. Next.js kent twee soorten omgevingsvariabelen: variabelen met de NEXT_PUBLIC_-prefix worden op build-time in je JavaScript-bundle gebakken; alle andere kunnen op runtime worden gelezen via process.env. Als je een gebakken variabele wilt wijzigen (bijvoorbeeld je Sentry DSN of Segment key), moet je opnieuw builden en deployen. Op Vercel gebeurt dit automatisch, maar in je eigen Docker-pipeline moet je dat expliciet regelen.

Concreet betekent dit dat je twee categorieën secrets moet onderscheiden in je Docker-setup:

# Build-time (moeten aanwezig zijn tijdens `docker build`)
NEXT_PUBLIC_APP_URL=https://app.mijnbedrijf.nl
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/1234

# Runtime (worden gelezen bij elke request, mogen wijzigen zonder rebuild)
DATABASE_URL=postgres://...
AUTH_SECRET=supergeheim
STRIPE_SECRET_KEY=sk_live_...

Geef build-time variabelen door aan docker build via --build-arg en zet ze om naar ENV in de builder-stage:

# In je Dockerfile, boven `RUN npm run build`:
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN

# In je build-commando:
docker build \
  --build-arg NEXT_PUBLIC_APP_URL=https://app.mijnbedrijf.nl \
  --build-arg NEXT_PUBLIC_SENTRY_DSN=$SENTRY_DSN \
  -t mijn-app:latest .

Runtime-variabelen geef je door bij docker run via --env-file of via je orchestrator (Docker Compose, Kubernetes Secret). Commit ze nooit in je image. Een gelekte Docker-image is een gelekte database. Voor Kubernetes gebruik ik Sealed Secrets of External Secrets Operator; voor een enkele VPS gewoon een .env.production die alleen leesbaar is voor de nextjs-gebruiker.

Sharp installeren voor Image Optimization

De next/image-component blijft ook bij zelf-hosten werken, maar sinds Next.js 15 moet je sharp zelf installeren als peer dependency. Zonder sharp valt Next.js terug op een langzame JavaScript-decoder die je server-CPU opvreet. Bij een app met 20 afbeeldingen per pagina zie ik zonder sharp response times van 1200–1800 ms; met sharp 200–350 ms.

npm install sharp

Voor de Docker-build betekent dit dat je de native binary van sharp op Alpine moet compileren of installeren. Alpine gebruikt musl in plaats van glibc, dus je moet expliciet de musl-variant kiezen:

# In je deps-stage:
RUN apk add --no-cache libc6-compat vips-dev

# Optioneel: forceer de pre-built binary voor musl
ENV npm_config_platform=linuxmusl
ENV npm_config_arch=x64
RUN npm ci --include=optional

Voor Image Optimization heb je daarnaast een cache-map nodig. Next.js schrijft standaard naar .next/cache/images, wat verdwijnt zodra je container herstart. Mount daarom een volume of gebruik een externe cache. Op één VPS: docker run -v nextjs_image_cache:/app/.next/cache/images. Op meerdere replicas: zet images.loader op custom en zet een CDN als BunnyCDN of Cloudflare Images ertussen. Als je zoekt naar hoe deze caching samenwerkt met de nieuwere use cache directive, heb ik dat in detail behandeld in de complete caching-gids voor Next.js 15.

Reverse proxy: Caddy, nginx of Traefik?

Je Next.js-server draait op poort 3000 zonder TLS. Voor productie zet je er een reverse proxy voor die HTTPS termineert, HTTP/2 en HTTP/3 spreekt, en statische assets efficiënt serveert. Ik heb alle drie de gangbare opties productie-getest en gebruik ze voor verschillende scenario's.

Caddy voor 90% van de use cases

Caddy is mijn eerste keuze voor teams die snel een productie-setup willen zonder TLS-scripts. Het regelt Let's Encrypt automatisch, ondersteunt HTTP/3 out of the box en heeft een leesbare config van 10 regels. Een productie-Caddyfile:

app.mijnbedrijf.nl {
    encode zstd gzip

    # Statische Next.js-assets: 1 jaar cache, immutable
    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    # Public-map assets
    @public path /favicon.ico /robots.txt /sitemap.xml
    header @public Cache-Control "public, max-age=86400"

    # Alles anders naar de Next.js-container
    reverse_proxy nextjs:3000 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
}

nginx voor complexe setups en bestaande stacks

Als je al nginx draait voor andere services, blijf daar dan bij. De config is verboser maar het ecosysteem aan kennis en modules is groter. Vergeet niet proxy_http_version 1.1; en proxy_set_header Connection ""; te zetten, anders werken Server-Sent Events (streaming responses van Next.js) niet. Ik heb hier in mijn vorige project een halve avond aan verloren voor ik doorhad dat dat de oorzaak was.

Traefik voor Kubernetes en dynamische omgevingen

Voor Kubernetes of Docker Swarm met veel services is Traefik superieur omdat het services automatisch ontdekt via labels. Je zet traefik.enable=true in je Compose-file en de proxy pikt de nieuwe container op zonder configuratie-reload. Voor een enkele Next.js-app is Caddy simpeler.

Health checks, zero-downtime en observability

Zonder health checks kan je orchestrator niet weten of je container klaar is voor verkeer. Voeg een lichte route toe die alleen ja/nee retourneert:

// app/api/health/route.ts
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

export function GET() {
  return Response.json({ status: 'ok', uptime: process.uptime() })
}

En de bijbehorende HEALTHCHECK-instructie in je Dockerfile:

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD wget --quiet --spider http://localhost:3000/api/health || exit 1

Voor zero-downtime deploys draai je twee containers achter je proxy en switch je met een blue-green pattern. Docker Compose ondersteunt dit sinds versie 2.24 met --wait en rolling updates. Op Kubernetes is het de standaard RollingUpdate-strategy met readinessProbe. In beide gevallen wacht je tot de nieuwe container 200 OK teruggeeft op /api/health voordat je oud verkeer verplaatst.

Voor observability koppel je Next.js aan een OpenTelemetry-collector. Sinds Next.js 15 heb je hiervoor instrumentation.ts in je project-root; die wordt eenmalig geladen bij server-start. Populaire receivers zijn Grafana Cloud (gratis tier is voldoende), Axiom en zelf-gehost Prometheus + Loki + Tempo. Voor logs die uit je middleware komen (requests, rate limits, auth-fouten) verwijs ik naar mijn middleware-gids; daar staat een compleet logging-pattern dat direct in Grafana zichtbaar wordt.

Extern kun je ook de officiële Next.js deployment documentatie raadplegen. Die is bijgewerkt tot Next.js 16 en beschrijft ook Compose-varianten die je als startpunt kunt gebruiken.

Migratieplan van Vercel naar Docker in 3 tot 5 dagen

Een realistische tijdlijn voor een middelgrote app (met authenticatie, database, cron jobs en 2–3 externe services). Aannames: je hebt al ervaring met Docker, je database staat al niet op Vercel Postgres maar bij Neon of Supabase, en je hebt DNS-controle.

Dag 1: Dockerfile en lokale build (4–6 uur)

Voeg output: 'standalone' toe, kopieer de multi-stage Dockerfile uit dit artikel, en zorg dat docker build en docker run lokaal werken. Test dat je login-flow, database-queries en Image Optimization werken. Los hier ook direct sharp-issues op als je die krijgt.

Dag 2: Environment variables en secrets (3–5 uur)

Splits je Vercel-omgevingsvariabelen in build-time en runtime. Zet build-args in je CI-pipeline en runtime-secrets in je secrets-manager. Verifieer dat een deploy zonder Vercel dashboard end-to-end werkt. Documenteer welke variabelen per omgeving verschillen; dit voorkomt 90% van de post-migratie-incidenten.

Dag 3: Vercel-specifieke features vervangen (4–8 uur)

Loop je codebase langs op edge-runtime, @vercel/*-packages, Vercel KV, Vercel Blob en Vercel Cron. Vervang edge-routes door runtime: 'nodejs', Vercel Blob door S3-compatible storage (Cloudflare R2 kost letterlijk cent per maand), Vercel Cron door een simpele cron-container of GitHub Actions scheduled workflow. Als je Turbopack gebruikt voor je builds, is mijn Turbopack-migratiegids een goede tweede lezing. De productie-build in Docker heeft een paar specifieke aandachtspunten.

Dag 4: Reverse proxy, TLS en observability (3–4 uur)

Deploy je container naar de VPS of het cluster. Configureer Caddy of nginx. Zet Cloudflare ervoor voor DDoS en caching. Koppel je logs en metrics aan je observability-stack. Verifieer HTTPS, HTTP/2, en dat je health check groen is.

Dag 5: DNS-cutover en monitoring (2–4 uur)

Zet de TTL van je DNS-record 24 uur van tevoren op 300 seconden. Op cutover-dag: wijzig de A-record naar je nieuwe server. Monitor de eerste 4 uur intensief; kijk naar responstijden, foutpercentage en cache-hit-ratio. Houd Vercel nog een week actief als fallback, met een simpele DNS-toggle klaar.

Veelgestelde vragen

Is Next.js zelf-hosten met Docker goedkoper dan Vercel?

Vanaf ongeveer €80–100 aan maandelijkse Vercel-kosten wordt zelf-hosten goedkoper. Onder dat bedrag win je vrijwel nooit terug wat je aan onderhoudstijd kwijt bent (4–8 uur per maand). Bij hoog verkeer of veel bandbreedte kan het verschil oplopen tot een factor 5 tot 10 in het voordeel van zelf-hosten.

Werkt On-Demand ISR bij zelf-hosten?

Ja. revalidateTag() en revalidatePath() werken identiek als op Vercel, maar de cache wordt opgeslagen op de lokale schijf van je container. Draai je meerdere replicas, gebruik dan een gedeelde cache-handler zoals @neshca/cache-handler met Redis of een officiële S3-adapter, anders zijn je invalidaties niet gesynchroniseerd tussen instances.

Kan ik Edge Runtime routes gebruiken bij zelf-hosten?

Nee, niet direct. Edge Runtime vereist een V8-isolate-omgeving zoals Cloudflare Workers of Vercel Edge. Bij Node-hosting draai je alles op de Node runtime. Voor de meeste routes heeft dit geen impact, alleen voor extreme cold-start-gevoelige endpoints. Wil je toch edge, deploy dan een subset naar Cloudflare Workers via @opennextjs/cloudflare.

Hoe groot is een productie-Docker-image voor Next.js 15?

Een lege Next.js 15-app met standalone output en Alpine base is ongeveer 180 MB. Met Prisma, sharp en Auth.js kom je op 260–320 MB. Met distroless base bespaar je nog 40–60 MB, ten koste van shell-toegang voor debugging.

Hoe lang duurt migratie van Vercel naar Docker?

Voor een middelgrote app met authenticatie, database en enkele cron jobs reken ik op 3 tot 5 werkdagen: 1 dag Dockerfile, 1 dag secrets, 1–2 dagen Vercel-specifieke features vervangen (edge routes, Vercel Blob, Vercel Cron), en 0,5 dag voor de DNS-cutover met monitoring.

Jasmine Patel
Over de Auteur Jasmine Patel

Web framework specialist comparing Next.js to everything else so you don't have to. Migrates teams off legacy stacks for fun.