Resumo

Esta pipeline automatiza a publicação da aplicação de forma simples: o GitHub valida o projeto, gera o pacote de produção e envia tudo pronto para o servidor. No ambiente de produção, a aplicação é publicada diretamente na pasta www e atualizada pelo PM2 dentro da estrutura padrão da cloud, usando o script start:production definido no próprio projeto. O processo cria um backup temporário de www durante a troca, limpa arquivos temporários e mantém o servidor sem histórico permanente de releases acumuladas.

Vantagens:

  • o deploy fica previsível e reproduzível
  • o build roda fora do servidor, poupando recursos da hospedagem
  • o ambiente segue o padrão nativo da cloud, sem alterar a estrutura interna
  • a publicação da aplicação fica simples no dia a dia com git push ou Run workflow
  • o servidor fica mais enxuto, sem manter releases antigas como backup permanente
  • se a validação falhar, o workflow tenta restaurar automaticamente a cópia temporária anterior de www

Use este pipeline se sua aplicação precisa ficar rodando no servidor, como projetos Node.js, Astro SSR, Next.js standalone, Express, Fastify, NestJS ou APIs HTTP. Se o projeto só gera arquivos HTML, CSS e JS, use o pipeline estático. Se a hospedagem confirmar Docker disponível e liberado para o usuário da aplicação, o pipeline Docker também pode ser usado.

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
  • porta da aplicação definida
  • variáveis de ambiente da aplicação
  • branch principal confirmada, normalmente main

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 valida e busca o ~/.env do servidor para obter variáveis de build
> runner executa o build da aplicação
> runner valida o script start:production
> runner compacta o pacote de produção do projeto
> runner envia o pacote por SCP
> servidor resolve o diretório *.configr.cloud da aplicação
> servidor executa source <app-root>/activate
> servidor extrai o pacote em um diretório temporário
> servidor instala dependências de runtime no diretório temporário
> servidor cria uma cópia temporária da pasta www atual
> servidor publica o conteúdo final na pasta www
> servidor limpa o estado antigo do PM2 daquele usuário e recria a aplicação em www com npm run start:production
> servidor valida a saúde da aplicação
> se a validação falhar, servidor restaura a cópia temporária anterior de www
> servidor remove o diretório temporário dist e o backup temporário
> o arquivo compactado enviado é removido ao fim da execução
> o servidor remove artefatos antigos de releases, se existirem
> o servidor mostra o status final do PM2

Workflow do GitHub Actions

No projeto local:

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

Cole o conteúdo abaixo:

name: CI/CD Pipeline

on:
  push:
    branches:
      - main
  workflow_dispatch:

