← Blog

Desplegar Next.js en Hetzner

Railyard

28 de mayo de 2026 · 8 min de lectura

Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta

Tienes una app de Next.js y una cuenta de Hetzner, y quieres que la app responda en tu propio dominio por HTTPS, sin entregarle todo a una plataforma que cobra por solicitud. Esta es la versión completamente manual: un servidor real, un Dockerfile que produce una imagen de producción pequeña, y Caddy al frente para los certificados. Al final tendrás next build corriendo en modo standalone dentro de un contenedor, servido detrás de HTTPS, con una forma repetible de reconstruir y reiniciar.

Qué necesitas

  • Una cuenta de Hetzner Cloud y un token de API.
  • Un servidor: un CX23 (2 vCPU, 4 GB de RAM, 40 GB NVMe, 5,99 € al mes sin IVA) corre bien en tiempo de ejecución una app de Next.js de tamaño pequeño a mediano; si next build mismo se queda sin memoria en una app grande, redimensiona temporalmente a un CX33 (4 vCPU, 8 GB de RAM, 8,99 € al mes) para el build, y baja después, o construye la imagen en una máquina separada y solo corre en el CX23.
  • Un dominio o subdominio con un registro A que puedas apuntar al servidor.
  • Una app de Next.js con scripts build y start en package.json, sobre Node.js 20.9 o más reciente, que es el mínimo que Next.js exige actualmente.
  • Acceso SSH al servidor.

Paso 1: crear el servidor

Desde la consola de Hetzner Cloud, crea un servidor del tipo cx23 (o tu elección redimensionada) sobre Ubuntu 24.04, agrega tu llave SSH al momento de la creación, y anota la IP pública una vez que esté corriendo. Instala Docker y el plugin de Compose por SSH:

curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin

Paso 2: apuntar el DNS al servidor

Crea un registro A para tu dominio o subdominio apuntando a la IP del servidor, por ejemplo app.tudominio.com. Dale unos minutos de propagación antes del Paso 6, ya que Caddy necesita que resuelva correctamente para solicitar un certificado.

Paso 3: activar la salida standalone

El build de Next.js mismo solo incluye los archivos que una página necesita si activas el rastreo de archivos de salida. Agrega esto a next.config.js (o next.config.ts):

module.exports = {
  output: "standalone",
}

Esto hace que next build produzca una carpeta .next/standalone que contiene un server.js mínimo y solo los node_modules que tu app realmente usa en tiempo de ejecución, en lugar de necesitar copiar todo el árbol de node_modules en la imagen de producción. El servidor standalone no copia automáticamente tu carpeta public/ ni .next/static, ambos se copian explícitamente en la salida standalone en el Dockerfile de abajo.

Paso 4: escribir el Dockerfile

Esto sigue el patrón del ejemplo oficial de Docker de Next.js, recortado:

ARG NODE_VERSION=22-slim

FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN if [ -f package-lock.json ]; then npm ci; \
    elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile; \
    elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
    else echo "No lockfile found" && exit 1; fi

FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]

node:22-slim es una versión LTS actual de Node.js, cómodamente por encima del mínimo de 20.9 que Next.js documenta hoy. El diseño en tres etapas mantiene la instalación de dependencias, el build mismo, y la imagen de ejecución separados, así que la imagen final solo contiene lo que output: "standalone" rastreó como necesario más las dos carpetas que no copia por sí solo. USER node corre el proceso como el usuario no-root node que ya está presente en las imágenes oficiales de Node, en lugar de root, lo cual no cuesta nada y vale la pena por defecto.

Paso 5: escribir el archivo Compose y el Caddyfile

services:
  web:
    build: .
    restart: unless-stopped
    environment:
      NODE_ENV: production
      PORT: "3000"
    expose:
      - "3000"

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - web

volumes:
  caddy-data:
  caddy-config:
app.tudominio.com {
  reverse_proxy web:3000
}

PORT y HOSTNAME son las dos variables de entorno que el server.js standalone de Next.js lee directamente, HOSTNAME=0.0.0.0 (definido en el Dockerfile) es lo que hace que el servidor escuche en todas las interfaces dentro del contenedor en lugar de solo en localhost, lo cual de otro modo lo haría inalcanzable desde el servicio caddy justo al lado. Cualquier variable que tu app lea en tiempo de ejecución con process.env, a diferencia de una incluida en el momento del build, también va en este bloque environment:; solo las variables con prefijo NEXT_PUBLIC_ se incrustan en el bundle del cliente durante next build, todo lo demás se queda solo del lado del servidor y puede cambiar sin reconstruir.

Paso 6: desplegar

docker compose up -d --build

La primera ejecución construye la imagen, que es la parte más lenta, luego arranca ambos contenedores y Caddy solicita su certificado. Sigue los logs para confirmar que tanto el build como la emisión del certificado funcionaron:

docker compose logs -f

Deberías ver que el servidor de Next.js registra una línea como Ready in <n>ms, y el log de Caddy incluye certificate obtained successfully para tu dominio. Visita https://app.tudominio.com y confirma que la app carga por HTTPS con un certificado válido.

Para un cambio de código posterior, el mismo comando reconstruye solo la imagen que cambió y recrea ese único contenedor:

docker compose up -d --build web

