← Blog

Desplegar Nuxt en Hetzner

Railyard

12 de septiembre de 2026 · 9 min de lectura

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

Tienes una app Nuxt y una cuenta de Hetzner, y quieres que la app responda en tu propio dominio por HTTPS, sin entregar 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 el servidor Nitro de Nuxt corriendo dentro de un contenedor, servido por HTTPS, con una forma repetible de reconstruir y reiniciar.

Lo que 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 €/mes sin IVA) corre una app Nuxt pequeña a mediana sin problema en ejecución; si el build en sí se queda sin memoria en una app grande, redimensiona temporalmente a un CX33 (4 vCPU, 8 GB de RAM, 8,99 €/mes) para el build, y baja de nuevo después, o construye la imagen en otra máquina y solo ejecuta el resultado en el CX23.
  • Un dominio o subdominio con un registro A que puedas apuntar al servidor.
  • Una app Nuxt con un script build en package.json, en una versión actual de Nuxt (4.6.2 al momento de escribir esto), que necesita como mínimo Node 20.19+ o 22.12+.
  • Acceso SSH al servidor.

Paso 1: crear el servidor

Desde la consola de Hetzner Cloud, crea un servidor con el tipo cx23 (o tu elección redimensionada) en Ubuntu 24.04, agrega tu llave SSH al crearlo, 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 para que se propague, y confirma que resuelve correctamente antes del Paso 6, ya que Caddy necesita eso para solicitar un certificado:

dig +short app.tudominio.com

Paso 3: la salida de build de Nuxt

Ejecutar nuxt build (o nuxi build) produce un directorio .output con dos partes: .output/public para los recursos estáticos, y .output/server/index.mjs como punto de entrada de Node. Ese punto de entrada es el preset “node-server” de Nitro, que es el preset por defecto de Nuxt mientras no hayas configurado otro objetivo de despliegue, así que no hay un equivalente a la activación explícita output: "standalone" de Next.js aquí, un simple nuxt build ya te da un servidor Node autónomo para ejecutar.

Dicho esto, “por defecto” significa que puede cambiar si los valores por defecto de Nuxt cambian algún día. Para un despliegue de producción, vale la pena fijarlo explícitamente en nuxt.config.ts para que una futura actualización de Nuxt no cambie silenciosamente tu objetivo de despliegue:

export default defineNuxtConfig({
  nitro: {
    preset: "node-server",
  },
})

Ejecutar node .output/server/index.mjs inicia directamente el servidor de producción, exactamente lo que hace el Dockerfile de abajo.

Paso 4: escribir el Dockerfile

ARG NODE_VERSION=24-alpine

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 . .
RUN npm run build

FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder --chown=node:node /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

Esto es más simple que el equivalente para Next.js, donde la salida standalone necesita su node_modules rastreado más las carpetas public/ y .next/static copiadas por separado. El .output/server de Nitro ya incluye su propio node_modules mínimo dentro de sí mismo como parte del build, así que la etapa runner solo tiene que copiar el único directorio .output, no se necesita nada más de la etapa builder en tiempo de ejecución. node:24-alpine es una imagen Node.js LTS actual, cómodamente por encima de los mínimos 20.19/22.12 que Nuxt 4 documenta hoy, y la base Alpine más pequeña mantiene la imagen final liviana ya que no hay ningún paso de build nativo en esta etapa. USER node ejecuta el proceso como el usuario no root node ya presente en las imágenes oficiales de Node en lugar de root.

Paso 5: escribir el archivo Compose y el Caddyfile

services:
  web:
    build: .
    restart: unless-stopped
    environment:
      NODE_ENV: production
      HOST: "0.0.0.0"
      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
}

HOST y PORT (o sus equivalentes NITRO_HOST / NITRO_PORT) son lo que el node-server de Nitro lee del entorno para decidir dónde escuchar. Fijar HOST=0.0.0.0 explícitamente, en vez de confiar en lo que sea que resulte ser el valor por defecto propio de Nitro, es lo que hace que el servidor sea alcanzable desde el contenedor caddy al lado, el mismo razonamiento que el HOSTNAME=0.0.0.0 de la guía de Next.js, solo que bajo los nombres de variables propios de Nitro. Cualquier valor que leas en tiempo de ejecución con useRuntimeConfig() va en este bloque environment:; solo las variables con prefijo NUXT_PUBLIC_ se incrustan en el paquete del cliente durante el build, todo lo demás se queda solo del lado del servidor y puede cambiar sin reconstruir la imagen.

Antes de desplegar, valida ambos archivos localmente. Para el archivo Compose:

docker compose -f compose.yml config

Y para el Caddyfile:

docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile

Ambos deberían reportar éxito antes de que los copies al servidor.

Paso 6: desplegar

docker compose up -d --build

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

docker compose logs -f

Deberías ver a Nitro registrar una línea como Listening on http://0.0.0.0:3000, y el registro de Caddy incluir certificate obtained successfully para tu dominio. Visita https://app.tudominio.com y confirma que la app carga por HTTPS con un certificado válido, o verifícalo directamente desde el servidor:

