Saltar al contenido principal
Volver al blog

Despliega Next.js con Docker: del desarrollo a producción

Ray MartínRay Martín
11 min de lectura
Despliega Next.js con Docker: del desarrollo a producción

Por que Docker para Next.js

Docker resuelve el clasico problema de "funciona en mi maquina" empaquetando tu aplicacion Next.js junto con todo su entorno de ejecucion en un contenedor portable. Ya sea que ejecutes tu aplicacion en el portatil de un desarrollador, en un pipeline de CI/CD o en un servidor de produccion, el comportamiento es identico porque el contenedor incluye cada dependencia, configuracion y libreria del sistema que tu aplicacion necesita.

Aunque plataformas como Vercel proporcionan despliegues sin configuracion para Next.js, Docker te da control total sobre tu infraestructura. Esto es esencial cuando necesitas:

  • Auto-alojar en tu propia infraestructura: Desplegar en AWS, Google Cloud, DigitalOcean o servidores propios
  • Garantizar reproducibilidad: Asegurar que el mismo artefacto de compilacion se ejecuta en cada entorno
  • Orquestar multiples servicios: Ejecutar tu app Next.js junto a bases de datos, caches y workers en segundo plano
  • Cumplir requisitos de compliance: Mantener control sobre donde se almacenan y procesan tus datos
  • Integrar con CI/CD existente: Encajar en pipelines basados en Docker con herramientas como GitHub Actions, GitLab CI o Jenkins
  • Escalar horizontalmente: Desplegar multiples replicas de contenedores detras de un balanceador de carga usando Kubernetes o Docker Swarm

Fundamentos del Dockerfile

Un Dockerfile es un archivo de texto con instrucciones que Docker utiliza para construir una imagen. Cada instruccion crea una capa en la imagen, y Docker cachea estas capas para acelerar compilaciones posteriores. Comprender las instrucciones clave es esencial para crear contenedores Next.js eficientes.

dockerfile
# Dockerfile simple de una sola etapa para Next.js
FROM node:20-alpine

WORKDIR /app

# Copiar archivos de paquete primero para mejor cacheo de capas
COPY package.json package-lock.json ./

# Instalar dependencias
RUN npm ci

# Copiar el resto del codigo de la aplicacion
COPY . .

# Compilar la aplicacion Next.js
RUN npm run build

# Exponer el puerto en el que escucha Next.js
EXPOSE 3000

# Establecer el comando por defecto
CMD ["npm", "start"]

Instrucciones clave del Dockerfile explicadas:

  • FROM: Establece la imagen base — node:20-alpine es una imagen minima de Node.js basada en Alpine Linux
  • WORKDIR: Establece el directorio de trabajo dentro del contenedor — todos los comandos posteriores se ejecutan desde esta ruta
  • COPY: Copia archivos del host al interior de la imagen del contenedor
  • RUN: Ejecuta un comando durante el proceso de compilacion — se usa para instalar dependencias y compilar
  • EXPOSE: Documenta en que puerto escucha el contenedor (no publica el puerto realmente)
  • CMD: Especifica el comando por defecto a ejecutar cuando el contenedor arranca

Importante: Siempre copia package.json y package-lock.json antes de copiar el resto del codigo fuente. Docker cachea cada capa, y dado que las dependencias cambian con menos frecuencia que el codigo fuente, este patron evita reinstalar todas las dependencias en cada compilacion.

Compilaciones Multi-Etapa para Imagenes Optimizadas

Un Dockerfile de una sola etapa incluye todas las herramientas de compilacion, dependencias de desarrollo y codigo fuente en la imagen final. Las compilaciones multi-etapa resuelven esto usando etapas separadas para instalar dependencias, compilar y ejecutar. La imagen final solo contiene lo necesario en tiempo de ejecucion, reduciendo drasticamente su tamano.

