Resumo

Esta pipeline automatiza a publicação da versão estática do projeto: o GitHub valida o código, gera a pasta final do site e envia os arquivos prontos para o servidor. No ambiente de produção, o conteúdo é publicado diretamente na pasta www da aplicação, com uma cópia temporária da versão anterior durante a troca. Isso deixa o processo mais leve, fácil de repetir e simples de operar no dia a dia.

Vantagens:

  • o deploy fica mais previsível e reproduzível
  • o build roda fora do servidor, poupando recursos da hospedagem
  • a publicação do projeto fica simples no dia a dia com git push ou Run workflow
  • o servidor recebe apenas os arquivos finais do build
  • a manutenção do ambiente fica mais enxuta no servidor
  • o pipeline evita armazenar artifacts intermediários no GitHub Actions
  • se a publicação falhar, o workflow tenta restaurar automaticamente a cópia temporária anterior de www

Use este pipeline se o projeto gera uma pasta final com index.html e arquivos estáticos, normalmente em dist. O servidor apenas entrega arquivos; ele não mantém um processo Node rodando. Se sua aplicação precisa de Node.js, Express, API, webhook, SSR ou processo em background, use o pipeline Node/PM2.

Antes de começar, tenha em mãos:

  • acesso ao repositório no GitHub
  • permissão para criar secrets no GitHub
  • acesso ao painel da hospedagem
  • usuário SSH da aplicação
  • domínio ou subdomínio definido
  • variáveis de ambiente usadas no build, se existirem
  • branch principal confirmada, normalmente main

Importante: a pasta www é limpa a cada deploy antes de receber os novos arquivos. Não mantenha uploads, arquivos manuais ou conteúdos importantes dentro dela.

Pipeline

git push na main
> job CI instala Node 22 no runner
> runner restaura cache do npm
> runner instala dependências com npm ci
> runner executa lint (se existir)
> runner executa testes (se existirem)
> job CD é liberado apenas se o job CI passar
> job CD instala Node 22 no runner
> runner restaura cache do npm
> runner instala dependências com npm ci
> runner busca o ~/.env do servidor somente se ele existir
> runner executa o build estático
> runner valida dist/index.html
> runner compacta o conteúdo de dist
> runner envia o arquivo compactado por SCP
> servidor resolve a pasta *.configr.cloud/www de destino
> servidor valida o arquivo compactado enviado
> servidor cria uma cópia temporária da pasta www atual
> servidor limpa a pasta de publicação atual
> servidor extrai o novo build na pasta www
> servidor remove o arquivo compactado
> servidor valida index.html no destino final
> se a validação falhar, servidor restaura a cópia temporária anterior de www
> servidor remove o backup temporário

Workflow do GitHub Actions

No projeto local:

mkdir -p .github/workflows
nano .github/workflows/ci-cd-static-pipeline.yml

Cole o conteúdo abaixo:

name: CI/CD Static Pipeline

on:
  push:
    branches:
      - main
  workflow_dispatch:

concurrency:
  group: deploy-static-production
  cancel-in-progress: true