curl -I https://app.tudominio.com

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 interrupción, Compose detiene el contenedor web anterior antes de iniciar el nuevo, así que hay una brecha medida por el tiempo que Nitro tarda en arrancar y responder su primera solicitud. Si esa brecha importa para tu tráfico, la solución estándar es correr dos contenedores de la 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

Nuxt no tiene su propia entrada de framework dedicada en el sistema de guías de Railyard, solo el “Node.js” genérico la tiene, porque la detección subyacente no necesita saber que se trata específicamente de Nuxt. 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 el build termina, todo sin que escribas un Dockerfile, el mismo camino que recorre un repositorio Nuxt que cualquier otro repositorio Node.js. Si el repositorio trae su propio Dockerfile, como el del Paso 4, ese se usa directamente en lugar del camino automático. En cualquier caso, se espera que la app escuche en el $PORT que Railyard asigna, el mismo requisito que el manejo explícito de HOST/PORT del Paso 4 satisface a mano.

Despliega directamente: /es/desplegar/nodejs-en-hetzner. Consulta /es/docs/node para la referencia del framework Node.js, que cubre cómo funciona la detección de build y arranque para cualquier repositorio basado en Node, incluyendo Nuxt.

El plan Free de Railyard cubre 1 servidor más un servidor de base de datos y 3 apps sin tarjeta requerida, el plan Pro es de $19 USD al mes por equipo, y el plan Team de $49 USD al mes por equipo.

Solución de problemas

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

La app se construye e inicia, pero la página nunca carga, Caddy expira. Verifica que HOST=0.0.0.0 realmente llegó al contenedor en ejecución (docker compose exec web env); un servidor que escucha solo en localhost dentro de su propio espacio de nombres de red de contenedor es inalcanzable desde el servicio caddy aunque ambos contenedores estén corriendo. Este es el error más común con un despliegue node-server de Nitro en particular, ya que los valores por defecto de Nitro no garantizan escuchar en todas las interfaces.

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 así como en las reglas ufw del servidor mismo, ambos deben permitir tráfico entrante para que el desafío ACME se complete.

El puerto 3000 (u 80) ya está en uso en el host. Algo más ya está vinculado a él, comúnmente una instalación de nginx por defecto dejada por una imagen base, o un contenedor sobrante de un intento anterior. sudo ss -tlnp | grep ':80\|:3000' muestra qué lo está reteniendo; el archivo Compose de esta guía no publica el puerto 3000 al host en absoluto (solo está exposed al servicio caddy), así que un conflicto aquí usualmente apunta al puerto 80 o 443 en su lugar.

Una variable de entorno muestra el valor antiguo después de que la cambiaste. Todo lo que se lee con useRuntimeConfig() se resuelve cuando arranca el contenedor, así que cambiarla y reiniciar el contenedor es suficiente, no se necesita reconstruir. Todo lo que tiene el prefijo NUXT_PUBLIC_, en cambio, se incrusta en el JavaScript del lado del cliente durante el build mismo, así que cambiar una de esas requiere docker compose up -d --build web de nuevo, un simple reinicio no hace nada por ella.

Preguntas frecuentes

¿Necesito fijar explícitamente nitro: { preset: "node-server" }, o el valor por defecto está bien? El valor por defecto funciona bien, node-server ya es lo que obtienes sin ninguna configuración. Fijarlo explícitamente es un pequeño seguro para producción para que una futura versión de Nuxt que cambie su preset por defecto no cambie también tu objetivo de despliegue.

¿Puedo usar pnpm, yarn o bun en lugar de npm? Sí, el Dockerfile del Paso 4 ya se ramifica según el lockfile presente, no se necesitan cambios más allá de tener ese lockfile en el repositorio. (Agrega una rama bun.lockb si usas Bun específicamente, el ejemplo de arriba cubre los tres lockfiles más comunes.)

¿Qué pasa con las rutas de servidor y las rutas API de Nuxt, corren en este mismo contenedor? Sí, todo lo que está bajo server/api o server/routes en tu app Nuxt se empaqueta en el mismo .output/server/index.mjs y corre en el mismo proceso que tus páginas, no hay un backend separado que desplegar a menos que hayas separado uno deliberadamente.

¿Es seguro correr con múltiples instancias detrás de un balanceador de carga más adelante? En su mayoría sí, el node-server de Nitro no tiene estado por defecto, así que correr varios contenedores detrás del balanceo de carga de Caddy funciona sin configuración especial. La advertencia habitual aplica si tu app mantiene algún estado en memoria entre solicitudes (limitación de tasa, caché en proceso), que tendría que moverse a algo compartido como Redis una vez que corras más de una instancia.

¿En qué se diferencia esto de desplegar en Vercel o Netlify? Funcionalmente tu app ejecuta el mismo código de cualquier forma, la salida de build de Nuxt es portable por diseño. Lo que estás intercambiando es un pipeline de build gestionado y una red de borde por tener tú mismo el servidor, el Dockerfile y el certificado, lo cual es más barato con tráfico estable y más trabajo en los extremos (escalado, alojamiento de imágenes, entornos de previsualización).

El Dockerfile y el archivo Compose aquí no cambian mucho entre proyectos Nuxt, 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 repositorio sin el Dockerfile

Pega la URL del repositorio 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.