dockerfile
# Etapa 1: Instalar dependencias
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --only=production &&     cp -R node_modules /prod_node_modules &&     npm ci

# Etapa 2: Compilar la aplicacion
FROM node:20-alpine AS builder
WORKDIR /app

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

# Desactivar telemetria de Next.js durante la compilacion
ENV NEXT_TELEMETRY_DISABLED=1

RUN npm run build

# Etapa 3: Runner de produccion
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Crear un usuario no-root por seguridad
RUN addgroup --system --gid 1001 nodejs &&     adduser --system --uid 1001 nextjs

# Copiar solo los archivos necesarios de la etapa de compilacion
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# Establecer la propiedad correcta
RUN chown -R nextjs:nodejs /app

# Cambiar al usuario no-root
USER nextjs

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

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

Este enfoque de tres etapas produce una imagen final que es tipicamente un 80-90% mas pequena que una compilacion de una sola etapa. La imagen de produccion contiene solo el runtime de Node.js, tu aplicacion compilada y las dependencias de produccion.

Desglose de etapas

  1. Etapa deps: Instala tanto dependencias de produccion como de desarrollo. Copia las dependencias solo de produccion por separado para usar en la etapa final
  2. Etapa builder: Copia el codigo fuente y todas las dependencias, luego ejecuta el proceso de compilacion de Next.js para producir la salida optimizada
  3. Etapa runner: Comienza desde una imagen Alpine limpia, copia solo la salida standalone, archivos estaticos y assets publicos. Se ejecuta como usuario no-root por seguridad

Modo de Salida Standalone

El modo de salida standalone de Next.js es la clave para crear imagenes Docker minimas. Cuando se activa, Next.js rastrea todos los modulos importados y crea una salida autocontenida que incluye solo los archivos necesarios para ejecutar la aplicacion — sin necesidad del directorio node_modules.

typescript
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
  // Opcional: reducir el tamano de imagen aun mas desactivando
  // la optimizacion de imagenes si la manejas externamente (ej. via CDN)
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.example.com",
      },
    ],
  },
};

export default nextConfig;

Con la salida standalone activada, la compilacion produce un directorio .next/standalone que contiene un archivo server.js minimo y solo los modulos de Node.js que tu aplicacion realmente importa. Esto tipicamente reduce el tamano del despliegue de varios cientos de megabytes a menos de 50MB.

Nota: La salida standalone no incluye la carpeta public/ ni el directorio .next/static. Debes copiarlos manualmente en tu Dockerfile, como se muestra en el ejemplo de compilacion multi-etapa anterior.

El archivo .dockerignore

Un archivo .dockerignore indica a Docker que archivos excluir al copiar el contexto de compilacion en el contenedor. Esto acelera las compilaciones y previene que archivos sensibles o innecesarios terminen en tu imagen.

plaintext
# .dockerignore
node_modules
.next
.git
.gitignore
*.md
LICENSE
.env
.env.*
.vscode
.idea
.DS_Store
Thumbs.db
docker-compose*.yml
Dockerfile*
.dockerignore
npm-debug.log*
yarn-debug.log*
yarn-error.log*
coverage
.nyc_output
__tests__
*.test.ts
*.test.tsx
*.spec.ts
*.spec.tsx
  • node_modules: Las dependencias se instalan dentro del contenedor — nunca copies los node_modules del host
  • .next: La salida de compilacion se genera dentro del contenedor durante RUN npm run build
  • .git: El historial de Git es innecesario en el contenedor y puede ser muy grande
  • Archivos .env: Las variables de entorno deben inyectarse en tiempo de ejecucion, nunca incorporarse en la imagen
  • Archivos de test: Los tests no pertenecen a las imagenes de produccion

Docker Compose para Desarrollo Local

Docker Compose te permite definir y ejecutar configuraciones multi-contenedor con un solo comando. Para desarrollo local, puedes ejecutar tu aplicacion Next.js junto a una base de datos PostgreSQL, cache Redis y cualquier otro servicio del que dependa tu app.

