← Notas técnicas··7 min de leitura

Self-host de Next.js 16 atrás de Caddy: do build à produção em uma noite

Quando você precisa colocar um site Next.js no ar, o caminho padrão hoje é Vercel. Funciona, é grátis pra projeto pequeno, e em cinco minutos você tem deploy contínuo com domínio customizado. Pra Khiza Studio escolhemos outro caminho — não por dogma, mas porque o servidor que sustenta a operação já roda Caddy pra outros sites. Adicionar mais um vendor pra hospedar uma landing page placeholder seria mais complexidade, não menos.

O resultado: este site é um Next.js 16 rodando sob systemd, atrás de Caddy, no mesmo servidor que serve outros domínios. Sem container. Sem CDN. Sem CI/CD elaborado pro v0. Caddy cuida do TLS sozinho.

Esse post documenta as quatro peças que fazem isso funcionar — e os lugares onde o Next.js 16 te empurra pra um setup específico que não é óbvio na documentação.

A decisão: por que não Vercel

A pergunta que importa não é “Vercel ou self-host” — é “o que adiciona menos superfície”. Pra Khiza Studio no estágio atual:

  • O servidor já existe. Caddy já roda. Reverse proxy é uma linha por subdomínio.
  • A página é um placeholder estático com algumas seções. Nada que precise de Edge Functions, ISR ou Image Optimization remoto.
  • Cada serviço gerenciado adicional é mais uma fatura, mais um vendor pra revogar token quando alguém sai, mais uma dependência operacional.
  • Operadores brasileiros (CTO incluso) ainda pagam preço FX num plano Vercel pago se a coisa crescer.

Self-host num servidor que já paguei resolve. Vercel resolve melhor quando o time é maior e a complexidade do app justifica abrir mão de controle. Pra v0 da Khiza, esse cruzamento ainda não chegou.

Peça 1: output: "standalone"

O Next.js 16 — assim como o 15 — sabe gerar um build mínimo, autossuficiente, com só os arquivos do node_modules que o server.js realmente precisa. Liga assim:

next.config.tsts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

Depois de pnpm build, o diretório .next/standalone/ contém:

  • server.js — entrada Node.js que serve a app
  • node_modules/ enxuto (só o que o server.js importa)
  • .next/ com os artefatos do build

É esse diretório que vai pro servidor. Sem pnpm install --prod no host, sem process manager exótico. Você roda node server.js e tá feito.

Peça 2: o detalhe que a doc esconde — public/ e .next/static/

Aqui mora o gotcha que custa uma hora se você não souber. O standalone build, propositalmente, não copia public/ nem .next/static/ pro tree standalone. A premissa do Next.js é que você vai servir esses estáticos via CDN (Vercel, Cloudflare, etc.) e só o server.js ficará no host.

Se você quer servir tudo no mesmo processo Node — que é o que se quer pra um self-host minimalista — precisa copiar manualmente. Resolvemos isso direto no script de build do package.json:

package.json (trecho)json
{
  "scripts": {
    "build": "next build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",
    "start": "HOSTNAME=127.0.0.1 PORT=4827 node .next/standalone/server.js"
  }
}

Depois disso, .next/standalone/ é uma árvore autossuficiente: server.js + node_modules + public + .next/static. Você consegue copiar essa pasta pra qualquer lugar e rodar node server.js que funciona.

HOSTNAME=127.0.0.1 é importante: o processo só escuta em loopback. Caddy é o ingress público; ninguém deve falar com a porta 4827 direto.

Peça 3: systemd como process manager

Process manager preferido: o que já vem com a distro. Em vez de PM2, Forever ou Docker — systemd. Uma unit, log via journalctl, restart automático em caso de falha.

khiza-studio.serviceini
[Unit]
Description=Khiza Studio (Next.js standalone)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=khiza-web
Group=khiza-web
WorkingDirectory=/http/khiza-studio

Environment=NODE_ENV=production
Environment=HOSTNAME=127.0.0.1
Environment=PORT=4827

ExecStart=/usr/bin/env node server.js

Restart=on-failure
RestartSec=5s

# Hardening: switches do systemd que custam zero ligar.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/http/khiza-studio
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
SystemCallArchitectures=native
UMask=0027

