← Blog

Desplegar SvelteKit en Hetzner

Railyard

20 de septiembre de 2026 · 8 min de lectura

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

Tienes una app SvelteKit y quieres que responda en tu propio dominio por HTTPS, en un servidor que pagas por mes en lugar de por solicitud. Esta es la versión totalmente manual: un servidor Hetzner real, un Dockerfile construido alrededor de @sveltejs/adapter-node, y Caddy al frente para los certificados. Al final tendrás vite build corriendo dentro de una pequeña imagen de producción, servida detrás de HTTPS, con una forma repetible de reconstruirla y reiniciarla.

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 bien en producción una app SvelteKit de tamaño pequeño a mediano. Si vite build se queda sin memoria en un sitio grande, cambia temporalmente a un CX33 (4 vCPU, 8 GB de RAM, 8,99 €/mes) para el build, y vuelve a bajar después.
  • Un dominio o subdominio con un registro A que puedas apuntar al servidor.
  • Una app SvelteKit que use @sveltejs/adapter-node, en Node.js 20.19 o más reciente, el mínimo que requiere Vite 7 (y por lo tanto el SvelteKit actual).
  • Acceso SSH al servidor.

Paso 1: crear el servidor

Desde la consola de Hetzner Cloud, crea un servidor usando el tipo cx23 (o el tamaño que hayas elegido) en Ubuntu 24.04, agrega tu llave SSH al momento de 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.yourdomain.com. Dale unos minutos para que se propague antes del Paso 6, ya que Caddy necesita que esto resuelva correctamente para solicitar un certificado.

Paso 3: cambiar a adapter-node

SvelteKit elige su adaptador desde svelte.config.js. El adapter-auto por defecto adivina un objetivo a partir de tu plataforma de despliegue, la cual no tiene idea de que estás construyendo una imagen Docker simple, así que reemplázalo explícitamente por el adaptador de Node:

npm install -D @sveltejs/adapter-node@6
import adapter from "@sveltejs/adapter-node";

export default {
  kit: {
    adapter: adapter(),
  },
};

Esta guía usa @sveltejs/adapter-node 6.0.0 junto con SvelteKit 3.1.0 y Svelte 5.57.2, las versiones vigentes al momento de escribir esto. Ejecutar npm run build ahora produce un directorio build/ en lugar de lo que adapter-auto hubiera elegido, con build/index.js y build/handler.js adentro, los dos puntos de entrada que describe la documentación del adaptador.

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
RUN npm prune --omit=dev

FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER node
EXPOSE 3000
CMD ["node", "build"]

node:24-alpine sigue a Node 24, la línea LTS actualmente activa. El esquema de tres etapas mantiene separadas la instalación de dependencias, el build en sí y la imagen de ejecución; npm prune --omit=dev en la etapa de build elimina las devDependencies antes de que se copien a la etapa final, ya que la salida de adapter-node todavía necesita tu node_modules de producción al lado, a diferencia de un bundle totalmente independiente. USER node corre el proceso como el usuario node, que no es root y ya viene incluido en las imágenes oficiales de Node.

Paso 5: escribir el archivo Compose y el Caddyfile

services:
  web:
    build: .
    restart: unless-stopped
    environment:
      NODE_ENV: production
      PORT: "3000"
      HOST: "0.0.0.0"
      ORIGIN: "https://app.yourdomain.com"
    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.yourdomain.com {
	reverse_proxy web:3000
}

PORT y HOST son lo que el servidor incorporado de adapter-node lee para decidir dónde escuchar; HOST=0.0.0.0 es lo que lo hace alcanzable desde el servicio caddy de al lado, en vez de solo desde dentro de su propio contenedor. ORIGIN importa cada vez que SvelteKit necesita conocer su propia URL absoluta, lo más común para validar el encabezado Origin en las form actions, una protección contra la falsificación de solicitudes entre sitios; dejarlo sin definir detrás de un proxy inverso es la causa más común de que las form actions fallen en producción mientras funcionan bien en desarrollo local. Cualquier otra variable que tu código del lado del servidor lea con $env/dynamic/private va en este mismo bloque environment:; solo las variables con el prefijo PUBLIC_ y leídas vía $env/static/public o $env/dynamic/public se incluyen en el bundle para el cliente al momento del build.

Verifica que el archivo se analiza correctamente:

docker compose config

Deberías ver la configuración completamente resuelta impresa sin líneas de error.

Paso 6: desplegar

docker compose up -d --build

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