yaml
# docker-compose.yml
version: "3.9"

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
      - /app/.next
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
      - REDIS_URL=redis://redis:6379
      - NEXT_PUBLIC_ENABLE_CONTACT_FORM=true
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    command: npm run dev

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes

volumes:
  postgres_data:
  redis_data:

Dockerfile de desarrollo

dockerfile
# Dockerfile.dev — optimizado para desarrollo con hot reload
FROM node:20-alpine

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

EXPOSE 3000

CMD ["npm", "run", "dev"]

Caracteristicas clave de esta configuracion de Docker Compose:

  • Montaje de volumenes: El montaje .:/app habilita el hot reloading — los cambios en tu host se reflejan en el contenedor inmediatamente
  • Volumenes anonimos: /app/node_modules y /app/.next se excluyen del montaje del host para que el contenedor use sus propias versiones
  • Dependencias de servicios: El depends_on con condition: service_healthy asegura que la base de datos este lista antes de que la app arranque
  • Health checks: PostgreSQL incluye un health check que verifica que la base de datos acepta conexiones
  • Datos persistentes: Los volumenes nombrados (postgres_data, redis_data) persisten datos entre reinicios del contenedor
bash
# Iniciar todos los servicios
docker compose up -d

# Ver logs
docker compose logs -f app

# Detener todos los servicios
docker compose down

# Detener y eliminar volumenes (resetear datos)
docker compose down -v

# Recompilar despues de cambios en dependencias
docker compose up -d --build

Variables de Entorno y Docker Secrets

Las variables de entorno en Docker pueden pasarse en tiempo de compilacion o de ejecucion. Para aplicaciones Next.js, es critico entender la diferencia entre variables de tiempo de compilacion y de ejecucion.

dockerfile
# Variables de tiempo de compilacion (disponibles durante npm run build)
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_ENABLE_CONTACT_FORM

# Variables de tiempo de ejecucion (disponibles cuando el contenedor esta corriendo)
ENV NODE_ENV=production
ENV PORT=3000

Las variables con prefijo NEXT_PUBLIC_ se incrustan en el JavaScript del lado del cliente durante la compilacion. Esto significa que deben estar disponibles como argumentos de compilacion, no solo como variables de entorno en tiempo de ejecucion.

bash
# Compilar con variables de entorno publicas
docker build   --build-arg NEXT_PUBLIC_API_URL=https://api.example.com   --build-arg NEXT_PUBLIC_ENABLE_CONTACT_FORM=true   -t myapp:latest .

# Ejecutar con variables de entorno del lado del servidor
docker run -d   -p 3000:3000   -e MAILJET_API_KEY=tu_clave   -e MAILJET_API_SECRET=tu_secreto   -e MAILJET_SENDER_EMAIL=hello@raymartin.es   -e DATABASE_URL=postgresql://user:pass@host:5432/db   myapp:latest

Seguridad: Nunca uses ENV para secretos en tu Dockerfile. Los valores establecidos con ENV se incorporan en las capas de la imagen y pueden ser extraidos. Usa docker run -e o Docker secrets para valores sensibles.

Docker Secrets con Compose

yaml
# docker-compose.prod.yml
version: "3.9"

services:
  app:
    image: myapp:latest
    ports:
      - "3000:3000"
    secrets:
      - mailjet_api_key
      - mailjet_api_secret
      - db_url
    environment:
      - MAILJET_API_KEY_FILE=/run/secrets/mailjet_api_key
      - MAILJET_API_SECRET_FILE=/run/secrets/mailjet_api_secret
      - DATABASE_URL_FILE=/run/secrets/db_url

secrets:
  mailjet_api_key:
    file: ./secrets/mailjet_api_key.txt
  mailjet_api_secret:
    file: ./secrets/mailjet_api_secret.txt
  db_url:
    file: ./secrets/db_url.txt

