Self-Hosting Next.js 16 con Docker: Guida Completa al Deployment di Produzione 2026

Guida pratica al self-hosting di Next.js 16 con Docker nel 2026: Dockerfile multi-stage con output standalone, reverse proxy Nginx, cache Redis condivisa, version skew protection e CI/CD con GitHub Actions.

Docker Next.js 16: Self-Hosting Completo 2026

Diciamoci la verità: fare self-hosting di Next.js 16 con Docker è diventato il pattern di deployment più richiesto del 2026. I costi di Vercel continuano a crescere (un caso documentato di recente è passato dal piano gratuito a oltre 700$/mese solo per il bandwidth — non proprio simpatico), e nel frattempo l'output standalone è maturato, Turbopack si è stabilizzato, e la containerizzazione è diventata la scelta naturale per chi vuole controllo, costi prevedibili e portabilità.

In questa guida vedremo il workflow completo, end to end: dalla configurazione di next.config.ts fino a un Dockerfile multi-stage di produzione, passando per reverse proxy Nginx, gestione delle variabili d'ambiente, cache condivisa tra istanze e pipeline CI/CD con GitHub Actions. Tutti gli esempi sono testati con Next.js 16 e Node.js 24 LTS — quindi niente sorprese.

Perché fare self-hosting di Next.js 16

Sia chiaro: Vercel rimane la piattaforma di riferimento per developer experience, e per molti progetti è ancora la scelta giusta. Ma nel 2026 il self-hosting offre vantaggi concreti che sono diventati decisivi:

  • Costo prevedibile: un VPS Hetzner o Contabo da 4-6$/mese gestisce traffico significativo, senza overage di bandwidth a fine mese.
  • Portabilità totale: lo stesso container gira su Kubernetes, Cloud Run, Fly.io, Railway o un VPS bare metal. Niente refactoring.
  • Compliance e residenza dei dati: scegli tu la regione esatta, controlli i log, gestisci secrets e crittografia come preferisci.
  • Zero vendor lock-in: niente codice specifico per Vercel — server actions, ISR, middleware, edge runtime, tutto funziona nel container Node standard.

Il trade-off, ovviamente, è il tempo di setup iniziale. Una pipeline production-grade (Docker, reverse proxy, cache condivisa, observability, CI/CD) richiede tra le 20 e le 60 ore di engineering. Non è poco, ma è un investimento una tantum — e questa guida copre praticamente ogni step.

Prerequisiti

  • Next.js 16 o superiore (App Router consigliato)
  • Docker 25+ con BuildKit abilitato (default dalla v23)
  • Node.js 24 LTS in locale per build di test
  • Un registry container (GitHub Container Registry, Docker Hub o ECR vanno tutti bene)
  • Un VPS o un servizio container con almeno 1 GB di RAM

Step 1: Abilitare l'output standalone

L'output standalone è davvero la chiave per immagini Docker leggere. Funziona così: Next.js copia solo i file effettivamente necessari (incluso un sottoinsieme di node_modules) in .next/standalone, riducendo l'immagine finale da ~900 MB a 150-230 MB. La prima volta che l'ho provato sono rimasto sorpreso dalla differenza.

Modifica next.config.ts:

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'standalone',
  outputFileTracingRoot: process.cwd(),
  experimental: {
    turbo: {
      resolveAlias: {},
    },
  },
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'images.unsplash.com' },
    ],
  },
};

export default nextConfig;

Dopo aver lanciato next build, troverai una struttura .next/standalone/ con server.js al suo interno: quello è il punto di ingresso del container.

Step 2: Scrivere il Dockerfile multi-stage

Il pattern deps → builder → runner separa l'installazione delle dipendenze dal build e dall'immagine finale di produzione. Risultato? Cache di layer ottimale e dimensione minima. Onestamente, è uno dei pochi pattern di Docker che mi è rimasto in testa al primo colpo.

# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-slim AS base

