Fazendo deploy de SvelteKit na Hetzner
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Você tem uma aplicação SvelteKit e quer ela respondendo no seu próprio domínio sobre HTTPS, num servidor que você paga por mês em vez de por requisição. Essa é a versão totalmente manual: um servidor Hetzner de verdade, um Dockerfile construído em torno do @sveltejs/adapter-node, e Caddy na frente pra certificados. Ao final você vai ter o vite build rodando dentro de uma imagem de produção pequena, servida por trás de HTTPS, com um jeito repetível de reconstruir e reiniciar ela.
O que você vai precisar
- Uma conta Hetzner Cloud e um token de API.
- Um servidor: um CX23 (2 vCPU, 4 GB de RAM, 40 GB NVMe, €5,99/mês sem IVA) roda bem um site SvelteKit pequeno a médio em tempo de execução. Se o próprio
vite buildficar sem memória num site grande, redimensione temporariamente pra um CX33 (4 vCPU, 8 GB de RAM, €8,99/mês) pro build, depois volte. - Um domínio ou subdomínio com um registro A que você consiga apontar pro servidor.
- Uma aplicação SvelteKit usando
@sveltejs/adapter-node, em Node.js 20.19 ou mais novo, que é o mínimo que o Vite 7 (e portanto o SvelteKit atual) exige. - Acesso SSH ao servidor.
Passo 1: criar o servidor
No console do Hetzner Cloud, crie um servidor usando o tipo cx23 (ou sua escolha redimensionada) no Ubuntu 24.04, adicione sua chave SSH no momento da criação, e anote o IP público quando ele estiver rodando. Instale o Docker e o plugin do Compose via SSH:
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Passo 2: apontar o DNS pro servidor
Crie um registro A pro seu domínio ou subdomínio apontando pro IP do servidor, por exemplo app.yourdomain.com. Dê alguns minutos pra propagar antes do Passo 6, já que o Caddy precisa dele resolvendo corretamente pra solicitar um certificado.
Passo 3: trocar pro adapter-node
O SvelteKit escolhe seu adapter a partir do svelte.config.js. O adapter-auto padrão chuta um destino a partir da sua plataforma de deploy, que não tem a menor ideia de que você está construindo uma imagem Docker simples, então troque ele explicitamente pelo adapter do Node:
npm install -D @sveltejs/adapter-node@6
import adapter from "@sveltejs/adapter-node";
export default {
kit: {
adapter: adapter(),
},
};
Este guia usa @sveltejs/adapter-node 6.0.0 contra o SvelteKit 3.1.0 e o Svelte 5.57.2, as versões atuais no momento em que isso foi escrito. Rodar npm run build agora produz um diretório build/ em vez do que o adapter-auto teria escolhido, contendo build/index.js e build/handler.js, os dois pontos de entrada que os docs do adapter descrevem.
Passo 4: escrever o 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 acompanha o Node 24, a linha LTS atualmente ativa. O layout de três estágios mantém a instalação de dependências, o build em si, e a imagem de runtime separados; npm prune --omit=dev no estágio builder descarta as devDependencies antes que elas sejam copiadas pro runner, já que a saída do adapter-node ainda precisa do seu node_modules de produção ao lado dela, diferente de um bundle totalmente standalone. USER node roda o processo como o usuário não-root node já presente nas imagens oficiais do Node.
Passo 5: escrever o arquivo do Compose e o 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 e HOST são o que o servidor embutido do adapter-node lê pra decidir onde escutar; HOST=0.0.0.0 é o que o torna alcançável pelo serviço caddy ao lado dele, em vez de só de dentro do próprio container. ORIGIN importa sempre que o SvelteKit precisa saber sua própria URL absoluta, mais comumente pra validar o cabeçalho Origin em form actions, uma proteção contra cross-site request forgery; deixar isso sem definir por trás de um reverse proxy é a causa mais comum de form actions falharem em produção enquanto funcionam bem no desenvolvimento local. Qualquer outra variável que seu código do lado do servidor lê com $env/dynamic/private vai nesse mesmo bloco environment:; só variáveis com o prefixo PUBLIC_ e lidas via $env/static/public ou $env/dynamic/public são empacotadas pro cliente no momento do build.
Cheque se o arquivo é lido sem erros:
docker compose config
Você deve ver a configuração totalmente resolvida impressa sem nenhuma linha de erro.
Passo 6: fazer o deploy
docker compose up -d --build
A primeira execução constrói a imagem, que é a parte mais lenta, depois inicia os dois containers e o Caddy solicita o certificado dele. Acompanhe os logs pra confirmar que os dois tiveram sucesso:
docker compose logs -f
Você deve ver uma linha como Listening on 0.0.0.0:3000 do container web, e o log do Caddy incluir uma emissão de certificado bem-sucedida pro seu domínio. Acesse https://app.yourdomain.com e confirme que a aplicação carrega com um certificado válido, sem aviso do navegador.
Pra uma mudança de código posterior, o mesmo comando reconstrói só a imagem que mudou:
docker compose up -d --build web
Isso não é zero-downtime. O Compose para o container web antigo antes de iniciar o novo, então existe um intervalo medido por quanto tempo o processo Node leva pra inicializar e servir a primeira requisição. Se esse intervalo importa pro seu tráfego, a correção padrão é rodar dois containers de aplicação por trás do próprio balanceamento de carga do Caddy e substituí-los um por vez, mais infraestrutura do que este guia de servidor único cobre.
O caminho mais curto: fazer isso com o Railyard
O SvelteKit não tem seu próprio caminho de build no Railyard, e isso é deliberado: uma aplicação SvelteKit construída com adapter-node ou deixada no adapter-auto parece, de fora, exatamente igual a qualquer outro servidor Node.js que lê package.json e inicia com node build. A detecção de repositório do Railyard lê seu lockfile pra escolher npm, pnpm, yarn ou bun, confirma o @sveltejs/adapter-node (ou o adapter-auto resolvendo pra ele) e um script de build combinando com vite build ou svelte-kit build, e depois roda a aplicação do mesmo jeito que os Passos 4 a 6 fazem à mão, sem você escrever nenhum Dockerfile. Se seu repositório já traz o próprio Dockerfile no lugar, esse é usado direto.
Faça deploy direto pelo caminho de Node.js: /deploy/nodejs-to-hetzner. Veja Deploy from a repository pra como a detecção e a etapa de build funcionam de ponta a ponta, e o quickstart se esse é seu primeiro deploy no Railyard.
Resolução de problemas
O build falha com o processo sendo morto na metade do caminho, sem erro claro. Quase sempre é falta de memória durante o vite build num servidor pequeno; redimensione temporariamente como mencionado em “O que você vai precisar”, ou adicione swap como um paliativo mais barato (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).
Form actions falham com um 403 em produção mas funcionam localmente. Isso é quase sempre um ORIGIN faltando ou errado. O SvelteKit checa o cabeçalho Origin da requisição contra esse valor pra qualquer envio de form que não seja GET; por trás de um reverse proxy a aplicação não consegue inferir isso por conta própria, então precisa ser definido explicitamente pra URL HTTPS exata que os usuários veem.
O HTTPS nunca é emitido, o Caddy continua registrando um timeout contra o Let’s Encrypt. Confirme que o registro A de DNS do Passo 2 realmente resolve pro IP desse servidor de fora da sua própria rede, e que as portas 80 e 443 estão abertas tanto no firewall da Hetzner quanto em qualquer regra de ufw na própria máquina.
A aplicação constrói e inicia, mas a página nunca carrega, o Caddy dá timeout. Cheque se HOST=0.0.0.0 realmente entrou no container em execução (docker compose exec web env); um servidor escutando só em localhost dentro do próprio namespace de rede é inalcançável pelo serviço caddy mesmo com os dois containers de pé.
Variáveis PUBLIC_ mostram o valor antigo depois que você mudou elas. Essas são embutidas no bundle do cliente no momento do build, então mudar uma precisa de docker compose up -d --build web de novo; só reiniciar o container não faz nada por elas.
Perguntas frequentes
Eu preciso do adapter-node, ou o adapter-auto também funciona no Docker? O adapter-auto só se resolve pra um adapter real quando reconhece a plataforma em que está rodando a partir de variáveis de ambiente que essas plataformas definem; uma imagem Docker simples no seu próprio servidor não é uma delas, então precisa ser adapter-node explicitamente, como no Passo 3.
Eu posso usar pnpm ou yarn em vez de npm? Sim, o Dockerfile no Passo 4 já se ramifica dependendo de qual lockfile está presente, nenhuma mudança necessária além de ter esse lockfile commitado.
Isso precisa de um banco de dados? Não por padrão. O SvelteKit em si não tem nenhuma opinião sobre persistência; se as funções load ou form actions da sua aplicação falam com o Postgres ou outro banco de dados, adicione ele como seu próprio serviço do Compose e passe a connection string dele no bloco environment: do serviço web do mesmo jeito que o ORIGIN é definido acima.
E se minha aplicação tem WebSocket ou conexões de longa duração? O reverse_proxy do Caddy encaminha os cabeçalhos de upgrade do WebSocket sem nenhuma configuração extra, a mesma linha única no Passo 5 já cobre isso.
Como isso é diferente de fazer deploy na Vercel? Funcionalmente sua aplicação roda o mesmo código dos dois jeitos; o que você está trocando é um build gerenciado e uma rede de edge por possuir o servidor, o Dockerfile e o certificado você mesmo, mais barato em tráfego constante e mais trabalho nas bordas (escalonamento, previews, infraestrutura de otimização de imagem).
O Dockerfile e o arquivo do Compose aqui não mudam muito entre projetos SvelteKit. O que varia é o que suas funções load e form actions leem do ambiente em tempo de execução, vale a pena checar de novo contra essa lista antes do primeiro deploy real.
Por que escolhem o Railyard para isso
- Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
- Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
- Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
- Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
- Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.