Todos los recursos

Un recorrido de principio a fin del patrón exacto que uso en ilustrari.com: un contenedor Next.js que no expone nada al exterior, un proxy Traefik dueño del 80/443 y un certificado Let's Encrypt que se emite y se renueva solo. Terminas con un deploy funcionando y una idea clara de por qué cada label está donde está.

Despliega Next.js detrás de Traefik con TLS automático

En resumen

  • Un solo proxy Traefik es dueño de los puertos 80/443 y termina el TLS; la app Next.js no expone nada al host.
  • Traefik descubre las rutas a partir de labels de Docker, así que desplegar una app es agregar cuatro labels, no tocar un config central.
  • Let's Encrypt emite y renueva solo mediante un resolver ACME. El certificado sale como efecto secundario de los labels.
  • Un Dockerfile multi-stage con output: standalone mantiene la imagen liviana y el runtime sin root.
  • El puerto interno de la app (3000) va en el label del loadbalancer, nunca en un mapeo de ports. En eso se resume toda la seguridad.

Subir una app de Next.js a un servidor suena trivial hasta que te topas con las tres cosas que de verdad cuestan tiempo: enrutar un dominio al contenedor correcto, conseguir un certificado TLS real sin tener que renovarlo a mano, y no exponer sin querer el puerto de tu app a todo internet. Esta guía recorre el patrón exacto que uso en ilustrari.com (Traefik al frente, un contenedor por app, Let's Encrypt en piloto automático) y te explica el porqué de cada pieza para que puedas depurarlo cuando, no si, algo se ponga raro. Al final vas a tener un dominio sirviendo HTTPS, un certificado que se renueva solo y una app cuya única puerta al exterior es el proxy.

Esto es un tutorial y un por qué a la vez. Te dejo config lista para copiar y pegar, pero también te digo qué label hace el trabajo de verdad y dónde se esconden las fallas más comunes. Si alguna vez peleaste con un 404 de un reverse proxy o viste un challenge de ACME quedarse colgado hasta el timeout, la idea es que eso deje de ser un misterio.

01 · Requisitos previos y el modelo mental

Antes de tirar un solo comando, ten claro el modelo, porque explica cada decisión de config que viene después. El servidor hace exactamente dos trabajos. Corre un reverse proxy (Traefik) que es dueño de los puertos 80 y 443 y termina el TLS de todo. Y corre un contenedor por app, y ninguno publica un puerto al host. El proxy es el único proceso con el que internet habla; la app vive en una red privada de Docker y, vista desde afuera, es invisible.

Primero necesitas tener unas cuantas cosas en su sitio:

  1. Un VPS al que puedas entrar por SSH, con Docker y el plugin de Docker Compose instalados.
  2. Un dominio, con un registro A apuntando a la IP pública del VPS. Esto tiene que resolver antes de desplegar, porque el challenge HTTP-01 de Let's Encrypt falla si el nombre todavía no apunta a la máquina.
  3. Los puertos 80 y 443 abiertos en el firewall y sin que ningún otro proceso los esté ocupando.
  4. Traefik ya corriendo con un resolver ACME (Let's Encrypt) y una red externa de Docker. A lo largo de la guía doy por hecho que el resolver se llama le y la red se llama edge, así que ajústalo a lo que tu Traefik use de verdad.

Importante

DNS primero, siempre. El motivo número uno de que un certificado nunca se emita es que el registro A se agregó al mismo tiempo que el deploy y todavía no había propagado. Confirma con dig +short example.com que el nombre devuelve la IP de tu VPS antes de levantar nada.

Si todavía no tienes Traefik corriendo, móntalo una sola vez como su propio proyecto de Compose, dueño de la red edge y del resolver ACME; de ahí en adelante cada app solo se engancha. El resto de la guía asume que ese setup inicial ya está hecho.

02 · Un Dockerfile liviano y sin root

Una imagen de Next.js hecha a la ligera termina siendo enorme y corre como root. Las dos cosas se arreglan en un solo Dockerfile multi-stage. La clave es el modo de output standalone de Next.js, que rastrea exactamente los archivos que la app necesita en runtime y te deja enviar una imagen runner sin el árbol completo de node_modules.

Primero, actívalo en tu config de Next.js:

// next.config.ts
import type { NextConfig } from 'next'

const config: NextConfig = {
  output: 'standalone',
}

export default config

Después el Dockerfile hace el build en una etapa y corre en otra etapa liviana y sin privilegios:

# etapa de build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# etapa de runtime
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER app
EXPOSE 3000
CMD ["node", "server.js"]

Vale la pena señalar algunas decisiones. La etapa de build carga toda la toolchain y las dependencias; la de runtime copia solo el output rastreado, así que la imagen final pesa una fracción. La línea EXPOSE 3000 es documentación y cableado interno de la red. No publica el puerto al host. Y correr como el usuario sin privilegios app significa que, si alguien compromete el contenedor, no arranca con permisos de root.

Consejo

El modo standalone no copia las carpetas public ni .next/static por ti, por eso se copian a mano. Si se te olvidan, la app arranca pero no sirve imágenes ni CSS, una forma bien confusa de perder una tarde entera.

03 · El archivo de Compose y los cuatro labels que importan

Aquí tienes la app como un servicio de Compose. No hay sección ports, y esa ausencia es justamente la clave del asunto.

services:
  web:
    build: .
    restart: unless-stopped
    networks: [edge]
    labels:
      - traefik.enable=true
      - traefik.http.routers.web.rule=Host(`example.com`)
      - traefik.http.routers.web.entrypoints=websecure
      - traefik.http.routers.web.tls.certresolver=le
      - traefik.http.services.web.loadbalancer.server.port=3000
networks:
  edge:
    external: true

Lee los labels uno por uno, porque cada uno corresponde a un concepto:

  • traefik.enable=true mete este contenedor en juego. Con Traefik configurado en exposed-by-default false, un contenedor se ignora hasta que diga lo contrario, un default seguro que evita exposiciones accidentales.
  • routers.web.rule=Host(example.com) es la condición de match: las requests para ese hostname se enrutan aquí. Pon tu dominio. El nombre del router (web) es una clave arbitraria; mantenla única por app.
  • routers.web.entrypoints=websecure amarra el router al entrypoint de HTTPS (puerto 443). No va a responder en HTTP plano a menos que le agregues un redirect, que es lo que quieres.
  • routers.web.tls.certresolver=le es la línea que dispara la emisión del certificado. Traefik la ve, le pide a Let's Encrypt un cert para el host de la regla, resuelve el challenge y guarda el resultado. El certificado sale, literalmente, como efecto secundario de este único label.
  • services.web.loadbalancer.server.port=3000 le dice a Traefik a qué puerto interno reenviar por la red edge. Aquí es donde vive el puerto del contenedor, no en un mapeo al host.

Por qué nada de mapeo de ports

Si pusieras ports: ["3000:3000"] abrirías un hueco en el firewall del host directo hasta la app, saltándote el TLS y Traefik por completo. Todo el modelo depende de que a la app solo se le pueda llegar a través del proxy en la red privada. Traefik se conecta al contenedor por edge usando el puerto del loadbalancer; el host nunca necesita enterarse de que el puerto 3000 existe.

04 · Levántalo y observa cómo se emite el certificado

Con el DNS resolviendo y el archivo guardado, el deploy es un solo comando:

docker compose up -d --build

Esto hace el build de la imagen y arranca el contenedor en segundo plano. Traefik detecta el contenedor nuevo en uno o dos segundos, lee sus labels y arranca el flujo de ACME para example.com. Míralo en el contenedor de Traefik, no en el de la app:

  1. Corre docker logs -f traefik (usa el nombre de tu contenedor de Traefik).
  2. Busca cómo arranca el challenge de ACME y luego una línea que confirme que el certificado se obtuvo para tu dominio. En un setup sano esto toma segundos.
  3. Entra a https://example.com (opens in new tab) en un navegador. Deberías ver tu app sobre HTTPS con un certificado válido de Let's Encrypt y sin ninguna advertencia.
  4. Confirma que el HTTP plano se comporta como esperas, por lo general un redirect a HTTPS si configuraste uno del lado de Traefik.

Un ejemplo paso a paso

Digamos que el dominio es proyeccion.example.com y Traefik ya corre con el resolver le en la red edge. Agregas el registro A, esperas a que dig +short proyeccion.example.com devuelva la IP del VPS, pones la regla Host con ese subdominio, corres docker compose up -d --build y le haces tail a los logs de Traefik. En unos segundos el cert se emite y el subdominio sirve HTTPS. Desplegar la siguiente app, digamos una segunda herramienta en agent-orchestra.example.com, son los mismos cuatro labels, con otro nombre de router y otro host. No hay que tocar ningún archivo central. Esa repetibilidad es la razón por la que el patrón escala a un puñado de apps en una sola máquina.

Nota

La renovación es automática. Traefik revisa sus certificados guardados y los renueva bastante antes de que venzan, a través del mismo resolver. No corres ningún cron job ni te cae una llamada a las 3 de la mañana por un cert vencido, siempre y cuando el volumen de almacenamiento de ACME sobreviva a los reinicios.

05 · Errores comunes y cómo leerlos

Casi todas las fallas aquí caen en un conjunto pequeño y conocido. Si identificas el síntoma, llegas directo a la causa.

  • El certificado nunca se emite. Casi siempre es DNS o el puerto 80 bloqueado. El challenge HTTP-01 de ACME necesita llegar a la máquina por el puerto 80 para el dominio de la regla. Verifica que el registro A resuelva y que nada más esté ocupando el 80.
  • Traefik devuelve un 404 para tu host. La regla del router no hace match, o falta traefik.enable=true, o el contenedor no está en la red edge. Revisa que se cumplan las tres cosas; un typo en la regla Host() es el sospechoso de siempre.
  • 502 Bad Gateway. Traefik encontró el router pero no logra llegar a la app. El loadbalancer.server.port no coincide con el puerto donde la app escucha de verdad, o la app se cayó al arrancar. Revisa docker logs del contenedor de la app.
  • La app carga pero sin estilos ni imágenes. Se te olvidó copiar public y .next/static en el Dockerfile (mira la sección 02), o la app no está en modo standalone.
  • Te topas con un límite de peticiones de ACME mientras pruebas. Let's Encrypt limita la emisión de certificados por dominio. Mientras estás iterando, apunta el resolver al endpoint de staging (pruebas) de Let's Encrypt y pásalo a producción cuando el flujo te funcione de punta a punta.

Atención

Nunca publiques el puerto de la app al host como arreglo rápido para un 502. Va a parecer que funciona, pero acabas de dejar expuesto un puerto sin protección, fuera del TLS y fuera de Traefik. Mejor arregla el puerto del loadbalancer. Un 502 es un bug de enrutamiento, no una excusa para abrir el firewall.

Cuando redespliegas una versión nueva, la regla que te protege es simple: deja que el contenedor nuevo esté sano antes de que el viejo se apague. Haz el build, arranca la imagen nueva, confirma que responde, y deja que Compose reemplace al contenedor viejo. El estado debería vivir fuera de la máquina (en el caso de ilustrari, eso es Supabase) para que el contenedor en sí siga siendo desechable y un redeploy nunca ponga en riesgo tus datos.

Ese es todo el patrón: una app privada, un proxy público y un certificado que se cuida solo. Una vez que la primera app está arriba, cada app que venga después son los mismos cuatro labels y un comando, que es justo el punto. La repetibilidad aburrida es la ventaja. Acierta con el DNS, mantén el puerto fuera del host, y deja que Traefik se encargue de la parte que a nadie le gusta hacer a mano.

Puntos clave

  • DNS primero: confirma que el registro A resuelve al VPS antes de desplegar, o el challenge de ACME va a fallar.
  • Un solo proxy Traefik es dueño del 80/443 y del TLS; la app no expone nada, y esa ausencia de mapeo de ports es, en sí, la seguridad.
  • Cuatro labels hacen todo el trabajo: enable, la regla Host, el entrypoint websecure y el certresolver; el puerto del loadbalancer lleva el 3000 interno.
  • Usa un Dockerfile multi-stage, standalone y sin root, y no se te olvide copiar public y .next/static a mano.
  • Conecta cada síntoma con su causa: el cert nunca se emite es DNS o puerto 80; un 404 es la regla del router o la red; un 502 es el puerto del loadbalancer o una app caída.

Preguntas frecuentes

¿Necesito una instancia de Traefik por cada app?

No, justo al revés. Una sola instancia de Traefik va al frente de todas las apps de la máquina. Es dueña de los puertos 80 y 443 y descubre cada app nueva por los labels de Docker. Correr un proxy por app rompería todo el modelo y volvería a traerte los conflictos de puertos que estás tratando de evitar. Monta Traefik una vez y deja que cada app se enganche a su red.

¿Por qué no simplemente mapear el puerto de la app con ports: 3000:3000?

Porque eso publica el puerto 3000 directo por el firewall del host, saltándose el TLS y Traefik por completo. Cualquiera que le pegue a la IP en ese puerto llega a tu app por HTTP plano, sin certificado y sin reglas de enrutamiento. Toda la gracia del patrón es que a la app solo se le pueda llegar a través del proxy en la red privada. Mejor usa el label loadbalancer.server.port; le dice a Traefik el puerto interno sin tener que exponerlo.

Mi certificado nunca se emite. ¿Qué reviso primero?

DNS y el puerto 80, en ese orden. Corre dig +short example.com y confirma que devuelve la IP de tu VPS antes de desplegar; un registro A recién agregado que todavía no propaga es la causa más común. Luego asegúrate de que nada esté ocupando el puerto 80 y de que el firewall lo deje pasar, porque el challenge HTTP-01 tiene que llegar a la máquina por el 80. Si estás iterando, cambia el resolver al staging de Let's Encrypt para no quemar el límite de peticiones de producción mientras depuras.

Me sale un 502 Bad Gateway. ¿Qué significa aquí?

Traefik encontró el router correcto pero no logra llegar a la app que está detrás. Dos causas típicas: el label loadbalancer.server.port no coincide con el puerto donde la app escucha de verdad (Next.js standalone sirve en 3000 por defecto), o la app se cayó al arrancar. Empieza por revisar docker logs del contenedor de la app. Aguanta las ganas de publicar el puerto al host como parche; un 502 es un problema de enrutamiento, no de firewall.

¿El certificado se renueva solo, o necesito un cron job?

Se renueva solo. Traefik lleva el control de sus certificados guardados y los renueva bastante antes de que venzan, a través del mismo resolver ACME, sin cron job y sin paso manual. Lo único que tienes que acertar es la persistencia: el almacenamiento de ACME (el archivo acme.json o el volumen) tiene que sobrevivir a los reinicios del contenedor. Si es efímero, Traefik vuelve a pedir certs en cada reinicio y tarde o temprano chocas con el límite de peticiones de Let's Encrypt.

¿Por qué output standalone en lugar de un build normal de Next.js en Docker?

El modo standalone rastrea exactamente los archivos que la app necesita en runtime, así que la imagen runner se envía sin el árbol completo de node_modules, una imagen mucho más liviana y rápida de descargar. El detalle es que no copia las carpetas public y .next/static de forma automática; esas las tienes que copiar a mano en el Dockerfile. Si se te olvida, la app arranca pero no sirve assets, una falla bien confusa. Puedes hacer un build sin standalone, pero vas a enviar una imagen más pesada sin ganar nada en este setup.

¿Prefieres que lo hagamos por ti?

Esto mismo lo construimos para negocios como el tuyo. La primera conversación es gratis y sin compromiso.

Escríbenos por WhatsApp

Escríbenos por WhatsApp

Escanéalo con tu teléfono para escribirnos por WhatsApp.

Escanéalo con tu teléfono para escribirnos por WhatsApp.

¿Estás desde el teléfono y no puedes escanear? Escríbenos a info@ilustrari.com

Primera conversación gratis. Te responde el fundador.

Recursos relacionados

GuíaDocker

Autoaloja todo tu stack en un solo VPS

No te hace falta una plataforma gestionada por cada servicio para llevar una operación pequeña en serio. Un solo VPS con Docker Compose y un reverse proxy Traefik corre varias apps sin enredos: HTTPS automático, ruteo por hostname, sin un panel de control por servicio, por lo que cuesta una suscripción de café. Esta guía te lleva por el montaje completo, un ejemplo real y el fallo que de verdad te golpea: los backups.

24 abr 202613 min de lectura
ArtículoDocker

Un solo VPS, muchas apps: apuntes de cómo hospedo mi stack de IA

ilustrari.com y varios proyectos paralelos corren en un solo VPS: un contenedor Docker por app, Traefik al frente, TLS automático y redeploys que apenas tumban un request. Aquí te muestro el armado completo, el razonamiento detrás, la postura de seguridad y el punto exacto donde deja de ser buena idea.

2 jun 202611 min de lectura
GuíaGitHub Actions

Despliegues casi sin caída a un VPS desde GitHub Actions

El docker compose up a secas detiene el contenedor viejo antes de que el nuevo esté listo, y cada request que cae en ese hueco recibe un rechazo. Este es el patrón de build-and-ship que uso en un solo VPS: Actions construye la imagen, la sube a un registro y luego entra por SSH para cambiar el contenedor. Con un health check de verdad y tags por SHA, el hueco se reduce a casi nada y un deploy malo se revierte en una línea. Terminas con un pipeline funcionando y claridad sobre dónde se esconde de verdad la caída.

3 may 202613 min de lectura