Fazendo deploy de Next.js na Hetzner
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Você tem uma aplicação Next.js 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 next build rodando em modo standalone 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 Next.js pequena a média em tempo de execução; se o próprio
next buildficar 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 Next.js com scripts
buildestartnopackage.json, em Node.js 20.9 ou mais novo, que é o mínimo que o próprio Next.js exige atualmente. - 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: ativar a saída standalone
O próprio build do Next.js só inclui os arquivos que uma página precisa se você optar pelo output file tracing. Adicione isto ao next.config.js (ou next.config.ts):
module.exports = {
output: "standalone",
}
Isso faz o next build produzir uma pasta .next/standalone contendo um server.js mínimo e só o node_modules que sua aplicação de fato usa em tempo de execução, em vez de precisar copiar toda a árvore de node_modules pra imagem de produção. O servidor standalone não copia sua pasta public/ nem .next/static automaticamente, os dois são copiados explicitamente pra saída standalone no Dockerfile abaixo.
Passo 4: escrever o Dockerfile
Isto segue o padrão do exemplo oficial de Docker do próprio Next.js, reduzido:
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 é uma release LTS atual do Node.js, confortavelmente acima do mínimo de 20.9 que o Next.js documenta hoje. 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, então a imagem final contém só o que output: "standalone" rastreou como necessário mais as duas pastas que ele não copia por conta própria. USER node roda o processo como o usuário não-root node que já está presente nas imagens oficiais do Node, em vez de root, o que não custa nada e vale a pena fazer por padrão.
Passo 5: escrever o arquivo do Compose e o 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.yourdomain.com {
reverse_proxy web:3000
}
PORT e HOSTNAME são as duas variáveis de ambiente que o server.js standalone do Next.js lê diretamente, HOSTNAME=0.0.0.0 (definido no Dockerfile) é o que faz o servidor escutar em todas as interfaces dentro do container em vez de só em localhost, o que caso contrário o tornaria inalcançável pelo serviço caddy ao lado dele. Qualquer variável que sua aplicação lê em tempo de execução com process.env, em contraste com uma embutida no momento do build, vai nesse bloco environment: também; só variáveis com o prefixo NEXT_PUBLIC_ são embutidas no bundle do cliente durante o next build, tudo o mais fica só no servidor e pode mudar sem um rebuild.
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 servidor do Next.js registrar uma linha como Ready in <n>ms, 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.
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 sua aplicação 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
Tudo nos Passos 1 e 3 até o 6 é o que a própria detecção de Node do Railyard faz automaticamente quando você faz deploy de um repositório em vez de montar o Dockerfile à mão. Pra um repositório sem o próprio Dockerfile, ele 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. Se o repositório já traz o próprio Dockerfile, esse é usado direto em vez do caminho automático.
O container escuta na porta 3000 por padrão especificamente pra um repositório Next.js, combinando com a porta que o Dockerfile deste guia expõe. Na Hetzner, o próprio servidor pode ser criado direto pelo Railyard com a região e o tamanho que você escolhe no momento da criação, em vez de pela etapa separada do Cloud console do Passo 1.
Faça deploy direto: /deploy/nextjs-to-hetzner. Veja Deploy from a repository pra como a detecção e a etapa de build funcionam de ponta a ponta.
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 next 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).
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 aplicação constrói e inicia, mas a página nunca carrega, o Caddy dá timeout. Cheque se HOSTNAME=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é.
Variáveis NEXT_PUBLIC_ mostram o valor antigo depois que você mudou elas. Essas são embutidas no bundle JavaScript no momento do build, então mudar uma exige docker compose up -d --build web de novo, só reiniciar o container sem reconstruir não faz nada por elas.
A porta 3000 (ou 80) já está em uso no host. Alguma outra coisa já está vinculada a ela, geralmente um reverse proxy anterior 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.
Perguntas frequentes
Eu preciso do output: "standalone", ou um next start normal também funciona no Docker? O next start funciona bem num container, mas precisa do seu node_modules completo copiado pra dentro, o que faz uma imagem bem maior e um deploy mais lento; o modo standalone é o caminho oficialmente recomendado especificamente pra deploys containerizados.
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.
E se minha aplicação usa otimização do next/image? Ela funciona sem nenhuma configuração no next start, incluindo em modo standalone, desde que o container tenha memória pra fazer o processamento de imagem em tempo real; isso é uma preocupação diferente do problema de memória no momento do build na seção de Resolução de problemas acima.
É seguro rodar isso com múltiplas instâncias por trás de um load balancer depois? Em geral sim, mas os próprios docs do Next.js apontam algumas coisas pra configurar primeiro se você fizer isso: um cache handler compartilhado em vez do padrão em disco, e uma NEXT_SERVER_ACTIONS_ENCRYPTION_KEY consistente entre as instâncias se você usa Server Actions, senão uma instância não consegue descriptografar o que outra criptografou.
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 é o build gerenciado e a rede de edge da Vercel 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, infraestrutura de otimização de imagem, ambientes de preview).
O Dockerfile e o arquivo do Compose aqui não mudam muito entre projetos Next.js, 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.