docker compose logs -f

Deberías ver una línea como Listening on 0.0.0.0:3000 del contenedor web, y el log de Caddy debería incluir la obtención exitosa del certificado para tu dominio. Visita https://app.yourdomain.com y confirma que la app carga con un certificado válido, sin advertencia del navegador.

Para un cambio de código posterior, el mismo comando reconstruye solo la imagen que cambió:

docker compose up -d --build web

Esto no es sin tiempo de inactividad. Compose detiene el contenedor web anterior antes de arrancar el nuevo, así que hay una brecha que dura lo que tarde el proceso de Node en arrancar y atender 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, más infraestructura de la que cubre esta guía de un solo servidor.

El atajo: hazlo con Railyard

SvelteKit no tiene su propio camino de build en Railyard, y eso es intencional: una app SvelteKit construida con adapter-node o dejada en adapter-auto se ve, desde afuera, exactamente igual a cualquier otro servidor Node.js que lee package.json y arranca con node build. La detección de repositorios de Railyard lee tu lockfile para elegir entre npm, pnpm, yarn o bun, confirma @sveltejs/adapter-node (o adapter-auto resolviéndose a él) y un script de build que coincida con vite build o svelte-kit build, y luego corre la app de la misma forma en que el Paso 4 al Paso 6 lo hacen a mano, sin que tengas que escribir ningún Dockerfile. Si tu repositorio trae su propio Dockerfile en cambio, ese se usa directamente.

Despliega directamente por el camino de Node.js: /es/deploy/nodejs-to-hetzner. Consulta desplegar desde un repositorio para ver cómo funciona de principio a fin la detección y el paso de build, y la guía de inicio rápido si este es tu primer despliegue en Railyard.

Solución de problemas

El build falla con el proceso terminado a la mitad, sin un error claro. Casi siempre es falta de memoria durante vite build en un servidor pequeño; cambia temporalmente a uno más grande 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).

Las form actions fallan con un 403 en producción pero funcionan en local. Esto casi siempre es un ORIGIN faltante o incorrecto. SvelteKit compara el encabezado Origin de la solicitud contra este valor en cualquier envío de formulario que no sea GET; detrás de un proxy inverso la app no puede inferirlo por sí sola, así que hay que definirlo explícitamente con la URL HTTPS exacta que ven los usuarios.

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 propia red, y que los puertos 80 y 443 están abiertos tanto en el firewall de Hetzner como en cualquier regla ufw del propio servidor.

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

Las variables PUBLIC_ muestran el valor anterior después de cambiarlas. Estas se incorporan en el bundle del cliente al momento del build, así que cambiar una requiere ejecutar docker compose up -d --build web de nuevo; solo reiniciar el contenedor no hace nada por ellas.

Preguntas frecuentes

¿Necesito adapter-node, o adapter-auto también funciona en Docker? adapter-auto solo se resuelve a un adaptador real cuando reconoce la plataforma en la que corre a partir de variables de entorno que esas plataformas definen; una imagen Docker simple en tu propio servidor no es una de ellas, así que tiene que ser adapter-node explícitamente, como en el Paso 3.

¿Puedo usar pnpm o yarn en lugar de npm? Sí, el Dockerfile del Paso 4 ya se ramifica según el lockfile que esté presente, no se necesitan cambios más allá de tener ese lockfile en el repositorio.

¿Esto necesita una base de datos? No por defecto. SvelteKit en sí mismo no tiene opinión sobre la persistencia; si las funciones load o las form actions de tu app hablan con Postgres u otra base de datos, agrégala como su propio servicio de Compose y pasa su cadena de conexión en el bloque environment: del servicio web, de la misma forma en que se definió ORIGIN arriba.

¿Qué pasa si mi app tiene WebSocket o conexiones de larga duración? reverse_proxy en Caddy reenvía los encabezados de actualización de WebSocket sin ninguna configuración adicional, la misma línea única del Paso 5 ya lo cubre.

¿En qué se diferencia esto de desplegar en Vercel? Funcionalmente tu app corre el mismo código de cualquier forma; lo que estás intercambiando es un build gestionado y una red de borde por tener tú mismo el servidor, el Dockerfile y el certificado, más barato con tráfico estable y más trabajo en los bordes (escalado, previews, infraestructura de optimización de imágenes).

El Dockerfile y el archivo Compose de aquí no cambian mucho entre proyectos SvelteKit. Lo que varía es lo que tus funciones load y form actions leen 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 los builds, puertos y 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.