En el desarrollo Fullstack moderno con Next.js 15, la capa de servidor de la aplicación ha dejado de ser un mero generador de páginas HTML. El App Router opera como un servicio backend de alto rendimiento que interactúa de manera constante con una infraestructura distribuida compleja:
Intentar instalar y coordinar manualmente estos servicios heterogéneos en los entornos de desarrollo locales provoca divergencias de configuración («en mi máquina sí funciona») e incrementa la fricción de incorporación de nuevos ingenieros. Para garantizar la paridad de entornos se utiliza la especificación Docker Compose (Compose Spec v2).
[Topología del Stack Fullstack Local]
Estación de trabajo del desarrollador / Navegador web
(HTTP: localhost:3000, Consola MinIO: localhost:9001)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Docker Network: backend-net (Red puente aislada) │
│ │
│ ┌────────────────────┐ ┌─────────────────────────┐ │
│ │ web (Next.js) │ ─────────► │ postgres (PostgreSQL) │ │
│ │ │ TCP: 5432 │ Volume: postgres_data │ │
│ │ • Server Actions │ └─────────────────────────┘ │
│ │ • Route Handlers │ │
│ │ • RSC SSR │ ─────────► ┌─────────────────────────┐ │
│ │ │ TCP: 6379 │ redis (Redis 7) │ │
│ │ │ │ Volume: redis_data │ │
│ │ │ └─────────────────────────┘ │
│ │ │ │
│ │ │ ─────────► ┌─────────────────────────┐ │
│ │ │ S3: 9000 │ minio (MinIO S3) │ │
│ │ │ │ Volume: minio_data │ │
│ │ │ └────────────▲────────────┘ │
│ └────────────────────┘ │ │
│ ┌────┴────────────┐ │
│ │ minio-init │ │
│ │ Crea los buckets│ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘Los principales desafíos de ingeniería en la orquestación de este clúster radican en el descubrimiento de servicios (Service Discovery) y el orden de arranque condicionado por pruebas de estado (Startup Ordering & Healthchecks).
En una red puente (bridge network), Docker Compose ejecuta un servidor DNS integrado (127.0.0.11) que asocia automáticamente los nombres de servicio (postgres, redis, minio) con sus direcciones IP virtuales. Esto elimina la necesidad de vincular direcciones IP estáticas del host.
El modelo mental de Docker Compose descansa en un grafo declarativo de dependencias condicionado por salud (Health-Conditioned Dependency Graph).
Un error común entre los desarrolladores consiste en creer que la directiva básica depends_on: ['postgres'] asegura que la base de datos está lista para procesar consultas. En la práctica, Docker califica un contenedor como iniciado en el instante exacto en que el kernel de Linux crea el proceso. En ese momento, PostgreSQL apenas comienza a leer los archivos de configuración, comprobar los registros WAL y abrir los sockets de red. Si Next.js arranca simultáneamente y ejecuta las migraciones de Prisma o Drizzle, la aplicación falla con el error ECONNREFUSED.
Para eliminar esta condición de carrera (Race Condition), Compose combina dos instrucciones:
healthcheck: comando ejecutado periódicamente dentro del contenedor (por ejemplo, pg_isready en Postgres o redis-cli ping en Redis). El servicio obtiene el estado healthy únicamente cuando el demonio acepta tráfico TCP.depends_on con la condición condition: service_healthy: bloquea el inicio del servicio dependiente web hasta que los contenedores de infraestructura superen sus pruebas de salud.Arranque del clúster [docker compose up]
│
├─────────────────────────────────┬─────────────────────────────────┐
▼ ▼ ▼
[Postgres: Inicio del demonio] [Redis: Asignación de memoria] [MinIO: Inicio API S3]
│ │ │
[pg_isready: Rechazado...] [redis-cli ping: PONG!] [curl /minio/health: 200]
│ │ │
[pg_isready: 200 OK!] ▼ ▼
│ Estado: HEALTHY Estado: HEALTHY
▼ │ │
Estado: HEALTHY │ │
│ │ │
└─────────────────────────────────┼─────────────────────────────────┘
│ Condiciones de salud satisfechas
▼
[minio-init: Creación de buckets S3]
│ Código de salida 0
▼
[web (Next.js): Inicio de la aplicación]
• Ejecución de migraciones de Prisma
• Conexión de pools a Postgres y Redis
• Atención de tráfico HTTP de usuariosUn segundo concepto indispensable es la resolución de endpoint dual para S3 (Dual-Endpoint Resolution). Debido a que MinIO interactúa tanto con el backend de Next.js (dentro de la red Docker) como con el navegador web del usuario (en la máquina host), la configuración debe diferenciar claramente las llamadas internas del SDK (http://minio:9000) de las URLs prefirmadas externas para subidas directas desde el navegador (http://localhost:9000).
Conceptos esenciales de orquestación:
Un desarrollador recién incorporado clona el repositorio y ejecuta docker compose up -d. En cuestión de segundos dispone de un entorno funcional completo: base de datos con esquema aplicado, instancia de Redis y buckets de S3 listos para operar, sin necesidad de instalar bases de datos en su máquina ni crear cuentas en AWS.
En los ejecutores de GitHub Actions, las suites de pruebas automatizadas con Playwright levantan este stack idéntico mediante Docker Compose. Las pruebas se ejecutan contra una base de datos relacional y un almacenamiento de objetos reales, descartando imprecisiones derivadas de mocks.
Para clientes corporativos con regulaciones de soberanía de datos que prohíben la salida hacia nubes públicas, este stack se despliega como una solución autocontenida y lista para producción en servidores propios.
El manifiesto de producción compose.yaml articula los cuatro servicios estableciendo dependencias condicionadas, volúmenes persistentes y un contenedor sidecar de aprovisionamiento:
Creación de la red puente backend-net y provisión de volúmenes persistentes con nombre.
Inicio concurrente de Postgres, Redis y MinIO, suspendiendo dependencias hasta superar las pruebas de salud.
Ejecución del contenedor minio/mc para configurar el alias, crear el bucket app-uploads y fijar permisos públicos de descarga.
El contenedor web inicia tras confirmar la salud de las bases de datos y vincula el puerto 3000 al host.
compose.yaml de Producción#Alineado con el estándar Compose v2 (donde la directiva version: ha sido declarada obsoleta):
name: agenticlab-fullstack
services:
# ── 1. MOTOR RELACIONAL POSTGRESQL 17 ──
postgres:
image: postgres:17-alpine
container_name: fullstack-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres_dev_secret}
POSTGRES_DB: ${POSTGRES_DB:-agenticlab_db}
ports:
# Exposición de puerto al host para herramientas visuales (DBeaver / DataGrip)
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-agenticlab_db}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
# ── 2. CACHÉ EN MEMORIA REDIS 7 ──
redis:
image: redis:7.4-alpine
container_name: fullstack-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redis_dev_secret}"]
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- backend-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_dev_secret}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 3s
# ── 3. ALMACENAMIENTO DE OBJETOS MINIO S3 ──
minio:
image: minio/minio:RELEASE.2024-11-07T00-52-20Z
container_name: fullstack-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minio_admin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minio_secret_password}
ports:
- "9000:9000" # Endpoint de la API REST de S3
- "9001:9001" # Consola Web de administración de MinIO
volumes:
- minio_data:/data
networks:
- backend-net
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
# ── 4. INICIALIZADOR DE BUCKETS S3 (SIDECAR) ──
minio-init:
image: minio/mc:RELEASE.2024-11-05T11-41-28Z
container_name: fullstack-minio-init
depends_on:
minio:
condition: service_healthy
networks:
- backend-net
entrypoint: >
/bin/sh -c "
echo 'Configurando alias de cliente MinIO...';
/usr/bin/mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY:-minio_admin} ${S3_SECRET_KEY:-minio_secret_password};
echo 'Creando bucket app-uploads...';
/usr/bin/mc mb myminio/app-uploads --ignore-existing;
echo 'Estableciendo política de descarga pública...';
/usr/bin/mc anonymous set download myminio/app-uploads;
echo 'Inicialización de MinIO completada con éxito.';
exit 0;
"
# ── 5. APLICACIÓN NEXT.JS 15 (WEB) ──
web:
build:
context: .
dockerfile: Dockerfile
target: runner
container_name: fullstack-web
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: 3000
# Nombres DNS internos en la red puente backend-net
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres_dev_secret}@postgres:5432/${POSTGRES_DB:-agenticlab_db}?schema=public"
REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_dev_secret}@redis:6379"
# Endpoint S3 para peticiones internas desde Node.js
S3_ENDPOINT: "http://minio:9000"
# Endpoint S3 público para descargas y subidas directas desde el navegador
NEXT_PUBLIC_S3_PUBLIC_URL: "http://localhost:9000"
S3_BUCKET: "app-uploads"
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-minio_admin}
S3_SECRET_KEY: ${S3_SECRET_KEY:-minio_secret_password}
S3_REGION: "us-east-1"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio-init:
condition: service_completed_successfully
networks:
- backend-net
networks:
backend-net:
driver: bridge
volumes:
postgres_data:
name: fullstack_postgres_data
redis_data:
name: fullstack_redis_data
minio_data:
name: fullstack_minio_dataUtilizamos el paquete oficial @aws-sdk/client-s3. El código de servidor se comunica internamente mediante http://minio:9000, mientras que las URLs prefirmadas se reescriben hacia http://localhost:9000 para que el navegador efectúe la subida directa:
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Cliente de servidor para llamadas internas dentro de la red Docker (Next.js -> MinIO)
export const s3ServerClient = new S3Client({
region: process.env.S3_REGION || 'us-east-1',
endpoint: process.env.S3_ENDPOINT || 'http://minio:9000',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || '',
secretAccessKey: process.env.S3_SECRET_KEY || '',
},
// Requisito indispensable para MinIO: forzar rutas tipo path-style (http://host/bucket)
forcePathStyle: true,
});
/**
* Genera una URL prefirmada para permitir la subida directa y segura desde el navegador
*/
export async function createPresignedUploadUrl(
fileKey: string,
contentType: string,
expiresInSeconds = 300
): Promise<{ uploadUrl: string; finalPublicUrl: string }> {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET || 'app-uploads',
Key: fileKey,
ContentType: contentType,
});
// Generar la URL prefirmada interna
const rawUrl = await getSignedUrl(s3ServerClient, command, {
expiresIn: expiresInSeconds,
});
// Reemplazar el host interno minio:9000 por el host público accesible por el navegador
const publicOrigin = process.env.NEXT_PUBLIC_S3_PUBLIC_URL || 'http://localhost:9000';
const internalOrigin = process.env.S3_ENDPOINT || 'http://minio:9000';
const uploadUrl = rawUrl.replace(internalOrigin, publicOrigin);
const finalPublicUrl = `${publicOrigin}/${process.env.S3_BUCKET}/${fileKey}`;
return { uploadUrl, finalPublicUrl };
}Declarar depends_on: [postgres] en el archivo compose.yaml. Al ejecutar docker compose up, el contenedor web falla de inmediato con PrismaClientInitializationError: Can't reach database server at postgres:5432.
Causa habitual: Creer que depends_on espera a la inicialización completa del demonio de base de datos.
Realidad en producción: El servicio de aplicación arranca antes de que Postgres esté preparado para aceptar negociaciones TCP.
# ❌ INCORRECTO: Solo valida la creación del contenedor, no su disponibilidad real
depends_on:
- postgres
# ✅ CORRECTO: Detiene el inicio hasta que la prueba de salud confirme estado healthy
depends_on:
postgres:
condition: service_healthyEl navegador del usuario intenta subir un archivo a la URL devuelta por la Server Action: PUT http://minio:9000/app-uploads/photo.jpg y falla con net::ERR_NAME_NOT_RESOLVED.
Causa habitual: El SDK de AWS construye la URL utilizando el parámetro endpoint configurado en S3Client, ignorando la separación de redes entre el host y los contenedores.
Realidad en producción: El navegador del cliente opera fuera de la red Docker y no puede resolver el nombre DNS interno minio. La URL debe apuntar a localhost:9000.
// ❌ INCORRECTO: Devuelve una URL con el host interno inalcanzable para el navegador
const uploadUrl = await getSignedUrl(s3Client, command); // devuelve http://minio:9000/...
// ✅ CORRECTO: Sustituir el origen interno por la dirección pública del host
const uploadUrl = rawUrl.replace(process.env.S3_ENDPOINT, process.env.NEXT_PUBLIC_S3_PUBLIC_URL);Las consultas dirigidas a MinIO arrojan errores ENOTFOUND app-uploads.minio.
Causa habitual: Por defecto, el SDK de AWS emplea URLs con formato virtual-hosted (http://bucket-name.s3.amazonaws.com).
Realidad en producción: El cliente intenta resolver el subdominio app-uploads.minio, el cual no existe en el DNS interno de Docker. MinIO requiere un direccionamiento tipo path-style (http://minio:9000/app-uploads).
// ❌ INCORRECTO: forcePathStyle desactivado por defecto
const client = new S3Client({ endpoint: 'http://minio:9000' });
// ✅ CORRECTO: Obligar al cliente a emplear la sintaxis path-style
const client = new S3Client({
endpoint: 'http://minio:9000',
forcePathStyle: true,
});Configurar ./data/postgres:/var/lib/postgresql/data en lugar de volúmenes con nombre postgres_data:/var/lib/postgresql/data.
Causa habitual: Intentar inspeccionar los archivos de la base de datos directamente desde el explorador del sistema operativo.
Realidad en producción: Caída drástica del rendimiento de lectura/escritura en disco debido a las capas de traducción del sistema de archivos (WSL2 / virtiofs), además de inconsistencias de permisos y corrupción de páginas WAL en PostgreSQL.
# ❌ ANTIPATRÓN: Montaje directo de ruta de host para bases de datos
volumes:
- ./local-db-data:/var/lib/postgresql/data
# ✅ CORRECTO: Volumen con nombre gestionado por Docker con rendimiento nativo de Linux
volumes:
- postgres_data:/var/lib/postgresql/dataDefinición de credenciales para Postgres, tokens para Redis y claves S3 en el archivo .env.
Ejecución de docker compose up -d para levantar la red puente y montar los volúmenes con nombre.
Compose detiene Next.js hasta que las pruebas de Postgres y Redis alcancen el estado healthy.
El contenedor minio-init crea app-uploads y establece la política de descarga pública.
El contenedor web inicia en el puerto 3000, conectándose a bases de datos verificadas.