Health Checks

Los health checks permiten a Docker y a los orquestadores de contenedores saber si tu aplicacion esta funcionando correctamente. Si un health check falla, el contenedor puede reiniciarse o reemplazarse automaticamente.

dockerfile
# Agregar health check al Dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3   CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1

Crea una ruta API simple de health check en tu aplicacion Next.js:

typescript
// app/api/health/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  try {
    // Opcionalmente verificar conectividad con la base de datos
    // await db.query("SELECT 1");

    return NextResponse.json(
      {
        status: "healthy",
        timestamp: new Date().toISOString(),
        uptime: process.uptime(),
        version: process.env.APP_VERSION || "desconocido",
      },
      { status: 200 }
    );
  } catch (error) {
    return NextResponse.json(
      {
        status: "unhealthy",
        error: error instanceof Error ? error.message : "Error desconocido",
      },
      { status: 503 }
    );
  }
}

export const dynamic = "force-dynamic";
  • interval: Con que frecuencia ejecutar el health check (30 segundos es un buen valor por defecto)
  • timeout: Tiempo maximo de espera para una respuesta antes de considerar el check como fallido
  • start-period: Periodo de gracia despues del inicio del contenedor durante el cual los fallos no se contabilizan — da tiempo a tu app para inicializarse
  • retries: Numero de fallos consecutivos necesarios antes de marcar el contenedor como no saludable

Compilacion y Push a un Container Registry

Un container registry almacena tus imagenes Docker para que puedan ser descargadas por los destinos de despliegue. Los registros mas populares incluyen Docker Hub, GitHub Container Registry (GHCR), AWS ECR y Google Artifact Registry.

bash
# Compilar la imagen con una etiqueta
docker build -t ghcr.io/raymartin/myapp:latest .
docker build -t ghcr.io/raymartin/myapp:v1.2.3 .

# Autenticarse en GitHub Container Registry
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin

# Subir la imagen
docker push ghcr.io/raymartin/myapp:latest
docker push ghcr.io/raymartin/myapp:v1.2.3

Compilaciones automatizadas con GitHub Actions

yaml
# .github/workflows/docker-build.yml
name: Compilar y Subir Imagen Docker

on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout del repositorio
        uses: actions/checkout@v4

      - name: Configurar Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Iniciar sesion en GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extraer metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha,prefix=

      - name: Compilar y subir
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            NEXT_PUBLIC_ENABLE_CONTACT_FORM=true

Despliegue en Plataformas Cloud

AWS ECS (Elastic Container Service)