jobs:
  ci:
    name: CI - Quality Checks
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run lint
        run: npm run lint --if-present

      - name: Run tests
        run: npm run test --if-present

  cd:
    name: CD - Build and Deploy Files
    needs: ci
    runs-on: ubuntu-latest
    env:
      REMOTE_ARCHIVE_PATH: /home/${{ secrets.SERVER_USER }}/dist.tar.gz

    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Configure SSH
        env:
          SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
        run: |
          if [ -z "$SSH_KEY" ]; then
            echo "SERVER_SSH_KEY is empty or was not configured in GitHub Secrets."
            exit 1
          fi

          mkdir -p ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/server_key
          chmod 600 ~/.ssh/server_key
          if ! ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts; then
            echo "Could not add SERVER_HOST to known_hosts. Check SERVER_HOST in GitHub Secrets."
            exit 1
          fi

      - name: Validate SSH connection
        run: |
          if ! ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "echo 'SSH connection validated.'"; then
            echo "Could not connect to the server using SSH. Check SERVER_HOST, SERVER_USER, SERVER_SSH_KEY, and the authorized public key."
            exit 1
          fi

      - name: Fetch optional build environment from server
        run: |
          if ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "test -f ~/.env"; then
            ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "cat ~/.env" > .server.env
            echo "Remote ~/.env found and loaded for the static build."
          else
            : > .server.env
            echo "Remote ~/.env was not found. Continuing static build without server environment variables."
          fi

      - name: Build static site
        run: |
          if [ -s .server.env ]; then
            set -a
            . ./.server.env
            set +a
          fi

          if ! npm run build; then
            echo "Static build failed. Check the build script and required build-time variables."
            exit 1
          fi

      - name: Validate build output
        run: |
          if [ ! -d dist ]; then
            echo "Build output directory 'dist' was not found."
            exit 1
          fi

          if [ ! -f dist/index.html ]; then
            echo "Static build did not generate dist/index.html."
            exit 1
          fi

      - name: Create deployment archive
        run: |
          if ! tar -czf dist.tar.gz -C dist .; then
            echo "Could not create dist.tar.gz from the dist directory."
            exit 1
          fi

      - name: Upload archive to server via SCP
        run: |
          if ! scp -i ~/.ssh/server_key dist.tar.gz "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${REMOTE_ARCHIVE_PATH}"; then
            echo "Could not upload dist.tar.gz to the server. Check SSH credentials and available disk space."
            exit 1
          fi

      - name: Resolve target directory on server
        id: resolve_target_dir
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            TARGET_DIR=\"\$(find \"\$HOME\" -maxdepth 1 \( -type l -o -type d \) -name '*.configr.cloud' 2>/dev/null | sort | while IFS= read -r candidate; do
              [ -d \"\$candidate/www\" ] && printf '%s\n' \"\$candidate/www\"
            done | head -n 1)\"

            if [ -z \"\$TARGET_DIR\" ]; then
              echo 'Could not find a target *.configr.cloud/www directory on the server.'
              echo 'Create an HTML application in the hosting panel before running this workflow.'
              exit 1
            fi

            printf '%s' \"\$TARGET_DIR\"
          " > .target-dir

          test -s .target-dir
          TARGET_DIR="$(tr -d '\r' < .target-dir)"
          BACKUP_DIR="$(dirname "$TARGET_DIR")/www-backup-tmp"
          printf 'target_dir=%s\n' "$TARGET_DIR" >> "$GITHUB_OUTPUT"
          printf 'backup_dir=%s\n' "$BACKUP_DIR" >> "$GITHUB_OUTPUT"

      - name: Show resolved target directory
        run: |
          echo "Target directory: ${{ steps.resolve_target_dir.outputs.target_dir }}"
          test -n "${{ steps.resolve_target_dir.outputs.target_dir }}"

      - name: Validate deployment archive on server
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            if [ ! -f \"${REMOTE_ARCHIVE_PATH}\" ]; then
              echo 'Deployment archive was not found on the server after upload.'
              exit 1
            fi
          "

      - name: Publish static files on server
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            ARCHIVE_PATH=\"${REMOTE_ARCHIVE_PATH}\"
            TARGET_DIR=\"${{ steps.resolve_target_dir.outputs.target_dir }}\"
            BACKUP_DIR=\"${{ steps.resolve_target_dir.outputs.backup_dir }}\"

            restore_previous() {
              if [ -d \"\$BACKUP_DIR\" ]; then
                find \"\$TARGET_DIR\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
                cp -a \"\$BACKUP_DIR/.\" \"\$TARGET_DIR/\" 2>/dev/null || true
              fi
            }

            mkdir -p \"\$TARGET_DIR\"
            rm -rf -- \"\$BACKUP_DIR\"
            mkdir -p \"\$BACKUP_DIR\"
            cp -a \"\$TARGET_DIR/.\" \"\$BACKUP_DIR/\" 2>/dev/null || true

            if ! find \"\$TARGET_DIR\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +; then
              echo 'Could not clean the target www directory before publishing.'
              restore_previous
              exit 1
            fi

            if ! tar -xzf \"\$ARCHIVE_PATH\" -C \"\$TARGET_DIR\"; then
              echo 'Could not extract the static archive into the target www directory.'
              restore_previous
              exit 1
            fi

            rm -f \"\$ARCHIVE_PATH\"
          "

      - name: Validate published static site
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            TARGET_DIR=\"${{ steps.resolve_target_dir.outputs.target_dir }}\"

            if [ ! -f \"\$TARGET_DIR/index.html\" ]; then
              echo 'Published site validation failed: index.html was not found in the target directory.'
              exit 1
            fi
          "

      - name: Restore previous static files on failure
        if: failure()
        run: |
          TARGET_DIR="${{ steps.resolve_target_dir.outputs.target_dir }}"
          BACKUP_DIR="${{ steps.resolve_target_dir.outputs.backup_dir }}"

          if [ -z "$TARGET_DIR" ] || [ -z "$BACKUP_DIR" ] || [ ! -f ~/.ssh/server_key ]; then
            echo "Target directory, backup directory, or SSH key is not available. Skipping static rollback."
            exit 0
          fi

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e

            if [ ! -d \"$BACKUP_DIR\" ]; then
              echo 'No previous static backup was found. Skipping rollback.'
              exit 0
            fi

            find \"$TARGET_DIR\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
            cp -a \"$BACKUP_DIR/.\" \"$TARGET_DIR/\" 2>/dev/null || true
            echo 'Previous static files were restored after deployment failure.'
          "

      - name: Remove uploaded archive
        if: always()
        run: |
          if [ ! -f ~/.ssh/server_key ]; then
            echo "SSH key was not configured. Skipping remote archive cleanup."
            exit 0
          fi

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            rm -f -- \"${REMOTE_ARCHIVE_PATH}\"
          "

      - name: Remove temporary static backup
        if: always()
        run: |
          BACKUP_DIR="${{ steps.resolve_target_dir.outputs.backup_dir }}"

          if [ -z "$BACKUP_DIR" ] || [ ! -f ~/.ssh/server_key ]; then
            echo "Backup directory or SSH key is not available. Skipping static backup cleanup."
            exit 0
          fi

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            rm -rf -- \"$BACKUP_DIR\"
          "

      - name: Show published files
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            TARGET_DIR=\"${{ steps.resolve_target_dir.outputs.target_dir }}\"

            ls -la \"\$TARGET_DIR\"
          "

No nano, salve e saia com: Ctrl + O, Enter, Ctrl + X.

Depois:

git add .github/workflows/ci-cd-static-pipeline.yml ci-cd-static-pipeline.md
git commit -m "Adiciona fluxo de CI/CD para deploy estático"
git push

Passo a passo do primeiro deploy

0. Criar a aplicação e preparar o acesso SSH

Na hospedagem, crie uma aplicação do tipo HTML.

Antes de configurar as chaves do GitHub Actions, defina também uma senha de acesso manual ao servidor. Isso permite entrar por SSH para validar o caminho publicado, conferir arquivos em www e executar os comandos operacionais do processo.

Na hospedagem, acesse:

Painel da aplicação > Acessos > Senha SFTP/SSH > Alterar Senha

Nesta etapa:

  • defina a senha do usuário da aplicação para o acesso manual via SSH
  • confirme que Usar SSH está ativado

Depois, teste o acesso manual pela sua máquina local:

ssh <usuario>@<host>

Na sua máquina local, dentro da pasta do projeto, valide se ele realmente gera uma saída estática:

npm ci
npm run build
test -f dist/index.html

Se não existir dist/index.html, ajuste o build do projeto ou use outro pipeline.

1. Gerar a chave SSH do GitHub Actions

Na sua máquina local ou em uma máquina segura:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f github_actions_deploy

Quando o terminal pedir a passphrase, deixe em branco e pressione Enter nas duas perguntas. Essa chave deve ser criada sem passphrase para que o GitHub Actions consiga usá-la sem prompt interativo.

Esse comando vai gerar:

  • github_actions_deploy: chave privada
  • github_actions_deploy.pub: chave pública

Copie e cole sempre o conteúdo diretamente do arquivo original, sem alterar quebras de linha, sem adicionar aspas e sem editar manualmente o texto da chave.

Se quiser visualizar e copiar os conteúdos logo em seguida:

cat github_actions_deploy
cat github_actions_deploy.pub

Exemplo visual do que significa copiar a chave completa:

Chave privada:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Chave pública:

ssh-ed25519 ... github-actions-deploy

A cópia deve incluir tudo, inclusive as linhas de início e fim da chave privada.

2. Configurar as chaves e secrets

Copie o conteúdo completo de github_actions_deploy.pub para o servidor ou para a interface da hospedagem. Esta é a chave pública.

Opção 1:

Na hospedagem, acesse:

Painel da aplicação > Acessos > Senha SFTP/SSH > Alterar Senha > Chaves Autorizadas

Cole o conteúdo completo do arquivo github_actions_deploy.pub no campo Chaves Autorizadas, do início ao fim e sem modificar a estrutura da chave. Mantenha Usar SSH ativado e salve em Atualizar.

Opção 2:

No servidor:

mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys

Cole dentro desse arquivo o conteúdo completo de github_actions_deploy.pub, do início ao fim, exatamente como ele foi gerado.

No nano, salve e saia com: Ctrl + O, Enter, Ctrl + X.

Em seguida:

chmod 600 ~/.ssh/authorized_keys

No GitHub

Copie o conteúdo completo de github_actions_deploy para o secret SERVER_SSH_KEY no GitHub. Esta é a chave privada.

Depois, no repositório, acesse Settings > Secrets and variables > Actions > New repository secret.

Criar:

  • SERVER_HOST Valor: <ip-ou-host-do-servidor>
  • SERVER_USER Valor: <usuario>
  • SERVER_SSH_KEY Valor: cole a chave privada completa do arquivo github_actions_deploy, do início ao fim e sem modificar a estrutura da chave

3. Criar o arquivo ~/.env no servidor, se necessário

Conteúdo opcional: crie ~/.env apenas se o build estático depender de variáveis de ambiente; se não depender, pule esta etapa.

No servidor:

ssh <usuario>@<ip-ou-host-do-servidor>
nano ~/.env

Se precisar dele, cole o conteúdo como no exemplo abaixo:

SITE_URL=https://<projeto.seudominio.com.br>

No nano, salve e saia com: Ctrl + O, Enter, Ctrl + X.

Se você criar ou alterar o ~/.env do servidor, basta disparar um novo deploy para gerar e publicar uma nova versão estática com esses valores.

Etapas de apoio

▸ Checklist antes de rodar o workflow

Antes de fazer o primeiro git push ou clicar em Run workflow, confira:

  • workflow criado em .github/workflows/ci-cd-static-pipeline.yml
  • aplicação criada como HTML na hospedagem
  • acesso manual por SSH testado
  • chave pública autorizada no servidor
  • secrets SERVER_HOST, SERVER_USER e SERVER_SSH_KEY criados no GitHub
  • arquivo ~/.env criado no servidor, apenas se o build precisar de variáveis
  • npm run build funcionando localmente
  • arquivo dist/index.html gerado localmente
  • branch principal confirmada, normalmente main

▸ Como o deploy funciona no dia a dia

Deploy automático

O deploy automático acontece sempre que você envia código para a branch main.

Use normalmente:

git add .
git commit -m "Atualizar projeto"
git push

As etapas de lint e test rodam com --if-present, então o workflow continua compatível com projetos que ainda não possuem esses scripts.

Deploy manual

Se quiser rodar o mesmo processo sem fazer um novo commit, use o disparo manual do workflow no GitHub:

GitHub > Actions > CI/CD Static Pipeline > Run workflow

Isso executa exatamente a mesma esteira do deploy automático, reaproveitando o commit atual da main.

▸ Comandos úteis de monitoramento

Se quiser inspecionar os arquivos publicados diretamente:

ls -la ~/*.configr.cloud/www

Para validar a publicação estática no servidor:

curl https://<projeto.seudominio.com.br>

▸ Como saber que deu certo

Use os comandos da etapa anterior e confira estes resultados:

  • ls -la ~/*.configr.cloud/www: mostra o novo index.html e os arquivos publicados
  • curl https://<projeto.seudominio.com.br>: retorna o HTML do site sem erro
  • GitHub Actions: o workflow terminou verde
  • navegador: o domínio abre; se ainda mostrar conteúdo antigo, limpe o cache ou aguarde o cache/CDN da hospedagem

▸ Erros comuns

Use a mensagem do log do GitHub Actions como ponto de partida:

  • SERVER_SSH_KEY is empty or was not configured in GitHub Secrets.: o secret SERVER_SSH_KEY não foi criado ou está vazio
  • Could not add SERVER_HOST to known_hosts.: SERVER_HOST está vazio, incorreto ou inacessível pelo runner
  • Could not connect to the server using SSH.: revise SERVER_HOST, SERVER_USER, SERVER_SSH_KEY e a chave pública autorizada no servidor
  • Load key ... error in libcrypto: chave privada colada de forma incompleta ou com estrutura alterada no secret SERVER_SSH_KEY
  • Permission denied (publickey,password): chave pública não autorizada no servidor ou SERVER_USER/SERVER_HOST incorreto
  • Remote ~/.env was not found. Continuing static build without server environment variables.: comportamento normal quando o build estático não precisa de variáveis remotas
  • Static build failed.: npm run build falhou; confira scripts, dependências e variáveis exigidas pelo build
  • Build output directory 'dist' was not found.: o build não gerou a pasta dist
  • Static build did not generate dist/index.html.: o projeto não gerou uma saída estática compatível
  • Could not upload dist.tar.gz to the server.: falha de SSH, permissão ou espaço em disco no servidor
  • Could not find a target *.configr.cloud/www directory: aplicação HTML não foi criada corretamente ou a pasta www não existe
  • Could not clean the target www directory before publishing.: o usuário não tem permissão para limpar a pasta de publicação
  • Published site validation failed: index.html was not found: a extração falhou ou o pacote enviado não contém index.html
  • Previous static files were restored after deployment failure.: o workflow restaurou automaticamente a cópia temporária anterior de www
  • domínio não atualizou: cache do navegador, cache da hospedagem ou arquivos antigos fora da pasta www