← Blog

Fazendo deploy de Nuxt na Hetzner

Railyard

12 de setembro de 2026 · 9 min de leitura

Siga isso no seu próprio servidor. Comece grátis, sem cartão

Você tem uma aplicação Nuxt e uma conta Hetzner, e quer a aplicação respondendo no seu próprio domínio sobre HTTPS, sem entregar a coisa toda pra uma plataforma que cobra por requisição. Essa é a versão totalmente manual: um servidor de verdade, um Dockerfile que produz uma imagem de produção pequena, e Caddy na frente pra certificados. Ao final você vai ter o servidor Nitro do Nuxt rodando dentro de um container, servido por trás de HTTPS, com um jeito repetível de reconstruir e reiniciar ele.

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 uma aplicação Nuxt pequena a média em tempo de execução; se o próprio build ficar sem memória numa aplicação grande, redimensione temporariamente pra um CX33 (4 vCPU, 8 GB de RAM, €8,99/mês) pro build, depois volte, ou construa a imagem numa máquina separada e só rode ela no CX23.
  • Um domínio ou subdomínio com um registro A que você consiga apontar pro servidor.
  • Uma aplicação Nuxt com um script build no package.json, numa release atual do Nuxt (4.6.2 no momento em que isso foi escrito), que precisa de no mínimo Node 20.19+ ou 22.12+.
  • 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, depois confirme que ele resolve corretamente antes do Passo 6, já que o Caddy precisa disso pra solicitar um certificado:

dig +short app.yourdomain.com

Passo 3: a saída de build do Nuxt

Rodar nuxt build (ou nuxi build) produz um diretório .output com duas partes: .output/public pros assets estáticos, e .output/server/index.mjs como o ponto de entrada do Node. Esse ponto de entrada é o preset “node-server” do Nitro, que é o preset padrão do Nuxt quando você não configurou nenhum outro destino de deploy, então não existe um equivalente ao opt-in explícito output: "standalone" do Next.js aqui, um nuxt build simples já te dá um servidor Node autocontido pra rodar.

Dito isso, “padrão” significa que pode mudar se os padrões do Nuxt algum dia mudarem. Pra um deploy de produção vale fixar isso explicitamente no nuxt.config.ts pra que uma futura atualização do Nuxt não troque seu destino de deploy silenciosamente por debaixo de você:

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

Rodar node .output/server/index.mjs inicia o servidor de produção diretamente, que é exatamente o que o Dockerfile abaixo faz.

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

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"]

Isso é mais simples do que a configuração equivalente do Next.js, onde a saída standalone precisa do node_modules rastreado mais as pastas public/ e .next/static copiadas separadamente. O .output/server do Nitro já empacota seu próprio node_modules mínimo dentro de si como parte do build, então o estágio runner só precisa copiar o único diretório .output, nada mais do estágio builder é necessário em tempo de execução. node:24-alpine é uma imagem LTS atual do Node.js, confortavelmente acima dos mínimos 20.19/22.12 que o Nuxt 4 documenta hoje, e a base Alpine menor mantém a imagem final magra já que não há nenhuma etapa de build nativo acontecendo nesse estágio. USER node roda o processo como o usuário não-root node já presente nas imagens oficiais do Node em vez de root.

Passo 5: escrever o arquivo do Compose e o 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.yourdomain.com {
  reverse_proxy web:3000
}

HOST e PORT (ou seus equivalentes NITRO_HOST / NITRO_PORT) são o que o node-server do Nitro lê do ambiente pra decidir a quê se vincular. Definir HOST=0.0.0.0 explicitamente, em vez de confiar em qualquer que seja o padrão do próprio Nitro, é o que torna o servidor alcançável pelo container caddy ao lado dele, o mesmo raciocínio do HOSTNAME=0.0.0.0 do guia de Next.js, só que sob os nomes de variável do próprio Nitro. Qualquer valor que você lê em tempo de execução com useRuntimeConfig() vai nesse bloco environment:; só variáveis com o prefixo NUXT_PUBLIC_ são embutidas no bundle do cliente durante o build, tudo o mais fica só no servidor e pode mudar sem reconstruir a imagem.

Antes de fazer o deploy, valide os dois arquivos localmente. Pro arquivo do Compose:

docker compose -f compose.yml config

E pro Caddyfile:

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

Os dois devem reportar sucesso antes de você copiá-los pro servidor.

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 o build e a emissão do certificado tiveram sucesso:

docker compose logs -f

Você deve ver o Nitro registrar uma linha como Listening on http://0.0.0.0:3000, e o log do Caddy incluir certificate obtained successfully pro seu domínio. Acesse https://app.yourdomain.com e confirme que a aplicação carrega sobre HTTPS com um certificado válido, ou cheque direto do servidor:

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

Pra uma mudança de código posterior, o mesmo comando reconstrói só a imagem que mudou e recria aquele único container:

docker compose up -d --build web

Isso não é verdadeiramente zero-downtime, o Compose para o container web antigo antes de iniciar o novo, então existe um intervalo medido por quanto tempo o Nitro leva pra inicializar e passar 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, o que é mais infraestrutura do que este guia cobre pra uma configuração de servidor único.

