Substitua os review apps do Heroku por previews de pull request
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Os review apps do Heroku sobem uma aplicação descartável completa pra cada pull request, o que é genuinamente útil pros revisores: clicar num link em vez de ler um diff e imaginar o que ele faz. A parte fácil de esquecer é que o app.json exige addons e uma região, todo review app roda no preço normal de dyno e add-on do Heroku, e o responsável pelo faturamento de uma organização paga pela aplicação de cada PR aberto igual pagaria por uma de produção, rateado por segundo mas não de graça. Este guia constrói o mesmo workflow você mesmo com GitHub Actions e Docker num único servidor, e então mostra como é uma versão hospedada disso depois de considerar o problema de custo real: aplicações de preview ociosas.
No final você vai ter um sistema de preview por PR funcionando que você construiu e entende completamente, e vai saber exatamente qual troca uma funcionalidade de preview hospedada está realmente fazendo em seu nome.
O que você vai precisar
- Um servidor pro qual a sua aplicação principal (ou uma cópia de staging dela) já faz deploy como uma imagem Docker. Um Hetzner CX23 (2 vCPU, 4 GB de RAM, €5,99/mês sem IVA) lida com uns poucos ambientes de preview simultâneos pra uma aplicação web típica.
- Um repositório no GitHub com Actions habilitado, e um jeito de construir e dar push numa imagem Docker com tag por pull request (a maioria das configurações de CI já faz isso pra branch principal; este guia marca por número de PR em vez disso).
- Um registro de DNS wildcard ou um subdomínio por preview (este guia usa
pr-<number>.review.example.com), e o Caddy já rodando no servidor pro HTTPS. - Acesso SSH do GitHub Actions pro servidor (uma deploy key basta; este guia não cobre configurar isso, já que a maioria das equipes já tem uma pro deploy principal).
Passo 1: construa e marque uma imagem por pull request
O que quer que construa a sua imagem de produção já funciona aqui; a única mudança é a tag:
name: Build PR image
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push
run: |
docker build -t ghcr.io/you/app:pr-${{ github.event.pull_request.number }} .
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/you/app:pr-${{ github.event.pull_request.number }}
Marcar pelo número do PR em vez do SHA do commit significa que todo push pro mesmo PR sobrescreve a mesma tag, que é exatamente o comportamento de um review app: o preview acompanha a branch, não um commit congelado.
Passo 2: escreva o template Compose por PR
services:
app:
image: ghcr.io/you/app:pr-${PR_NUMBER}
restart: unless-stopped
env_file:
- .env
ports:
- "127.0.0.1:${APP_PORT}:3000"
Vincular a 127.0.0.1 mantém o container inacessível diretamente da internet; o Caddy é o único ponto de entrada público, igual a como um único servidor de produção com várias aplicações normalmente isola elas umas das outras numa mesma máquina.
Passo 3: escreva o script de deploy
Isso roda no próprio servidor, disparado via SSH a partir do workflow do Actions:
#!/usr/bin/env bash
set -euo pipefail
PR_NUMBER="$1"
APP_PORT=$((40000 + PR_NUMBER))
DIR="/opt/previews/pr-$PR_NUMBER"
mkdir -p "$DIR"
cp /opt/previews/template/docker-compose.yml "$DIR/"
cp /opt/previews/template/.env "$DIR/.env"
export PR_NUMBER APP_PORT
docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" pull
docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d
cat > "/etc/caddy/sites/pr-$PR_NUMBER.caddyfile" <<EOF
pr-$PR_NUMBER.review.example.com {
reverse_proxy 127.0.0.1:$APP_PORT
}
EOF
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
Derivar APP_PORT a partir do número do PR (40000 + PR_NUMBER) é um jeito simples de evitar colisão de portas entre PRs abertos simultaneamente sem um registro separado pra acompanhar o que já está em uso; funciona até você ter mais de alguns milhares de PRs abertos ao mesmo tempo, o que não é uma restrição real pra quase nenhuma equipe.
O Caddyfile principal precisa de uma linha adicionada uma vez, no início, pra pegar todo arquivo de site gerado automaticamente:
import /etc/caddy/sites/*.caddyfile
Passo 4: conecte isso ao workflow do GitHub Actions
deploy-preview:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy preview
run: |
ssh deploy@your-server "/opt/previews/deploy.sh ${{ github.event.pull_request.number }}"
Adicione um job correspondente de pull_request: types: [closed] que roda um script de desmontagem em vez disso: docker compose down, remover o diretório pr-<number>, apagar o Caddyfile gerado, e recarregar o Caddy. Um PR fechado com um container pendurado é o equivalente, em review app, de um servidor que ninguém lembrou de desligar.
Passo 5: confirme de ponta a ponta
Abra um PR de teste, espere o workflow terminar, e visite https://pr-<number>.review.example.com; você deve ver a branch desse PR rodando, não a aplicação de produção. Dê push num commit novo no mesmo PR e confirme que a imagem refaz o build e a mesma URL agora serve o commit novo. Feche o PR e confirme que o container e o arquivo de site do Caddy desapareceram em menos de um minuto.
O caminho mais rápido: faça isso com a Railyard
Tudo acima — o build por PR, a porta dinâmica, a configuração do Caddy gerada, a desmontagem ao fechar — é o que a funcionalidade de ambientes de preview da Railyard faz automaticamente depois que o GitHub é conectado e os previews são ativados pra uma aplicação. Todo pull request recebe a própria cópia completa automaticamente, mesmo build, mesmos tipos de processo, a própria URL, removida no momento em que o PR fecha, sem arquivo de workflow ou script de deploy pra você manter.
A parte que vale destacar especificamente, já que é o problema de fato dos review apps do Heroku em vez de um detalhe menor: aplicações novas de preview e staging dormem por padrão depois de 30 minutos sem tráfego, despertando na próxima requisição. Um PR aberto por duas semanas enquanto alguém está de férias não é um container ocioso custando o preço cheio o tempo todo; está dormindo quase o tempo todo. Isso é configurável por aplicação em Settings → General (nunca, 10 minutos, 30 minutos, 1 hora, 6 horas, ou um dia sem tráfego, ou horários ativos específicos em vez disso), e é a única coisa que a configuração manual dos Passos 1 a 5 não te dá sem você construir sozinho, já que nada naquele script impede um container de rodar no preço cheio pelo tempo que o PR ficar aberto. Existe um limite de 5 previews simultâneos por aplicação, o que raramente importa pra quantidade típica de PRs abertos de uma equipe; referência completa: Environments and previews.
Resolução de problemas
A URL do preview retorna 404 mesmo com o script de deploy tendo rodado. Cheque se docker exec caddy caddy reload --config /etc/caddy/Caddyfile de fato teve sucesso; um erro de sintaxe num arquivo de site gerado faz o reload falhar silenciosamente a não ser que você cheque o código de saída dele, deixando o Caddy rodando a configuração anterior.
Dois PRs servem o mesmo conteúdo. O APP_PORT colidiu, geralmente porque a fórmula de derivação de porta foi mudada sem checar os previews já rodando primeiro; docker ps --format '{{.Names}} {{.Ports}}' mostra o que de fato está vinculado onde.
Containers se acumulam depois que PRs fecham. O workflow de desmontagem ou não está configurado pro tipo de PR closed, ou o GitHub não disparou ele porque o PR foi fechado sem merge por um caminho que o filtro types do seu workflow não cobre; confirme que types: [closed] pega tanto PRs com merge quanto simplesmente fechados, já que o GitHub dispara o mesmo evento pros dois.
O preview mostra código antigo depois de um push. Confirme que a tag da imagem no template Compose de fato corresponde ao que o Passo 1 acabou de dar push; docker compose pull antes de up -d é obrigatório, já que o Compose não puxa uma imagem nova pra uma tag existente a não ser que seja instruído a fazer isso.
Perguntas frequentes
Isso funciona com GitLab em vez de GitHub? O mesmo padrão funciona com GitLab CI/CD em vez de Actions; o template Compose por PR, a geração de configuração do Caddy e o script de desmontagem não mudam, só a sintaxe do workflow que os dispara.
Previews podem compartilhar um banco de dados com produção? Podem, mas uma migration rodando contra a conexão de um preview com o banco de dados de produção é um risco real que vale evitar; um banco de dados por preview, mesmo um barato e descartável populado a partir de um snapshot, é mais seguro e é o que a funcionalidade de ambientes da Railyard faz por padrão exatamente por esse motivo.
O que acontece com os secrets que cada preview precisa? O template .env da configuração manual é uma lacuna real: ele precisa de valores reais pra qualquer coisa que a aplicação exija (chaves de API, uma URL de banco de dados), e copiar secrets de produção pra todo preview é uma questão de segurança própria que vale pensar separadamente, geralmente limitando credenciais só-de-preview desde o início.
Um CX23 é mesmo suficiente pra vários previews ao mesmo tempo? Pra um punhado de previews leves rodando a mesma aplicação como um processo cada, sim; é exatamente o tipo de carga de trabalho com que uma política de sleep automático mais ajuda, já que a maioria dos PRs abertos não está sendo clicada ativamente em nenhum momento dado.
Os review apps do Heroku acertaram uma coisa que vale manter: um revisor deveria receber um link rodando, não um diff pra imaginar. O que eles erram é cobrar por todo PR aberto igual uma aplicação de produção independente de alguém estar realmente olhando ou não, o que os Passos 1 a 5 acima reproduzem exatamente a não ser que você também construa a lógica de sleep sozinho. Essa é a única coisa que vale ser honesto antes de copiar qualquer funcionalidade de ambiente de preview, hospedada ou construída por você mesmo.
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.