[Install]
WantedBy=multi-user.target

Os blocos no final (NoNewPrivileges, ProtectSystem, etc.) não são paranoia teatral — são switches do systemd que custam zero ligar e fecham vetores reais. ProtectSystem=strict torna /usr somente-leitura pra esse processo; ReadWritePaths=/http/khiza-studio reabre só o diretório que o app realmente toca. Se algum dia uma vulnerabilidade conseguir RCE pro processo, ela não consegue plantar nada em /usr/bin.

User=khiza-web é um system user dedicado ao webserving — não roda nada além disso, sem shell de login interessante, sem sudo. Cada novo site que adicionarmos no servidor reusa esse mesmo user com seu próprio /http/<site>/ e sua própria unit.

Peça 4: Caddy como ingress

Caddy faz uma coisa que era trabalhoso fazer com nginx: TLS automático via Let's Encrypt, sem certbot manual, sem renovação via cron. Snippet inteiro:

Caddyfile (trecho)caddy
studio.wagnerbonfiglio.com.br {
    encode zstd gzip
    reverse_proxy 127.0.0.1:4827
}

Quatro linhas. Aponta o registro A do DNS, recarrega o Caddy (systemctl reload caddy), e na primeira request o cert TLS é provisionado.

O fluxo, montado

diagramatext
   host com Caddy   (v0: build acontece no próprio host)
  ┌──────────────────────────────────────────────────────┐
  │  ~/khiza-studio   (admin user, ex: khizaai)          │
  │    git pull --ff-only                                │
  │    pnpm install --frozen-lockfile                    │
  │    pnpm build                                        │
  │             ↓                                        │
  │    sudo cp -aT .next/standalone /http/khiza-studio   │
  │    sudo chown -R khiza-web:khiza-web                 │
  │             ↓                                        │
  │  /http/khiza-studio/   (owner: khiza-web)            │
  │    server.js   node_modules/   public/   .next/      │
  │             ↓                                        │
  │    sudo systemctl restart khiza-studio               │
  │             ↓                                        │
  │      127.0.0.1:4827                                  │
  │             ↓                                        │
  │  Caddy (TLS + reverse proxy)                         │
  │             ↓                                        │
  └────────  studio.wagnerbonfiglio.com.br  ──── internet

O deploy v0 é literalmente, num único shell no host: do clone do repo, git pull --ff-only, pnpm install --frozen-lockfile, pnpm build, sudo cp -aT .next/standalone /http/khiza-studio, sudo chown -R khiza-web:khiza-web /http/khiza-studio e sudo systemctl restart khiza-studio. Sem SSH de volta pra outro lugar, sem chave de deploy, sem rsync. Quem prefere construir fora do host tem um fluxo alternativo via rsync documentado no README do repo.

Quando esse padrão para de fazer sentido

Honestamente:

  • Múltiplos containers/microsserviços com autoscaling. Saia para Fly.io, Render, ou eventualmente Kubernetes.
  • Tráfego que precisa de CDN edge perto do usuário. Vercel ou Cloudflare na frente.
  • Time grande sem ninguém com paciência pra debugar systemd. Vercel.

Pra um landing-page-com-blog rodando ao lado de outros sites num VPS já existente — que é o caso da Khiza Studio em maio de 2026 — é o caminho de menor superfície. Quando passarmos a precisar de Postgres, sessões, billing, a gente reavalia, talvez fragmenta em mais units, talvez migra a parte autenticada pra outro lugar. Por enquanto: três comandos, quatro linhas de Caddyfile, uma noite.

Pegue e adapte

Os quatro artefatos descritos aqui — o next.config.ts, o build script, a unit do systemd e o snippet do Caddy — estão completos no post acima. Não há dependência escondida, não tem placeholder. Copie, troque o domínio, troque o nome do user, troque a porta, e tá pronto pra rodar.

Se você tá no Brasil rodando um VPS pra projetos pessoais ou pra cliente e ainda paga uma assinatura mensal porque “deu trabalho montar do zero”, esse é o caminho de menor pacote de complexidade. O preço do servidor já tá pago.


Esse foi o primeiro post técnico publicado da Khiza Studio. Se a gente puder ajudar a montar algo parecido pro seu projeto — ou pro produto da sua empresa — fale com a gente.