json
{
  "family": "nextjs-app",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "containerDefinitions": [
    {
      "name": "nextjs",
      "image": "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest",
      "portMappings": [
        {
          "containerPort": 3000,
          "protocol": "tcp"
        }
      ],
      "environment": [
        { "name": "NODE_ENV", "value": "production" },
        { "name": "PORT", "value": "3000" }
      ],
      "secrets": [
        {
          "name": "DATABASE_URL",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:db-url"
        }
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 15
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/nextjs-app",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
}

Google Cloud Run

bash
# Compilar y subir a Google Artifact Registry
gcloud builds submit --tag gcr.io/my-project/myapp:latest

# Desplegar en Cloud Run
gcloud run deploy myapp   --image gcr.io/my-project/myapp:latest   --platform managed   --region us-central1   --port 3000   --memory 512Mi   --cpu 1   --min-instances 0   --max-instances 10   --set-env-vars NODE_ENV=production   --set-secrets DATABASE_URL=db-url:latest,MAILJET_API_KEY=mailjet-key:latest   --allow-unauthenticated

DigitalOcean App Platform

yaml
# .do/app.yaml
name: nextjs-app
region: nyc

services:
  - name: web
    dockerfile_path: Dockerfile
    github:
      repo: raymartin/myapp
      branch: main
      deploy_on_push: true
    http_port: 3000
    instance_count: 2
    instance_size_slug: professional-xs
    health_check:
      http_path: /api/health
      initial_delay_seconds: 15
      period_seconds: 30
    envs:
      - key: NODE_ENV
        value: production
      - key: DATABASE_URL
        type: SECRET
        value: "${db.DATABASE_URL}"

databases:
  - name: db
    engine: PG
    version: "16"
    size: db-s-1vcpu-1gb
    num_nodes: 1

Optimizacion para Produccion

Imagenes Alpine para tamano minimo

Las imagenes de Node.js basadas en Alpine son significativamente mas pequenas que las imagenes basadas en Debian por defecto. La imagen node:20-alpine tiene aproximadamente 50MB comparada con los 350MB de node:20.

bash
# Comparar tamanos de imagen
docker images --format "table {{.Repository}}	{{.Tag}}	{{.Size}}"

# REPOSITORY        TAG              SIZE
# myapp             debian           1.2GB
# myapp             alpine           180MB
# myapp             alpine-standalone 85MB

Estrategias de cacheo de capas

dockerfile
# Orden optimo de capas para eficiencia del cache
FROM node:20-alpine AS deps
WORKDIR /app

# 1. Copiar solo archivos de paquete (cambian raramente)
COPY package.json package-lock.json ./
RUN npm ci

# 2. Copiar archivos de configuracion (cambian ocasionalmente)
COPY next.config.ts tsconfig.json tailwind.config.ts postcss.config.js ./

# 3. Copiar codigo fuente (cambia frecuentemente)
COPY app/ ./app/
COPY components/ ./components/
COPY content/ ./content/
COPY hooks/ ./hooks/
COPY messages/ ./messages/
COPY public/ ./public/
COPY routes/ ./routes/
COPY styles/ ./styles/
COPY utils/ ./utils/
COPY middleware.ts i18n.ts environment.d.ts ./

RUN npm run build

Escaneo de seguridad

bash
# Escanear imagen en busca de vulnerabilidades con Docker Scout
docker scout cves myapp:latest

# Escanear con Trivy (codigo abierto)
trivy image myapp:latest

# Escanear con Snyk
snyk container test myapp:latest

Mejores practicas de seguridad para contenedores Docker en produccion:

  • Ejecutar como no-root: Siempre crea y cambia a un usuario no-root en tu Dockerfile
  • Usar etiquetas especificas: Fija tu imagen base a una version especifica como node:20.11-alpine en lugar de node:20-alpine
  • Escanear regularmente: Integra el escaneo de vulnerabilidades en tu pipeline de CI/CD
  • Minimizar superficie de ataque: Usa compilaciones multi-etapa para excluir herramientas de compilacion de la imagen final
  • Actualizar imagenes base: Recompila regularmente con imagenes base actualizadas para incorporar parches de seguridad
  • Sistema de archivos de solo lectura: Monta el sistema de archivos raiz como solo lectura cuando sea posible usando --read-only
  • Sin secretos en imagenes: Nunca almacenes credenciales, claves API o tokens en las capas de la imagen

Consejo profesional: Combina compilaciones multi-etapa con el modo de salida standalone para obtener la imagen de produccion mas pequena posible. Una imagen Docker de Next.js bien optimizada puede tener menos de 100MB, lo que significa despliegues mas rapidos, menores costes de almacenamiento y tiempos de arranque de contenedor mas rapidos. Ejecuta docker images despues de cada optimizacion para medir el impacto de tus cambios.

Docker te da propiedad total de tu pipeline de despliegue. Combinando compilaciones multi-etapa, salida standalone, health checks y mejores practicas de seguridad, puedes desplegar aplicaciones Next.js en cualquier infraestructura con confianza. Ya sea que elijas AWS, Google Cloud, DigitalOcean o tus propios servidores, la aplicacion containerizada se comporta de forma identica en todas partes.

Compartir:

Artículos relacionados