← Blog

Substitua os review apps do Heroku por previews de pull request

Railyard

15 de julho de 2026 · 7 min de leitura

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.

Ative os previews pra uma aplicação

Conecte o GitHub, ative uma configuração, e todo PR depois disso recebe a própria cópia que dorme quando ninguém está olhando.

Plano grátis, sem cartão, e 14 dias de Pro para cada equipe nova. Um preço fixo por equipe.