Esto no es verdaderamente sin interrupciones, Compose detiene el contenedor web anterior antes de arrancar el nuevo, así que hay una pausa medida por lo que tu app tarde en arrancar y responder su primera solicitud. Si esa pausa importa para tu tráfico, la solución estándar es correr dos contenedores de app detrás del propio balanceo de carga de Caddy y reemplazarlos uno a la vez, lo cual es más infraestructura de la que esta guía cubre para una configuración de un solo servidor.

El atajo: hazlo con Railyard

Todo lo que está en los Pasos 1 y 3 al 6 es lo que la detección de Node de Railyard hace automáticamente cuando despliegas un repositorio en lugar de armar el Dockerfile a mano. Para un repo sin su propio Dockerfile, lee package.json, elige tu gestor de paquetes según el lockfile presente (npm, pnpm, yarn o bun), y ejecuta tu script start como proceso web una vez que termina el build, todo sin que escribas ningún Dockerfile. Si el repo sí trae su propio Dockerfile, ese se usa directamente en lugar del camino automático.

El contenedor escucha en el puerto 3000 por defecto específicamente para un repo de Next.js, lo cual coincide con el puerto que expone el Dockerfile de esta guía. En Hetzner, el servidor mismo se puede crear directamente desde Railyard con la región y el tamaño que elijas al momento de crearlo, en lugar de a través del paso separado de la consola Cloud del Paso 1.

Despliega directamente: /es/desplegar/nextjs-en-hetzner. Ve Desplegar desde un repositorio para cómo funciona la detección y el build de principio a fin.

Solución de problemas

El build falla con el proceso muerto a mitad de camino, sin error claro. Casi siempre es falta de memoria durante next build en un servidor pequeño; redimensiona temporalmente como se mencionó en “Qué necesitas”, o agrega swap como solución más barata (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).

El HTTPS nunca se emite, Caddy sigue registrando un tiempo de espera agotado contra Let’s Encrypt. Confirma que el registro DNS A del Paso 2 realmente resuelve a la IP de este servidor desde fuera de tu red, y que los puertos 80 y 443 están abiertos en el firewall de Hetzner además de en las reglas de ufw de la propia máquina, ambos tienen que permitir tráfico entrante para que el desafío ACME se complete.

La app construye y arranca, pero la página nunca carga, Caddy agota el tiempo de espera. Verifica que HOSTNAME=0.0.0.0 realmente llegó al contenedor en ejecución (docker compose exec web env); un servidor que solo escucha en localhost dentro de su propio espacio de nombres de red de contenedor es inalcanzable desde el servicio caddy aunque ambos contenedores estén activos.

Las variables NEXT_PUBLIC_ muestran el valor viejo después de cambiarlas. Estas se incrustan en el bundle de JavaScript en el momento del build, así que cambiar una requiere correr de nuevo docker compose up -d --build web, reiniciar solo el contenedor sin reconstruir no hace nada por ellas.

El puerto 3000 (u 80) ya está en uso en el host. Algo más ya está vinculado a él, comúnmente un proxy inverso anterior o un contenedor sobrante de un intento previo. sudo ss -tlnp | grep ':80\|:3000' muestra qué lo está ocupando; el archivo Compose de esta guía no publica el puerto 3000 al host en absoluto (solo está expose-ado al servicio caddy), así que un conflicto aquí usualmente apunta más bien al puerto 80 o 443.

Preguntas frecuentes

¿Necesito output: "standalone", o un next start normal también funciona en Docker? next start funciona bien en un contenedor, pero necesita que se copie todo tu node_modules, lo cual produce una imagen notablemente más grande y un despliegue más lento; el modo standalone es el camino oficialmente recomendado específicamente para despliegues contenedorizados.

¿Puedo usar pnpm o yarn en lugar de npm? Sí, el Dockerfile del Paso 4 ya hace una rama según el lockfile presente, no se necesita ningún cambio más allá de tener ese lockfile en el control de versiones.

¿Y si mi app usa la optimización de imágenes de next/image? Funciona sin ninguna configuración con next start, incluido en modo standalone, siempre que el contenedor tenga la memoria para hacer el procesamiento de imágenes al vuelo; esta es una preocupación distinta del problema de memoria en el momento del build en la Solución de problemas de arriba.

¿Es seguro correr esto más adelante con varias instancias detrás de un balanceador de carga? En su mayoría sí, pero la propia documentación de Next.js señala algunas cosas por configurar primero si lo haces: un manejador de caché compartido en lugar del de disco por defecto, y una NEXT_SERVER_ACTIONS_ENCRYPTION_KEY consistente entre instancias si usas Server Actions, de lo contrario una instancia no puede descifrar lo que otra cifró.

¿En qué se diferencia esto de desplegar en Vercel? Funcionalmente tu app corre el mismo código en ambos casos; lo que estás cambiando es el build gestionado y la red de borde de Vercel por ser dueño del servidor, el Dockerfile, y el certificado tú mismo, lo cual es más barato a tráfico estable y más trabajo en los extremos (escalado, infraestructura de optimización de imágenes, entornos de vista previa).

El Dockerfile y el archivo Compose aquí no cambian mucho entre proyectos de Next.js, lo que realmente varía es lo que tu app lee del entorno en tiempo de ejecución, vale la pena revisarlo contra esta lista antes del primer despliegue real.

Por qué eligen Railyard para esto

  • Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
  • Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
  • Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
  • Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
  • Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.

Despliega este repo sin el Dockerfile

Pega la URL del repo en Railyard y el build, los puertos y las variables de entorno se detectan por ti en un servidor Hetzner.

Plan gratis, sin tarjeta, y 14 días de Pro para cada equipo nuevo. Un precio fijo por equipo.