Guia Passo a Passo
Como construir um site estático e hospedar de casa — do zero ao deploy, sem experiência prévia necessária.
Antes de Começar
Você vai precisar de quatro coisas. Não se preocupe em acertar tudo de primeira — sempre dá pra ajustar depois.
- Node.js 22 ou superior instalado na sua máquina de desenvolvimento. Baixe em nodejs.org ou instale via nvm (recomendado — permite alternar entre versões do Node facilmente).
- Docker e Docker Compose instalados no servidor. Siga a documentação oficial do Docker para seu sistema — no Ubuntu são alguns comandos apt.
- Uma máquina para usar como servidor. Qualquer laptop, desktop ou mini PC antigo serve. Só precisa ficar ligado e conectado à sua rede. Este guia usa um Mac Mini com Ubuntu, mas qualquer distribuição Linux funciona.
- Um domínio apontando para o DNS da Cloudflare. O plano gratuito da Cloudflare é mais que suficiente. Você vai precisar dele para o túnel e os certificados TLS.
Passo 1: Criar o Projeto Next.js
Vamos criar um novo projeto Next.js, configurar a exportação estática e a estilização. No final deste passo você terá um site que gera arquivos HTML puros.
Comece criando um novo projeto. A flag --app usa o App Router (padrão no Next.js 15), e --tailwind configura o Tailwind CSS automaticamente.
npx create-next-app@latest resume --typescript --tailwind --appAbra o next.config.ts e adicione a configuração de exportação estática. Essa única linha é o que faz toda a abordagem funcionar — diz ao Next.js para gerar arquivos HTML estáticos em vez de exigir um servidor Node.js.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
images: { unoptimized: true },
};
export default nextConfig;O Tailwind CSS 4 usa custom properties CSS para temas. Defina seus design tokens em globals.css — essas variáveis ficam disponíveis em toda a aplicação e facilitam manter uma aparência consistente.
/* globals.css */
@import "tailwindcss";
@theme {
--color-bg: #0d1117;
--color-bg-inset: #010409;
--color-surface: #161b22;
--color-surface-hover: #1c2128;
--color-border: #30363d;
--color-border-hover: #484f58;
--color-text: #e6edf3;
--color-text-secondary: #8b949e;
--color-text-muted: #6e7681;
--color-accent: #58a6ff;
--color-accent-dim: rgba(56, 139, 253, 0.15);
}O next/font/google cuida do carregamento de fontes sem layout shift. Declare suas fontes em layout.tsx e elas são automaticamente otimizadas e self-hosted — sem requisições externas ao Google Fonts em runtime.
// layout.tsx
import { DM_Sans, Instrument_Serif } from "next/font/google";
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-body",
weight: ["300", "400", "500", "600", "700"],
});
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
variable: "--font-display",
weight: "400",
style: ["normal", "italic"],
});
// In the <html> tag:
<html className={`${dmSans.variable} ${instrumentSerif.variable}`}>
<body className="font-[family-name:var(--font-body)]">
{children}
</body>
</html># Test the build
npm run build
# You should see an "out/" directory with static HTML files
ls out/Por que exportação estática?
Um site exportado estaticamente é apenas arquivos — não tem nada pra crashar, nada pra atualizar, nada pra escalar. Você pode hospedar em Nginx, Caddy, S3, GitHub Pages, ou até servir de um pendrive. O trade-off é que você não pode usar funcionalidades do Next.js que exigem servidor (API routes, ISR, middleware), mas para um site pessoal não são necessárias.
Passo 2: Configurar o Servidor
Qualquer máquina antiga funciona. Este guia usa um Mac Mini Late 2012 com 16 GB de RAM e SSD de 1 TB, mas um laptop usado ou um Raspberry Pi também servem. O objetivo é ter uma máquina Linux headless que você gerencia por SSH.
Baixe o Ubuntu Server em ubuntu.com e grave em um pendrive usando Rufus (Windows) ou Balena Etcher (Mac/Linux). Dê boot pela USB, siga o instalador e escolha "Ubuntu Server (minimized)" — você não precisa de ambiente desktop.
# Download Ubuntu Server from https://ubuntu.com/download/server
# Flash to USB with Balena Etcher or Rufus
# Boot from USB, follow the installer
# Choose "Ubuntu Server (minimized)"Depois da instalação, certifique-se de que o SSH está habilitado para poder gerenciar a máquina remotamente. A partir daqui, pode desconectar o monitor e o teclado — tudo é feito pelo terminal.
# Install SSH server (if not already installed)
sudo apt update && sudo apt install -y openssh-server
# Check it's running
sudo systemctl status ssh
# From your workstation, connect:
ssh [email protected]Dê um IP estático ao servidor para que ele seja sempre acessível no mesmo endereço da rede local. Edite o arquivo de configuração do netplan e aplique as mudanças.
# Edit netplan config
sudo nano /etc/netplan/01-netcfg.yaml# Example netplan configuration
network:
version: 2
ethernets:
enp1s0: # your interface name
dhcp4: false
addresses:
- 192.168.1.100/24
routes:
- to: default
via: 192.168.1.1
nameservers:
addresses:
- 1.1.1.1
- 8.8.8.8# Apply the changes
sudo netplan applyDicas práticas
Habilite o unattended-upgrades para que patches de segurança sejam instalados automaticamente — você não quer ter que fazer SSH toda semana só pra rodar apt update. Dê um hostname memorável à máquina para acessá-la por nome. E considere a economia: toda a infraestrutura custa cerca de R$10/mês em eletricidade. Você é dono do hardware e de toda a stack.
# Enable automatic security updates
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
# Set a friendly hostname
sudo hostnamectl set-hostname miniserverPasso 3: Containerizar o Site
Vamos empacotar o site em um container Docker usando um build multi-stage. A imagem final contém apenas Nginx e seus arquivos estáticos — sem runtime Node.js, menos de 25 MB no total.
Crie um Dockerfile com dois estágios. O primeiro (builder) instala dependências e executa o build do Next.js. O segundo (runner) copia apenas os arquivos estáticos gerados para uma imagem Nginx Alpine. É isso que mantém a imagem de produção minúscula.
# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine AS runner
COPY --from=builder /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80Crie um arquivo nginx.conf que serve os arquivos estáticos com compressão gzip e headers de cache. A diretiva try_files cuida do roteamento client-side — se um arquivo não for encontrado diretamente, tenta adicionar .html ou faz fallback para index.html.
# nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json
application/javascript text/xml
application/xml text/javascript
image/svg+xml;
gzip_min_length 256;
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri.html $uri/ /index.html;
}
}Crie um arquivo .dockerignore para impedir que node_modules, .next e out/ sejam copiados para o build context. Sem isso, os builds ficam mais lentos e você pode ter conflitos inesperados.
# .dockerignore
node_modules
.next
outCrie um docker-compose.yml no servidor que define o serviço. O build context aponta para o código-fonte, e o container se conecta à rede Docker compartilhada que vamos criar no próximo passo.
# docker-compose.yml
services:
resume:
build:
context: /path/to/your/source-code
dockerfile: Dockerfile
container_name: resume
restart: unless-stopped
networks:
- server-net
networks:
server-net:
external: trueFaça o deploy com um único comando. A flag --no-cache garante que o Docker não use layers em cache, para que alterações nos arquivos sejam sempre capturadas. O processo todo leva cerca de 30 segundos.
docker compose build --no-cache && docker compose up -dBom saber
Se precisar debugar, docker exec -it resume sh te coloca no shell do container em execução. Para ver logs, use docker logs resume. E se algo der errado, docker compose down && docker compose up -d te dá um restart limpo.
Passo 4: Configurar a Rede
A peça final: levar o tráfego da internet até seu container sem abrir nenhuma porta na rede doméstica. Vamos usar uma rede Docker bridge, Caddy como reverse proxy e um Cloudflare Tunnel.
Crie uma rede Docker bridge compartilhada. Todos os containers no servidor vão se conectar a essa rede, o que permite que se encontrem por nome (ex: o container do Caddy acessa o do resume como "resume:80") mantendo o tráfego isolado do host.
docker network create server-netO Caddy roda como outro container na mesma rede. Ele atua como reverse proxy, roteando requisições por domínio para o container correto. A melhor parte: o Caddy provisiona e renova certificados TLS do Let's Encrypt automaticamente. Zero gerenciamento de certificados.
# Caddyfile
your-domain.com {
reverse_proxy resume:80
}# docker-compose.yml for Caddy
services:
caddy:
image: caddy:alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- server-net
volumes:
caddy_data:
caddy_config:
networks:
server-net:
external: trueUm Cloudflare Tunnel cria uma conexão somente de saída do seu servidor para a edge network da Cloudflare. Isso significa zero portas de entrada abertas no roteador — sem port forwarding, sem DNS dinâmico, sem regras de firewall. Cloudflare cuida de DNS, proteção DDoS e cache na edge de graça.
# Install cloudflared
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
# Authenticate with Cloudflare
cloudflared tunnel login
# Create a tunnel
cloudflared tunnel create my-tunnel
# Route your domain to the tunnel
cloudflared tunnel route dns my-tunnel your-domain.com
# Create the config file
mkdir -p ~/.cloudflared# ~/.cloudflared/config.yml
tunnel: <your-tunnel-id>
credentials-file: /home/your-user/.cloudflared/<tunnel-id>.json
ingress:
- hostname: your-domain.com
service: http://caddy:443
- service: http_status:404# Run as a system service
sudo cloudflared service install
sudo systemctl start cloudflared
sudo systemctl enable cloudflaredPor que Nginx e Caddy?
Eles servem papéis diferentes. O Nginx vive dentro de cada container como um servidor de arquivos leve — mantém cada app autocontido e portável. O Caddy roda na frente de tudo, roteando tráfego e cuidando do TLS. Você poderia simplificar fazendo o Caddy fazer os dois papéis, mas essa abordagem escala melhor quando você hospeda múltiplos apps. Outro bônus: o Cloudflare Tunnel funciona até atrás de CGNAT, então você não precisa de IP estático nem de IP público para hospedar de casa.
Você está no ar
É isso. Você tem um site estático rodando em um container Docker, servido pelo Nginx, com reverse proxy do Caddy, e tunelado para a internet pelo Cloudflare — tudo de uma máquina na sua prateleira. Sem contas de cloud, sem portas abertas, controle total. Agora faça do seu jeito.