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.

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 --app

Abra 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 apply

Dicas 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 miniserver

Passo 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 80

Crie 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
out

Crie 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: true

Faç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 -d

Bom 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-net

O 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: true

Um 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 cloudflared

Por 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.