O caminho mais curto: fazer isso com o Railyard

O Nuxt não tem sua própria entrada dedicada de framework no sistema de guias do Railyard, só o “Node.js” genérico tem, porque a detecção por debaixo não precisa saber que é especificamente Nuxt. Ela lê o package.json, escolhe seu gerenciador de pacotes a partir de qual lockfile está presente (npm, pnpm, yarn ou bun), e roda seu script start como processo web quando o build termina, tudo sem você escrever nenhum Dockerfile, o mesmo caminho que um repositório Nuxt passa igual a qualquer outro repositório Node.js. Se o repositório já traz o próprio Dockerfile, como o do Passo 4, esse é usado direto em vez do caminho automático. De qualquer forma, espera-se que a aplicação escute na $PORT que o Railyard atribui, a mesma exigência que o tratamento explícito de HOST/PORT do Passo 4 satisfaz à mão.

Faça deploy direto: /deploy/nodejs-to-hetzner. Veja /docs/node pra referência do framework Node.js, que cobre como a detecção de build e start funciona pra qualquer repositório baseado em Node, Nuxt incluído.

O plano Free do Railyard cobre 1 servidor mais um servidor de banco de dados e 3 aplicações sem cartão necessário, o Pro é US$19/mês por equipe, e o Team é US$49/mês por equipe.

Resolução de problemas

O build falha com o processo sendo morto na metade do caminho, sem erro claro. Isso é quase sempre falta de memória durante o build num servidor pequeno; ou 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).

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 do container é inalcançável pelo serviço caddy mesmo com os dois containers de pé. Esse é o erro mais comum de todos num deploy de node-server do Nitro especificamente, já que os próprios padrões do Nitro não garantem vincular a todas as interfaces.

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 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, as duas precisam permitir tráfego de entrada pro desafio ACME se completar.

A porta 3000 (ou 80) já está em uso no host. Alguma outra coisa já está vinculada a ela, geralmente uma instalação padrão de nginx sobrando de uma imagem base, ou um container sobrando de uma tentativa anterior. sudo ss -tlnp | grep ':80\|:3000' mostra o que está ocupando; o arquivo do Compose deste guia não publica a 3000 pro host de forma alguma (ela só é exposed pro serviço caddy), então um conflito aqui geralmente aponta pra porta 80 ou 443 no lugar.

Uma variável de ambiente mostra o valor antigo depois que você mudou ela. Qualquer coisa lida com useRuntimeConfig() é resolvida quando o container inicia, então mudar ela e reiniciar o container já basta, sem precisar de rebuild. Qualquer coisa com o prefixo NUXT_PUBLIC_, em contraste, é embutida no JavaScript do lado do cliente durante o próprio build, então mudar uma dessas exige docker compose up -d --build web de novo, um restart por si só não faz nada por ela.

Perguntas frequentes

Eu preciso definir nitro: { preset: "node-server" } explicitamente, ou o padrão já basta? O padrão já é funcionalmente suficiente, node-server já é o que você recebe sem nenhuma configuração. Fixar isso explicitamente é um seguro pequeno pra produção pra que uma futura release do Nuxt mudando seu preset padrão não mude seu destino de deploy junto com ele.

Eu posso usar pnpm, yarn ou bun 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. (Adicione um ramo pra bun.lockb se você usa Bun especificamente, o exemplo acima cobre os três lockfiles mais comuns.)

E as server routes e API routes do Nuxt, elas rodam nesse mesmo container? Sim, qualquer coisa em server/api ou server/routes na sua aplicação Nuxt é empacotada no mesmo .output/server/index.mjs e roda no mesmo processo que suas páginas, não há nenhum backend separado pra fazer deploy a menos que você tenha deliberadamente separado um.

É seguro rodar isso com múltiplas instâncias por trás de um load balancer depois? Em geral sim, o node-server do Nitro não tem estado por padrão, então rodar vários containers por trás do balanceamento de carga do Caddy funciona sem configuração especial. A ressalva usual se aplica se sua aplicação mantém algum estado em memória entre requisições (limitação de taxa, cache em processo), que precisaria se mudar pra algo compartilhado como o Redis uma vez que você estiver rodando mais de uma instância.

Como isso é diferente de fazer deploy na Vercel ou na Netlify? Funcionalmente sua aplicação roda o mesmo código dos dois jeitos, a saída de build do Nuxt é portável por design. O que você está trocando é um pipeline de build gerenciado e uma rede de edge por possuir o servidor, o Dockerfile e o certificado você mesmo, o que é mais barato em tráfego constante e mais trabalho nas bordas (escalonamento, hospedagem de imagem, ambientes de preview).

O Dockerfile e o arquivo do Compose aqui não mudam muito entre projetos Nuxt, a parte que realmente varia é o que sua aplicação lê 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.

Faça deploy deste repositório sem o Dockerfile

Cole a URL do repositório no Railyard e os builds, portas e variáveis de ambiente são detectados pra você num servidor Hetzner.

Plano grátis, sem cartão, e 14 dias de Pro para cada equipe nova. Um preço fixo por equipe.