# Stage 1: dipendenze
FROM base AS deps
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    libc6 \
    && rm -rf /var/lib/apt/lists/*

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --include=optional

# Stage 2: build
FROM base AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL

ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# Stage 3: runner di produzione
FROM base AS runner
WORKDIR /app

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

RUN groupadd --system --gid 1001 nodejs \
    && useradd --system --uid 1001 --gid nodejs nextjs

COPY --from=builder /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

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

CMD ["node", "server.js"]

Punti chiave da tenere a mente:

  • BuildKit cache mount: --mount=type=cache,target=/root/.npm riusa la cache di npm tra build, riducendo i tempi del 40-60%. Un game changer su CI.
  • Non-root user: nextjs (UID 1001) è praticamente obbligatorio per compliance (Kubernetes PSS, OpenShift e simili).
  • HEALTHCHECK: serve a Docker Swarm, Cloud Run e ai load balancer per gestire correttamente i rolling deploy.
  • sharp: incluso tramite --include=optional per ottenere l'ottimizzazione immagini nativa (senza dover ripiegare su squoosh).

Step 3: Configurare .dockerignore

Senza un .dockerignore esplicito, Docker si trascina dentro node_modules e .next locali nel contesto di build. Risultato: 500+ MB di trasferimento sprecato e cache invalidata a ogni rebuild. Ecco il file minimo:

node_modules
.next
.git
.gitignore
.env*.local
.vscode
.idea
README.md
*.log
coverage
.nyc_output
Dockerfile*
docker-compose*.yml
.dockerignore

Step 4: docker-compose per ambienti locali e VPS

Su VPS singolo, docker-compose.yml resta il modo più pragmatico di orchestrare app + reverse proxy + storage. Non serve impazzire con Kubernetes finché non hai più di una macchina.

services:
  app:
    build:
      context: .
      args:
        NEXT_PUBLIC_SITE_URL: https://example.com
    image: ghcr.io/yourorg/nextjs-app:latest
    restart: unless-stopped
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
      - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
      - NEXT_DEPLOYMENT_ID=${GITHUB_SHA:-local}
    expose:
      - "3000"
    networks:
      - web
    deploy:
      resources:
        limits:
          memory: 512M

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - app
    networks:
      - web

networks:
  web:
    driver: bridge

Step 5: Reverse proxy Nginx

La documentazione ufficiale di Next.js raccomanda sempre un reverse proxy davanti al server Node — e per buoni motivi. Nginx gestisce TLS, rate limiting, gzip/brotli, protezione contro slowloris e payload malformati, lasciando a Next.js solo quello che sa fare meglio: il rendering.

# /etc/nginx/conf.d/app.conf
upstream nextjs_upstream {
  server app:3000;
  keepalive 64;
}

server {
  listen 80;
  server_name example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name example.com;

  ssl_certificate     /etc/nginx/certs/fullchain.pem;
  ssl_certificate_key /etc/nginx/certs/privkey.pem;
  ssl_protocols       TLSv1.2 TLSv1.3;

  gzip on;
  gzip_types text/plain application/javascript application/json text/css;

  limit_req_zone $binary_remote_addr zone=app:10m rate=20r/s;

  location /_next/static/ {
    proxy_pass http://nextjs_upstream;
    proxy_cache_valid 200 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  location / {
    limit_req zone=app burst=40 nodelay;

    proxy_pass http://nextjs_upstream;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    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;

    proxy_read_timeout 60s;
    proxy_send_timeout 60s;
  }
}

Step 6: Configurazione di produzione (quella che ti salva la vita)

Chiave di crittografia per Server Actions

Questo è il classico dettaglio che ti morde dopo settimane di produzione. Senza una chiave fissa, ogni istanza ne genera una al boot. In un deploy multi-istanza, o semplicemente dopo un restart, i form inviati con la chiave vecchia falliranno. Genera una chiave AES-256 una sola volta:

openssl rand -base64 32

Poi imposta NEXT_SERVER_ACTIONS_ENCRYPTION_KEY come secret nel container e — questa è la parte importante — mantieni lo stesso valore tra deploy.

Version Skew Protection

Durante un rolling deploy, alcuni client hanno ancora il bundle vecchio mentre il server serve già asset nuovi. Risultato? Quei fastidiosissimi ChunkLoadError casuali. La soluzione è impostare un deploymentId univoco:

const nextConfig: NextConfig = {
  output: 'standalone',
  deploymentId: process.env.NEXT_DEPLOYMENT_ID || undefined,
};

Passa GITHUB_SHA (o qualsiasi identificatore unico) come variabile d'ambiente. Next.js rifiuterà le richieste con ID non corrispondente e forzerà il reload del client. Pulito.

Cache condivisa tra istanze

Di default, use cache e ISR usano una cache in-memory per ciascuna istanza. Quindi se hai N container dietro un load balancer, ottieni N cache divergenti e revalidate ripetuti — non esattamente quello che vuoi. Configura un cache handler personalizzato basato su Redis:

// cache-handler.mjs
import { CacheHandler } from '@neshca/cache-handler';
import createRedisCache from '@neshca/cache-handler/redis-strings';
import { createClient } from 'redis';

CacheHandler.onCreation(async () => {
  const client = createClient({ url: process.env.REDIS_URL });
  await client.connect();
  return {
    handlers: [
      await createRedisCache({ client, keyPrefix: 'nextjs:' }),
    ],
  };
});

export default CacheHandler;

Poi in next.config.ts:

const nextConfig: NextConfig = {
  output: 'standalone',
  cacheHandler: require.resolve('./cache-handler.mjs'),
  cacheMaxMemorySize: 0,
};

Storage per upload e media

Il filesystem locale del container è effimero: ogni restart o scale-out perde i file. Punto. Usa storage S3-compatibile (AWS S3, Cloudflare R2, MinIO, Backblaze B2 — sono tutte ottime opzioni) per qualsiasi upload utente.

Step 7: Pipeline CI/CD con GitHub Actions

Questo workflow automatizza build, push sul registry e deploy via SSH al VPS. Una volta configurato, fai git push e ti dimentichi del resto:

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          build-args: |
            NEXT_PUBLIC_SITE_URL=${{ vars.NEXT_PUBLIC_SITE_URL }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/myapp
            echo "GITHUB_SHA=${{ github.sha }}" > .env.deploy
            docker compose pull
            docker compose up -d --remove-orphans
            docker image prune -f

Opzioni di deployment alternative

Google Cloud Run

Cloud Run è probabilmente la scelta più semplice se non vuoi gestire un VPS: paghi solo le richieste, autoscaling fino a zero, HTTPS gratis. Un singolo comando dopo aver buildato l'immagine:

gcloud run deploy nextjs-app \
  --image gcr.io/PROJECT/nextjs-app:latest \
  --region europe-west1 \
  --platform managed \
  --allow-unauthenticated \
  --memory 512Mi \
  --cpu 1 \
  --concurrency 80 \
  --set-env-vars NODE_ENV=production

Fly.io

Fly.io distribuisce il container in più regioni con un singolo fly deploy. Ottimo per app con utenti sparsi nel mondo — il routing anycast manda ciascun utente al nodo più vicino, senza che tu debba pensarci.

Coolify su VPS

Se vuoi l'esperienza Vercel (git push deploy, preview URL, SSL automatico) ma sulla tua infrastruttura, Coolify open-source è la scelta migliore del 2026, almeno secondo me. Una sola installazione su un VPS Hetzner da 6$/mese gestisce decine di app. Lo uso da quasi un anno e non sono mai tornato indietro.

Errori comuni da evitare

  • Dimenticare HOSTNAME=0.0.0.0: senza questa env var, server.js binda solo su 127.0.0.1 e Docker non riesce a inoltrare il traffico. Frustrante quanto banale.
  • Variabili NEXT_PUBLIC_* a runtime: queste vengono inlinate nel bundle client durante next build, quindi devono essere passate come ARG nel Dockerfile, non come env del container.
  • Non installare sharp: senza sharp, l'ottimizzazione immagini ricade su squoosh, dalle 5 alle 10 volte più lento. Differenza tangibile sotto carico.
  • Esporre direttamente la porta 3000: niente reverse proxy significa niente protezione contro slowloris, niente TLS terminato bene, niente rate limiting. Non fatelo in produzione.
  • Stessa chiave server actions tra ambienti: usa una chiave diversa per staging e produzione, ma identica tra istanze dello stesso ambiente.

Domande frequenti

Next.js 16 funziona davvero in Docker senza Vercel?

Sì, e funziona bene. Tutte le feature di Next.js 16 — App Router, Server Components, Server Actions, ISR, middleware, edge runtime emulato — girano in un container Node standard. L'unica differenza è che alcune ottimizzazioni di edge networking (la Vercel Edge Network) richiedono un CDN davanti al container. Cloudflare, Fastly o BunnyCDN funzionano tutti benissimo.

Quanto si risparmia rispetto a Vercel?

Dipende molto dal traffico. Per progetti piccoli (sotto 100 GB di bandwidth/mese) la differenza è minima e probabilmente non vale lo sbattimento. Sopra i 200 GB/mese però il delta diventa serio: un caso documentato è passato da 700$/mese su Vercel a 12$/mese su un VPS Hetzner. La soglia di break-even si trova tipicamente intorno ai 30-50 dollari di costo Vercel.

Posso usare ISR e on-demand revalidation con self-hosting?

Sì, ma con cache handler condiviso (Redis o S3) se hai più istanze. La revalidation on-demand via revalidateTag() e revalidatePath() funziona out-of-the-box, ma senza cache condivisa invalida solo l'istanza che riceve la richiesta. Configura un cache handler personalizzato come mostrato sopra e sei a posto.

Quanto è grande l'immagine Docker finale?

Con output standalone + Node 24 slim + multi-stage, l'immagine di produzione tipica è tra 150 e 230 MB. Senza standalone, si scende difficilmente sotto i 900 MB. Per ridurre ulteriormente puoi usare node:24-alpine al posto di slim, ottenendo immagini sotto i 130 MB — ma attenzione alle differenze nella libc: sharp e bcrypt richiedono build aggiuntivi.

Come gestisco i secret in produzione?

Regola numero uno: mai inseriti nel Dockerfile o in docker-compose.yml committato. Usa un file .env caricato da docker compose con env_file:, oppure Docker Secrets per Swarm, oppure Kubernetes Secrets / External Secrets Operator. Per VPS singolo, la combinazione .env con permessi 600 e proprietà root:docker è più che sufficiente.

Conclusione

Self-hosting Next.js 16 con Docker nel 2026 è, francamente, la combinazione ottimale di costo, controllo e capacità per la maggior parte dei team. Il pattern multi-stage con output standalone produce immagini sotto i 250 MB pronte per qualsiasi orchestratore. Aggiungi Nginx davanti, cache Redis condivisa, version skew protection e una pipeline GitHub Actions, e ottieni un setup production-grade comparabile a Vercel — per una frazione del costo.

Il consiglio che do sempre: inizia in piccolo. Un VPS, docker-compose, Nginx. Quando il traffico cresce (e se cresce), lo stesso container migra senza modifiche a Cloud Run o Kubernetes. Questa è la portabilità reale che il self-hosting ti dà — ed è una cosa che Vercel, per definizione, non può garantirti.

Sull'Autore Editorial Team

Our team of expert writers and editors.