Las plataformas comerciales de alojamiento serverless (Vercel, AWS Amplify, Netlify) ofrecen un alto nivel de abstracción y comodidad de desarrollo: entornos de vista previa efímeros, redes CDN globales y escalabilidad automática inmediata. No obstante, cuando una aplicación alcanza una fase de tráfico masivo y sostenido con decenas de millones de visitas mensuales, las abstracciones serverless se transforman en multiplicadores exponenciales de costes operativos.
Un análisis económico detallado revela tres partidas principales de sobrecoste en las soluciones Serverless PaaS:
GB-sec) bajo tráfico continuo resulta entre 5 y 10 veces más caro que alquilar núcleos de procesador dedicados (vCPU).Next.js incorpora el Modo Standalone (output: standalone), empaquetando la aplicación en un artefacto hermético y ligero, idóneo para su contenedorización en Docker y su posterior despliegue en Servidores Privados Virtuales (VPS) mediante orquestadores modernos: Coolify, Kamal o Dokku.
La arquitectura de un clúster de Self-Hosting en producción se basa en la combinación de un proxy inverso perimetral (Traefik v3) y contenedores Docker aislados de Next.js:
Tráfico HTTPS cliente entrante (Puertos 80, 443 / HTTP/3 QUIC)
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ Traefik v3 Edge Reverse Proxy │
│ - Emisión y renovación automática de certificados Let's Encrypt │
│ - Descubrimiento dinámico de contenedores vía Docker Socket & Labels │
│ - Multiplexación HTTP/2 y HTTP/3, compresión Zstd / Brotli / Gzip │
│ - Rate Limiting global, mitigación de escaneos y protección DDoS │
└─────────────┬────────────────────────────────────────────┬─────────────┘
│ Red interna en puente (Docker Bridge Network)
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────┐
│ Next.js Container A (Blue) │ │ Next.js Container B (Green)
│ - Node.js 22 LTS Alpine │ │ - Nueva versión de la app│
│ - Usuario no root nextjs │ │ - Traspaso fluido │
│ - Puerto interno 3000 │ │ - Zero-Downtime Rollout │
└──────────────┬───────────────┘ └────────────┬─────────────┘
│ │
└─────────────────────┬─────────────────────┘
│ Montaje de volumen
▼
┌────────────────────────────────────────────────────────────────────────┐
│ Docker Persistent Volume (/cache) │
│ - Persistencia de páginas de Incremental Static Regeneration (ISR) │
│ - Retención de caché entre sucesivos despliegues de contenedores │
│ - Caché interna del compilador y metadatos de peticiones fetch │
└────────────────────────────────────────────────────────────────────────┘Al establecer output: 'standalone' en next.config.ts, el compilador de Next.js ejecuta la herramienta @vercel/nft (Node File Trace). Este módulo analiza los árboles de sintaxis abstracta (AST) del código del servidor y rastrea exhaustivamente las llamadas estáticas y dinámicas a import y require.
Como resultado, en el directorio .next/standalone se genera:
server.js);node_modules requeridos en tiempo de ejecución (omitiendo librerías de desarrollo, TypeScript y herramientas de linting).# ---------------------------------------------------------
# ETAPA 1: Imagen base Alpine Linux con Node.js 22 LTS
# ---------------------------------------------------------
FROM node:22-alpine AS base
# Instalar libc6-compat para compatibilidad con librerías nativas (sharp)
RUN apk add --no-cache libc6-compat
WORKDIR /app
# ---------------------------------------------------------
# ETAPA 2: Instalación de dependencias con caché de capas
# ---------------------------------------------------------
FROM base AS deps
WORKDIR /app
# Copiar manifiestos de dependencias
COPY package.json pnpm-lock.yaml* yarn.lock* package-lock.json* ./
RUN \
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else echo "Lockfile not found." && exit 1; \
fi
# ---------------------------------------------------------
# ETAPA 3: Compilación del proyecto
# ---------------------------------------------------------
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Desactivar telemetría para agilizar la compilación
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Compilación de la aplicación en artefacto standalone
RUN \
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
elif [ -f yarn.lock ]; then yarn build; \
else npm run build; \
fi
# ---------------------------------------------------------
# ETAPA 4: Entorno mínimo de ejecución en producción (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"
# Crear usuario y grupo de sistema sin privilegios de root
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copiar archivos públicos estáticos
COPY --from=builder /app/public ./public
# Configurar permisos para la carpeta de caché ISR
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next
# Copiar el servidor standalone y los activos estáticos del cliente
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Cambiar al usuario sin privilegios
USER nextjs
EXPOSE 3000
# Ejecutar el servidor Node.js directamente
CMD ["node", "server.js"]Traefik escucha los eventos del demonio de Docker a través de /var/run/docker.sock. Cuando un nuevo contenedor de Next.js se inicia, Traefik analiza sus labels, configura el enrutador correspondiente, solicita el certificado SSL a Let's Encrypt y conmuta el tráfico tras superar la comprobación de salud:
El demonio de Docker emite un evento de inicio con etiquetas de servicio (traefik.http.routers.nextjs.rule=Host(`example.com`)).
Traefik resuelve desafíos HTTP-01 o TLS-ALPN-01, aprovisiona un certificado TLS 1.3 válido y lo almacena en acme.json.
Traefik sondea la ruta /api/health del contenedor. El tráfico se retiene hasta confirmar una respuesta HTTP 200.
El nuevo contenedor entra al pool del balanceador mientras el contenedor saliente recibe SIGTERM para finalizar peticiones activas.
El siguiente manifiesto docker-compose.prod.yml define un despliegue de producción completo en VPS: integra Traefik v3 con gestión automática de TLS mediante Let's Encrypt, aislamiento de redes, límites de memoria y un volumen persistente para la caché ISR de Next.js 15.
services:
# -------------------------------------------------------
# 1. PROXY INVERSO TRAEFIK V3
# -------------------------------------------------------
traefik:
image: traefik:v3.1
container_name: production_traefik
restart: always
command:
# Deshabilitar telemetría anónima
- "--global.sendAnonymousUsage=false"
# Activar proveedor Docker para detección automática de etiquetas
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
# Puntos de entrada para HTTP, HTTPS y HTTP/3 QUIC
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.http3=true"
# Redirección global automática de HTTP a HTTPS
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Solicitud automática de certificados SSL con Let's Encrypt
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "[email protected]"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
- "443:443/udp" # Puerto UDP para protocolo HTTP/3 QUIC
volumes:
# Montar socket de Docker en modo solo lectura (:ro) por seguridad
- /var/run/docker.sock:/var/run/docker.sock:ro
# Almacenamiento persistente de certificados TLS
- traefik_certs:/letsencrypt
networks:
- web_gateway
security_opt:
- no-new-privileges:true
# -------------------------------------------------------
# 2. APLICACIÓN STANDALONE NEXT.JS 15
# -------------------------------------------------------
nextjs_app:
build:
context: .
dockerfile: Dockerfile
target: runner
container_name: production_nextjs
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
- HOSTNAME=0.0.0.0
- DATABASE_URL=postgresql://user:password@postgres:5432/app_db
# Cuotas de recursos para impedir saturación del host
deploy:
resources:
limits:
cpus: '2.0'
memory: 2048M
reservations:
cpus: '0.5'
memory: 512M
volumes:
# CRÍTICO: Conservar la caché de páginas ISR entre despliegues sucesivos
- nextjs_cache:/app/.next/cache
networks:
- web_gateway
labels:
# Habilitar enrutamiento en Traefik para este contenedor
- "traefik.enable=true"
# Regla de enrutamiento por nombre de dominio
- "traefik.http.routers.nextjs.rule=Host(`example.com`) || Host(`www.example.com`)"
- "traefik.http.routers.nextjs.entrypoints=websecure"
- "traefik.http.routers.nextjs.tls=true"
- "traefik.http.routers.nextjs.tls.certresolver=letsencrypt"
# Puerto interno del contenedor
- "traefik.http.services.nextjs.loadbalancer.server.port=3000"
# Verificación de disponibilidad del servicio (Healthcheck)
- "traefik.http.services.nextjs.loadbalancer.healthcheck.path=/api/health"
- "traefik.http.services.nextjs.loadbalancer.healthcheck.interval=10s"
- "traefik.http.services.nextjs.loadbalancer.healthcheck.timeout=3s"
# Vincular middlewares de compresión, limitación de tasa y seguridad
- "traefik.http.routers.nextjs.middlewares=nextjs-compress,nextjs-ratelimit,nextjs-headers"
# Middleware de compresión (Brotli / Gzip)
- "traefik.http.middlewares.nextjs-compress.compress=true"
# Middleware de limitación de tasa (Rate Limiting)
- "traefik.http.middlewares.nextjs-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.nextjs-ratelimit.ratelimit.burst=50"
# Cabeceras de seguridad HSTS y prevención de incrustación
- "traefik.http.middlewares.nextjs-headers.headers.sslredirect=true"
- "traefik.http.middlewares.nextjs-headers.headers.stsSeconds=63072000"
- "traefik.http.middlewares.nextjs-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.nextjs-headers.headers.stsPreload=true"
- "traefik.http.middlewares.nextjs-headers.headers.customresponseheaders.X-Robots-Tag=all"
# ---------------------------------------------------------
# VOLÚMENES PERSISTENTES Y REDES
# ---------------------------------------------------------
volumes:
traefik_certs:
driver: local
nextjs_cache:
driver: local
networks:
web_gateway:
driver: bridgeEjecutar el contenedor sin vincular un volumen a .next/cache: docker run -p 3000:3000 my-nextjs. Con cada actualización se genera un contenedor con el disco vacío, borrando todas las páginas ISR y metadatos de peticiones fetch.
Tras el despliegue, miles de visitantes concurrentes solicitan rutas dinámicas simultáneamente, originando una avalancha de renderizados SSR en frío que colapsa la CPU del servidor y desborda la base de datos (Cache Stampede).
# ❌ ERROR FATAL: El directorio de caché se destruye en cada despliegue
services:
nextjs:
image: my-app:latest
# ✅ SOLUCIÓN ARQUITECTÓNICA: Montar un Persistent Volume en .next/cache
services:
nextjs:
image: my-app:latest
volumes:
- next_cache_vol:/app/.next/cache # Mantiene la caché entre versionesInyectar contraseñas de bases de datos o claves privadas mediante directivas ARG y ENV en el Dockerfile: ARG DATABASE_URL y ENV DATABASE_URL=$DATABASE_URL.
Aunque no se utilicen en la etapa final, los valores permanecen grabados en los metadatos de las capas intermedias. Cualquier usuario con acceso de lectura al registro de Docker puede extraerlos con docker history --no-trunc <image-id>.
# ❌ VULNERABILIDAD: Secretos grabados de forma permanente en el historial
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
RUN npm run build
# ✅ SOLUCIÓN: Uso de Docker Build Secrets o variables de entorno en Runtime
# RUN --mount=type=secret,id=DATABASE_URL \
# DATABASE_URL=$(cat /run/secrets/DATABASE_URL) npm run build
# O bien: solo variables NEXT_PUBLIC_* en compilación y secretos en runtime vía docker-compose!Omitir la instrucción USER nextjs en la etapa final del Dockerfile. De manera predeterminada, los procesos se ejecutan como root.
Si una dependencia de npm presenta una vulnerabilidad de ejecución remota de código (RCE) o escape de directorios, el atacante obtendrá privilegios absolutos de administrador dentro del contenedor, facilitando el escape a la máquina anfitriona.
# ❌ INSEGURO: Ejecución con privilegios de root
CMD ["node", "server.js"]
# ✅ SEGURO: Usuario de sistema sin privilegios
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
USER nextjs
CMD ["node", "server.js"]Iniciar el contenedor mediante gestores de paquetes: CMD ["npm", "start"]. La utilidad npm intercepta y silencia las señales POSIX sin propagarlas a Node.js. Al detener el contenedor, este se bloquea durante 10 segundos hasta que el sistema lo liquida mediante SIGKILL, interrumpiendo abruptamente las conexiones de los usuarios.
Ejecute siempre el binario directamente: CMD ["node", "server.js"], implementando mecanismos de drenaje ordenado (Graceful Shutdown) en el código del servidor.
Configurar el cortafuegos UFW (abrir solo puertos 22, 80, 443), deshabilitar acceso SSH por clave, activar fail2ban e instalar Docker con Docker Compose.
Declarar output: 'standalone' en next.config.ts, implementar la ruta /api/health y construir un Dockerfile multi-stage ligero.
Crear una red puente aislada, iniciar el contenedor de Traefik con el socket /var/run/docker.sock en modo :ro y configurar certificados Let's Encrypt.
Declarar un Docker Volume persistente vinculado a /app/.next/cache para retener páginas precompiladas y etiquetas entre despliegues.
Configurar canalizaciones en GitHub Actions para compilar imágenes, publicarlas en GHCR y activar conmutaciones automáticas mediante Kamal o webhooks de Coolify.