Esse projeto tinha um cliente de verdade: meu pai. Ele revende carros, e eu construí o vpcarmultimarcas.com ↗ pra ele, catálogo público, painel admin, o pacote completo. A parte que eu achava que seria só "subir num servidor" acabou me ensinando três coisas que nenhum tutorial tinha me avisado.
01 O problema
Eu tinha uma aplicação de três peças rodando lindamente no meu WSL: uma API em Express + TypeScript + Prisma, um banco PostgreSQL, e um front em React + Vite + Tailwind, tudo orquestrado por Docker Compose. Faltava o principal: colocar no ar num domínio de verdade, com HTTPS, barato o suficiente pra um site de loja de bairro, e sem virar um segundo emprego de manutenção.
Escolhi a AWS Lightsail, é a AWS "sem o labirinto": uma instância com
preço fixo e previsível, sem a sopa de VPC, security groups e IAM que o EC2 puro exige.
Peguei o plano de US$ 5/mês, Ubuntu 24.04, na região
sa-east-1 (São Paulo, pra latência baixa). Plano ingênuo: subir o
docker compose up e acabou.
02 Trazendo o código pro servidor
Primeiro tropeço, logo no git clone. O repositório é privado, e o GitHub
não aceita mais senha de conta por HTTPS:
$ git clone https://github.com/picoliW/vpcar-site.git
Password authentication is not supported for Git operations.
fatal: Authentication failed
Dava pra resolver com um Personal Access Token, mas ele fica gravado no servidor e vale pra tudo que a minha conta acessa. A opção mais limpa é uma deploy key: um par de chaves só daquela máquina, só de leitura, que eu revogo num clique sem mexer na conta. Gerei a chave no servidor, colei a pública em Settings → Deploy keys do repositório com "write access" desmarcado, e clonei via SSH:
ssh-keygen -t ed25519 -C "vpcar-lightsail" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub # cola no GitHub como Deploy key (read-only)
git clone git@github.com:picoliW/vpcar-site.git # note o git@ e o :
03 O Prisma que não subia
Com o código no servidor, rodei o Compose. O Caddy pegou o certificado HTTPS de primeira, o Postgres subiu, o front subiu, e a API entrou num loop de reinício. Nos logs, antes do crash:
Prisma failed to detect the libssl/openssl version to use...
Defaulting to "openssl-1.1.x"
Error: Could not parse schema engine response
Isso não acontecia no meu WSL, e o motivo é sutil: eu tinha baseado a imagem da API em
node:20-alpine. O Alpine usa musl em vez de glibc e não
traz o openssl, e o Prisma 5 erra a detecção nesse ambiente, cai num engine
openssl-1.1.x que não existe na imagem, e morre antes de aplicar as
migrations. No meu WSL nunca apareceu porque lá é Ubuntu com glibc e openssl instalados.
A correção foi trocar a base pra Debian slim e instalar o openssl explicitamente:
FROM node:20-slim
WORKDIR /app
# O Prisma precisa do openssl instalado pra escolher o engine certo.
# Base Debian (não alpine/musl): no Alpine o Prisma erra a detecção
# e cai no engine openssl-1.1.x, que não existe na imagem.
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install
COPY prisma ./prisma
RUN npx prisma generate
COPY . .
RUN npm run build
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
Depois disso os logs mostraram as migrations aplicadas e a API estável. Lição guardada: Alpine é ótimo pra imagens pequenas, mas Prisma + Alpine é uma armadilha conhecida, pra Prisma, Debian slim custa alguns megabytes a mais e evita a dor.
04 A instância que engasgou no build
Com o Dockerfile certo, o próximo problema não era erro, era lentidão. O
docker compose build demorava uma eternidade e às vezes travava. O plano de
US$ 5 tem 512 MB de RAM, e compilar o TypeScript e o bundle do Vite ao
mesmo tempo estourava a memória: o servidor entrava em swap, e build em swap é
build no ritmo de disco.
A saída limpa na Lightsail é tirar um snapshot e recriar num plano maior. Snapshot da instância, nova instância de 1 GB a partir dele, reaponto o IP estático, e pronto, o build que se arrastava passou a terminar rápido. Ninguém precisa recriar nada do zero: o snapshot carrega o disco inteiro, com o repositório e o Docker já lá.
Antes do primeiro up, gastei um tempo bom com a API sem conectar no banco.
Dois detalhes: a senha do Postgres fica gravada dentro do volume na
primeira subida, mudar o .env depois não muda a senha real, e a API para
de conectar. E cifrão na senha é traiçoeiro: o Docker Compose interpreta o
$ como variável e o substitui por vazio. Senha definida antes do primeiro
up, e sem $, resolveu.
05 Analytics sem entregar dados pro Google
Eu queria saber quantas visitas o site recebia, mas sem colar o Google Analytics e
entregar os dados dos visitantes. Escolhi o Umami: analytics open source,
sem cookies, que eu mesmo hospedo. E como já tinha um Postgres rodando, era só reaproveitar,
criei um banco umami separado no mesmo container.
A ideia inicial era servir o Umami num subcaminho, vpcarmultimarcas.com/stats,
via BASE_PATH. Não funcionou, no Umami 3.2.0 sobre Next.js 16 o subcaminho
quebrava. Em vez de brigar, mudei pra um subdomínio:
stats.vpcarmultimarcas.com. Um registro DNS, o Caddy cuidando do certificado,
e o script de tracking injetado no index.html. Funcionou de primeira, e
subdomínio é a topologia mais limpa pra isso de qualquer jeito.
06 Como ficou montado
No fim, uma instância só carrega tudo, orquestrado por Docker Compose:
┌─ Lightsail (Ubuntu, 1 GB, sa-east-1) ─────────┐
browser ─▶ Caddy (HTTPS) ─┼─▶ web (React + Vite, build estático) │
├─▶ api (Express + Prisma) ─▶ Postgres │
└─▶ umami (stats.subdomínio) ─▶ Postgres (db umami)
└───────────────────────────────────────────────┘
O deploy do dia a dia virou banal: git push no meu WSL, e no servidor um
git pull seguido de docker compose up -d --build. Repositório
privado, chave de leitura, e nenhum .env versionado.
07 O que aprendi
- "Funciona na minha máquina" mora nas bibliotecas de sistema, não no seu código. O Prisma quebrou por causa de glibc vs musl e openssl, coisas do sistema base, invisíveis até o container de produção rodar.
- RAM é o gargalo escondido de um build. Não foi CPU nem disco: foram 512 MB que não davam conta de compilar TypeScript e Vite juntos. Snapshot + plano maior é a saída de menor atrito na Lightsail.
- Quando uma ferramenta briga com o subcaminho, o subdomínio quase sempre vence. Vale pro Umami e pra qualquer app que assuma que vive na raiz.
- Deploy key > token pra um servidor. Escopo mínimo, só leitura, revogável num clique, sem tocar na conta.
O site do meu pai está no ar, com HTTPS e analytics próprio, rodando por poucos dólares
por mês. E o que eu achei que seria um docker compose up virou uma aula de
tudo que existe entre "funciona aqui" e "funciona pra todo mundo".