concurrency:
  group: deploy-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 Application
    needs: ci
    runs-on: ubuntu-latest
    env:
      APP_NAME: ${{ github.event.repository.name }}
      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: Validate remote env file
        run: |
          if ! ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "test -f ~/.env"; then
            echo "Remote ~/.env was not found. Create it on the server before running this pipeline."
            exit 1
          fi

      - name: Fetch build environment from server
        run: |
          if ! ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "cat ~/.env" > .server.env; then
            echo "Could not read remote ~/.env from the server."
            exit 1
          fi

          if [ ! -s .server.env ]; then
            echo "Remote ~/.env is empty. Add the production variables before deploying."
            exit 1
          fi

      - name: Build application
        run: |
          set -a
          . ./.server.env
          set +a
          if ! npm run build; then
            echo "Application build failed. Check the build script and required build-time variables."
            exit 1
          fi

      - name: Validate production scripts
        run: |
          if ! node -e "const fs = require('node:fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); if (!pkg.scripts || !pkg.scripts['start:production']) { throw new Error('Missing required script: start:production'); }"; then
            echo "package.json must define scripts.start:production."
            exit 1
          fi

      - name: Create deployment archive
        run: |
          ARCHIVE_PATH="$(mktemp /tmp/dist.XXXXXX.tar.gz)"
          tar \
            --exclude=.git \
            --exclude=.github \
            --exclude=node_modules \
            --exclude=.server.env \
            --exclude=.env \
            --exclude=.env.* \
            -czf "$ARCHIVE_PATH" .
          mv "$ARCHIVE_PATH" dist.tar.gz

      - name: Upload deployment 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 application root on server
        id: resolve_app_root
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            APP_ROOT=\"\$(find \"\$HOME\" -maxdepth 1 \\( -type l -o -type d \\) -name '*.configr.cloud' 2>/dev/null | sort | head -n 1)\"

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

            ACTIVATE_PATH=\"\$APP_ROOT/activate\"

            if [ ! -f \"\$ACTIVATE_PATH\" ]; then
              echo 'Could not find the activate script for the application.'
              echo 'Check whether the hosting Node.js application was created correctly.'
              exit 1
            fi

            printf '%s\n%s' \"\$APP_ROOT\" \"\$ACTIVATE_PATH\"
          " > .app-root-data

          test -s .app-root-data
          APP_ROOT="$(sed -n '1p' .app-root-data)"
          ACTIVATE_PATH="$(sed -n '2p' .app-root-data)"
          printf 'app_root=%s\n' "$APP_ROOT" >> "$GITHUB_OUTPUT"
          printf 'activate_path=%s\n' "$ACTIVATE_PATH" >> "$GITHUB_OUTPUT"
        shell: bash

      - name: Prepare deployment directories on server
        id: prepare_deployment
        run: |
          APP_ROOT="${{ steps.resolve_app_root.outputs.app_root }}"
          DIST_PATH="$APP_ROOT/dist-tmp"
          WWW_PATH="$APP_ROOT/www"
          BACKUP_PATH="$APP_ROOT/www-backup-tmp"

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            rm -rf -- \"$DIST_PATH\" \"$BACKUP_PATH\"
            mkdir -p \"$DIST_PATH\"
            mkdir -p \"$WWW_PATH\"
          "

          printf 'dist_path=%s\n' "$DIST_PATH" >> "$GITHUB_OUTPUT"
          printf 'www_path=%s\n' "$WWW_PATH" >> "$GITHUB_OUTPUT"
          printf 'backup_path=%s\n' "$BACKUP_PATH" >> "$GITHUB_OUTPUT"
        shell: bash

      - 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: Extract deployment archive on server
        run: |
          DIST_PATH="${{ steps.prepare_deployment.outputs.dist_path }}"

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            if ! tar -xzf \"${REMOTE_ARCHIVE_PATH}\" -C \"$DIST_PATH\"; then
              echo 'Could not extract the deployment archive on the server.'
              exit 1
            fi
            rm -f \"${REMOTE_ARCHIVE_PATH}\"
          "
        shell: bash

      - name: Activate environment and install runtime dependencies
        run: |
          DIST_PATH="${{ steps.prepare_deployment.outputs.dist_path }}"
          ACTIVATE_PATH="${{ steps.resolve_app_root.outputs.activate_path }}"

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            ENV_PATH=\"\$HOME/.env\"

            set +e
            . \"$ACTIVATE_PATH\"
            ACTIVATE_STATUS=\$?
            set -e
            if [ ! -f \"\$ENV_PATH\" ]; then
              echo 'Remote ~/.env was not found during runtime dependency installation.'
              exit 1
            fi

            set -a
            . \"\$ENV_PATH\"
            set +a

            if ! command -v node >/dev/null 2>&1; then
              echo \"Activate exit status: \$ACTIVATE_STATUS\"
              echo 'Node.js is not available after sourcing activate.'
              exit 1
            fi

            if ! command -v npm >/dev/null 2>&1; then
              echo \"Activate exit status: \$ACTIVATE_STATUS\"
              echo 'npm is not available after sourcing activate.'
              exit 1
            fi

            cd \"$DIST_PATH\"
            if ! npm ci --omit=dev; then
              echo 'Runtime dependency installation failed on the server.'
              exit 1
            fi
          "
        shell: bash

      - name: Publish to www and restart PM2
        run: |
          APP_ROOT="${{ steps.resolve_app_root.outputs.app_root }}"
          ACTIVATE_PATH="${{ steps.resolve_app_root.outputs.activate_path }}"
          DIST_PATH="${{ steps.prepare_deployment.outputs.dist_path }}"
          WWW_PATH="${{ steps.prepare_deployment.outputs.www_path }}"
          BACKUP_PATH="${{ steps.prepare_deployment.outputs.backup_path }}"

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            ENV_PATH=\"\$HOME/.env\"
            APP_NAME=\"${APP_NAME}\"

            set +e
            . \"$ACTIVATE_PATH\"
            ACTIVATE_STATUS=\$?
            set -e
            if [ ! -f \"\$ENV_PATH\" ]; then
              echo 'Remote ~/.env was not found before restarting PM2.'
              exit 1
            fi

            set -a
            . \"\$ENV_PATH\"
            set +a

            if [ -z \"\${PORT:-}\" ]; then
              echo 'PORT is not defined in ~/.env.'
              echo 'Set PORT in the server ~/.env before restarting the application.'
              exit 1
            fi

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

            if [ ! -d \"$DIST_PATH\" ]; then
              echo 'Temporary dist directory was not created correctly.'
              exit 1
            fi
            if [ ! -f \"$DIST_PATH/package.json\" ]; then
              echo 'package.json was not found in the extracted deployment.'
              exit 1
            fi
            PM2_BIN=\"\$(command -v pm2)\"
            NPM_BIN=\"\$(command -v npm)\"

            if [ -z \"\$PM2_BIN\" ]; then
              echo 'pm2 is not available after sourcing activate.'
              exit 1
            fi

            if [ -z \"\$NPM_BIN\" ]; then
              echo 'npm is not available after sourcing activate.'
              exit 1
            fi

            echo \"Using pm2: \$PM2_BIN\"
            echo \"Using npm: \$NPM_BIN\"
            echo \"Using start script: npm run start:production\"
            echo \"Publishing application to: $WWW_PATH\"

            rm -rf -- \"$BACKUP_PATH\"
            mkdir -p \"$BACKUP_PATH\"
            if [ -d \"$WWW_PATH\" ]; then
              cp -a \"$WWW_PATH/.\" \"$BACKUP_PATH/\" 2>/dev/null || true
            fi

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

            if ! cp -a \"$DIST_PATH/.\" \"$WWW_PATH/\"; then
              echo 'Could not copy the new deployment into www.'
              restore_previous
              exit 1
            fi

            pm2 delete \"$APP_NAME\" >/dev/null 2>&1 || true
            pm2 kill >/dev/null 2>&1 || true

            if ! pm2 start \"\$NPM_BIN\" --name \"$APP_NAME\" --cwd \"$WWW_PATH\" -- run start:production; then
              echo 'PM2 failed to start the application using npm run start:production.'
              restore_previous
              pm2 start \"\$NPM_BIN\" --name \"$APP_NAME\" --cwd \"$WWW_PATH\" -- run start:production >/dev/null 2>&1 || true
              pm2 save >/dev/null 2>&1 || true
              exit 1
            fi

            if ! pm2 save; then
              echo 'PM2 failed to save the current process list.'
              exit 1
            fi
          "
        shell: bash

      - name: Verify application health
        run: |
          ACTIVATE_PATH="${{ steps.resolve_app_root.outputs.activate_path }}"
          APP_ROOT="${{ steps.resolve_app_root.outputs.app_root }}"

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            ENV_PATH=\"\$HOME/.env\"
            APP_NAME=\"${APP_NAME}\"

            set +e
            . \"$ACTIVATE_PATH\"
            ACTIVATE_STATUS=\$?
            set -e
            if [ ! -f \"\$ENV_PATH\" ]; then
              echo 'Remote ~/.env was not found during health check.'
              exit 1
            fi

            set -a
            . \"\$ENV_PATH\"
            set +a

            ATTEMPTS=15
            SLEEP_SECONDS=2
            SUCCESS=''

            for i in \$(seq 1 \$ATTEMPTS); do
              if curl -fsS --connect-timeout 2 --max-time 5 \"http://127.0.0.1:\${PORT}/\" >/dev/null; then
                SUCCESS=1
                break
              fi

              echo \"Waiting for application response... attempt \$i/\$ATTEMPTS\"
              sleep \$SLEEP_SECONDS
            done

            if [ -z \"\$SUCCESS\" ]; then
              echo 'Application health check failed after all attempts.'
              pm2 logs \"$APP_NAME\" --lines 200 --nostream || true
              exit 1
            fi
          "
        shell: bash

      - name: Restore previous www on failure
        if: failure()
        run: |
          ACTIVATE_PATH="${{ steps.resolve_app_root.outputs.activate_path }}"
          WWW_PATH="${{ steps.prepare_deployment.outputs.www_path }}"
          BACKUP_PATH="${{ steps.prepare_deployment.outputs.backup_path }}"

          if [ -z "$WWW_PATH" ] || [ -z "$BACKUP_PATH" ] || [ ! -f ~/.ssh/server_key ]; then
            echo "WWW path, backup path, or SSH key is not available. Skipping rollback."
            exit 0
          fi

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

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

            set +e
            . \"$ACTIVATE_PATH\"
            set -e

            find \"$WWW_PATH\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
            cp -a \"$BACKUP_PATH/.\" \"$WWW_PATH/\" 2>/dev/null || true

            NPM_BIN=\"\$(command -v npm)\"
            if [ -n \"\$NPM_BIN\" ] && [ -f \"$WWW_PATH/package.json\" ]; then
              pm2 delete \"\$APP_NAME\" >/dev/null 2>&1 || true
              pm2 start \"\$NPM_BIN\" --name \"\$APP_NAME\" --cwd \"$WWW_PATH\" -- run start:production >/dev/null 2>&1 || true
              pm2 save >/dev/null 2>&1 || true
            fi

            echo 'Previous www backup was restored after deployment failure.'
          "
        shell: bash

      - name: Remove temporary dist directory
        if: always()
        run: |
          DIST_PATH="${{ steps.prepare_deployment.outputs.dist_path }}"

          if [ -z "$DIST_PATH" ] || [ ! -f ~/.ssh/server_key ]; then
            echo "Dist path or SSH key is not available. Skipping temporary directory cleanup."
            exit 0
          fi

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            rm -rf -- \"$DIST_PATH\"
          "
        shell: bash

      - name: Remove temporary www backup
        if: always()
        run: |
          BACKUP_PATH="${{ steps.prepare_deployment.outputs.backup_path }}"

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

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            rm -rf -- \"$BACKUP_PATH\"
          "
        shell: bash

      - name: Remove legacy release artifacts
        if: always()
        run: |
          APP_ROOT="${{ steps.resolve_app_root.outputs.app_root }}"

          if [ -z "$APP_ROOT" ] || [ ! -f ~/.ssh/server_key ]; then
            echo "Application root or SSH key is not available. Skipping legacy artifact cleanup."
            exit 0
          fi

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            rm -rf -- \"$APP_ROOT/releases\" \"$APP_ROOT/current\"
          "
        shell: bash

      - 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}\"
          "
        shell: bash

      - name: Show PM2 status
        run: |
          ACTIVATE_PATH="${{ steps.resolve_app_root.outputs.activate_path }}"
          APP_ROOT="${{ steps.resolve_app_root.outputs.app_root }}"

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set +e
            . \"$ACTIVATE_PATH\"
            ACTIVATE_STATUS=\$?
            set -e
            pm2 list
          "
        shell: bash

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

Depois:

git add .github/workflows/ci-cd-pipeline.yml
git commit -m "Adiciona fluxo de CI/CD nativo com PM2"
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 Node.js.

Antes de configurar as chaves do GitHub Actions, defina também uma senha de acesso manual ao servidor. Isso permite entrar por SSH para testar conexão, criar o ~/.env, validar o PM2 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>

1. Padronizar o script de produção no projeto

Antes do primeiro deploy, o projeto precisa expor um script start:production no package.json. Esse é o contrato que o pipeline usa para subir a aplicação no PM2.

O script deve ficar dentro da chave "scripts" do package.json. O exemplo abaixo é apenas um mapa visual para mostrar onde alterar:

{
  "scripts": {
    ...
    "build": "astro build",
    "start:production": "node dist/server/entry.mjs",
    ...
  }
}

No package.json real, adicione/altere apenas os comandos de build e start:production conforme o framework:

Astro SSR

"build": "astro build",
"start:production": "node dist/server/entry.mjs"

Next.js standalone

"build": "next build",
"start:production": "node .next/standalone/server.js"

NestJS

"build": "nest build",
"start:production": "node dist/main.js"

Express ou Fastify com TypeScript

"build": "tsc",
"start:production": "node dist/server.js"

Express ou Fastify sem build

"build": "echo \"No build step\"",
"start:production": "node server.js"

O importante é manter os nomes build e start:production, mesmo que os comandos internos mudem de framework para framework.

Na sua máquina local, dentro da pasta do projeto, faça um teste mínimo:

npm ci
npm run build
npm run start:production

O comando start:production precisa iniciar um servidor HTTP escutando em process.env.PORT. Se ele não subir localmente, ajuste o script antes de seguir para o deploy.

Sessions no Astro

Se o projeto utilizar sessions no Astro e não houver outro storage configurado, defina explicitamente o driver abaixo no astro.config:

session: {
  driver: "fs-lite",
  options: {
    base: "./.astro/sessions",
  },
},

Esse ajuste faz com que as sessões sejam salvas em uma pasta local da própria aplicação em execução. Isso é importante principalmente quando o build acontece fora do servidor, como no GitHub Actions, porque evita que o runtime tente usar caminhos absolutos do ambiente de build.

Use essa configuração quando o projeto tiver sessions ativas e não estiver utilizando outro storage, como Redis.

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

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

4. Criar o arquivo ~/.env no servidor

Esse arquivo é criado uma única vez e vira a fonte de verdade do ambiente de produção.

Conteúdo obrigatório: este pipeline exige ~/.env no servidor; a linha PORT=<porta-da-aplicacao> é obrigatória.

Ele é usado em dois momentos:

  • no build da aplicação, caso a aplicação precise de variáveis já durante o processo de build
  • no runtime da aplicação, quando o workflow carrega o ~/.env no shell remoto antes de iniciar o processo no PM2

No servidor:

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

Cole a base abaixo e ajuste os valores do seu projeto. Se a aplicação exigir mais variáveis, adicione abaixo no mesmo arquivo.

NODE_ENV=production
HOST=0.0.0.0
PORT=<porta-da-aplicacao>

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

Sempre que você alterar o ~/.env do servidor, basta disparar um novo deploy para rebuildar o pacote de produção e reiniciar a aplicação com as novas variáveis.

Escolha uma porta livre e fixa para cada aplicação. Em hospedagem compartilhada, evite reutilizar a mesma porta em mais de um projeto. O domínio só passa a abrir publicamente depois que o proxy reverso da hospedagem apontar para essa porta.

5. Ativar o PM2 no boot

Depois do primeiro deploy bem-sucedido, faça essa ativação uma única vez para que a aplicação volte sozinha após reinicializações do servidor.

No servidor:

source ~/*.configr.cloud/activate
cd ~/*.configr.cloud/www
pm2 startup

Depois disso, o pm2 startup vai imprimir um novo comando com privilégios elevados.

  • copie esse comando gerado
  • envie esse comando ao suporte usando o modelo da etapa seguinte
  • depois que o suporte confirmar a execução, volte ao servidor e rode:
pm2 save

Esse pm2 save grava o estado atual para que o PM2 saiba qual processo deve restaurar automaticamente no boot.

6. Enviar a mensagem para o suporte da Cloud

Depois de concluir a configuração e obter o comando gerado pelo pm2 startup, envie esta mensagem ao suporte:

Título do ticket: Configuração de Proxy Reverso HTTPS e PM2 Startup

Olá, tudo bem?

Poderiam configurar o proxy reverso do domínio <projeto.seudominio.com.br> apontando para a porta <porta-da-aplicacao>, com HTTPS ativo?

Além disso, poderiam executar o comando abaixo com sudo, gerado pelo `pm2 startup`, para registrar a inicialização automática do PM2 do usuário da aplicação?

<comando-gerado-pelo-pm2-startup>

Depois disso, eu mesmo executarei o `pm2 save` para salvar o estado atual da aplicação.

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-pipeline.yml
  • aplicação criada como Node.js 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
  • variável PORT definida no ~/.env
  • script start:production criado no package.json
  • npm run build funcionando 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 Pipeline > Run workflow

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

▸ Comandos úteis de monitoramento

Para validar a aplicação no servidor:

source ~/*.configr.cloud/activate
cd ~/*.configr.cloud/www
pm2 list
pm2 logs <nome-da-aplicacao>
pm2 monit
curl http://127.0.0.1:<porta-da-aplicacao>/

Se quiser inspecionar os arquivos publicados:

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

▸ Como saber que deu certo

Use os comandos da etapa anterior e confira estes resultados:

  • pm2 list: a aplicação aparece com status online
  • pm2 logs <nome-da-aplicacao>: não mostra erro de inicialização ou loop de restart
  • curl http://127.0.0.1:<porta-da-aplicacao>/: responde sem erro no servidor
  • ls -la ~/*.configr.cloud/www: mostra os arquivos atuais da aplicação
  • GitHub Actions: o workflow terminou verde
  • navegador: o domínio abre depois do proxy reverso configurado

▸ 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.: o arquivo ~/.env ainda não foi criado no servidor
  • Remote ~/.env is empty.: o arquivo existe, mas não tem variáveis salvas
  • Application build failed.: npm run build falhou; confira dependências, scripts e variáveis usadas no build
  • package.json must define scripts.start:production.: faltou o script start:production no package.json
  • 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 *.configr.cloud application directory: a aplicação Node.js não foi criada corretamente na hospedagem
  • Could not find the activate script: a aplicação Node.js da hospedagem não possui o arquivo activate esperado
  • Deployment archive was not found on the server after upload.: o pacote não chegou ao servidor no caminho esperado
  • Could not extract the deployment archive on the server.: o pacote enviado está incompleto, corrompido ou sem permissão de leitura
  • Runtime dependency installation failed on the server.: npm ci --omit=dev falhou no servidor
  • PORT is not defined in ~/.env.: faltou PORT no ~/.env do servidor
  • Temporary dist directory was not created correctly.: o diretório temporário dist-tmp não pôde ser preparado no servidor
  • Previous www backup was restored after deployment failure.: o workflow restaurou automaticamente a cópia temporária anterior de www
  • pm2 is not available after sourcing activate.: o PM2 não está disponível no ambiente ativado da aplicação
  • PM2 failed to start the application using npm run start:production.: o script de produção falhou ou aponta para um arquivo inexistente
  • Application health check failed after all attempts.: a aplicação não respondeu em http://127.0.0.1:<porta>/; confira PORT, logs do PM2 e proxy reverso