Resumo

Esta pipeline automatiza a publicação da aplicação com Docker: o GitHub valida o projeto, monta a imagem e envia o pacote pronto para o servidor. No ambiente de produção, o container é recriado com base nas variáveis centralizadas em ~/.env, o que ajuda a deixar o deploy mais previsível e padronizado. Assim, o servidor precisa basicamente de acesso SSH e Docker funcionando corretamente.

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
  • logs e monitoramento ficam concentrados no container
  • a manutenção do ambiente fica mais enxuta no servidor

Use este pipeline se a hospedagem confirmar que Docker está instalado, ativo e liberado para o usuário SSH da aplicação. Ele é uma opção mais avançada: o Docker mantém o container vivo com --restart always, então este fluxo não usa 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
  • porta da aplicação definida
  • variáveis de ambiente da aplicação
  • branch principal confirmada, normalmente main
  • confirmação da hospedagem de que Docker está funcional para o usuário da aplicação

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
> GitHub Actions faz o build da imagem Docker
> runner lê o ~/.env do servidor para obter variáveis de build
> runner salva a imagem como .tar
> runner envia a imagem por SCP
> servidor valida o ambiente remoto e o acesso ao Docker
> servidor faz docker load da nova imagem
> servidor recria o container com limites de CPU, memória e PIDs, usando --network host e --restart always
> servidor valida a saúde da aplicação
> servidor remove imagens antigas do mesmo app após o health check
> servidor remove o arquivo .tar enviado ao fim da execução

Workflow do GitHub Actions

Dockerfile

No projeto local:

nano Dockerfile

Cole o conteúdo abaixo:

#syntax=docker/dockerfile:1.7

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM deps AS build
WORKDIR /app
COPY . .
RUN --mount=type=secret,id=app_env \
    sh -ac 'set -a && . /run/secrets/app_env && set +a && npm run build'

FROM node:22-alpine AS production
WORKDIR /app

ENV NODE_ENV=production \
    HOST=0.0.0.0

COPY package*.json ./
RUN npm ci --omit=dev

COPY --from=build /app/dist ./dist
CMD ["node", "dist/server/entry.mjs"]

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

O CMD deve apontar para o comando real que inicia a aplicação dentro do container. Se o projeto gerar outro arquivo final, ajuste essa linha antes de seguir.

Exemplo para Next.js standalone:

COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
CMD ["node", "server.js"]

.dockerignore

Na raiz do projeto, crie também o arquivo .dockerignore.

nano .dockerignore

Cole o conteúdo abaixo:

.git
.github
dist
node_modules
.env
.env.*
npm-debug.log*

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

Teste local do Dockerfile

Antes de publicar pelo GitHub Actions, valide localmente se a imagem sobe e responde na porta esperada:

docker build -t minha-aplicacao .
docker run --rm --env-file .env -p 3000:3000 minha-aplicacao

Em outro terminal:

curl http://127.0.0.1:3000/

Troque 3000 pela porta usada pela aplicação. Se esse teste falhar, corrija o Dockerfile****, o CMD ou as variáveis antes do primeiro deploy.

Arquivo do workflow

No projeto local:

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

Cole o conteúdo abaixo:

name: CI/CD Docker 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 Container
    needs: ci
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      APP_NAME: ${{ github.event.repository.name }}
      IMAGE_TAG: ${{ github.sha }}
      REMOTE_TAR_DIR: docker-deploy

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

      - 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: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Build Docker image
        run: |
          if ! docker buildx build \
            --platform linux/amd64 \
            --load \
            --secret id=app_env,src=.server.env \
            --tag "${APP_NAME}:${IMAGE_TAG}" \
            .; then
            echo "Docker image build failed. Check the Dockerfile, build script, and build-time variables."
            exit 1
          fi

      - name: Save Docker image as TAR archive
        run: |
          if ! docker save "${APP_NAME}:${IMAGE_TAG}" -o "${APP_NAME}-${IMAGE_TAG}.tar"; then
            echo "Could not save the Docker image as a TAR archive."
            exit 1
          fi

      - name: Upload image to server via SCP
        run: |
          if ! ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "mkdir -p ~/${REMOTE_TAR_DIR}"; then
            echo "Could not create the remote Docker deploy directory."
            exit 1
          fi

          if ! scp -i ~/.ssh/server_key "${APP_NAME}-${IMAGE_TAG}.tar" "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:~/${REMOTE_TAR_DIR}/${APP_NAME}-${IMAGE_TAG}.tar"; then
            echo "Could not upload the Docker image TAR archive to the server. Check SSH credentials and available disk space."
            exit 1
          fi

      - name: Validate remote Docker runtime
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            ENV_PATH=\"\$HOME/.env\"

            if ! command -v docker >/dev/null 2>&1; then
              echo 'Docker is not installed on the server.'
              echo 'Ask your hosting provider to install and enable Docker before running this deploy.'
              exit 1
            fi

            if ! docker info >/dev/null 2>&1; then
              echo 'The SSH user cannot access Docker.'
              echo 'Ensure Docker is running and that this user can execute Docker commands without an interactive prompt.'
              exit 1
            fi

            if [ ! -f \"\$ENV_PATH\" ]; then
              echo 'Remote ~/.env was not found during Docker runtime validation.'
              exit 1
            fi

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

            if [ -z \"\${PORT:-}\" ]; then
              echo 'PORT is not defined in ~/.env.'
              echo 'Set an explicit application port before running this deploy.'
              exit 1
            fi
          "

      - name: Load Docker image on server
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            APP_NAME='${APP_NAME}'
            IMAGE_TAG='${IMAGE_TAG}'
            REMOTE_TAR_DIR='${REMOTE_TAR_DIR}'
            TAR_PATH=\"\$HOME/\$REMOTE_TAR_DIR/\$APP_NAME-\$IMAGE_TAG.tar\"

            if [ ! -f \"\$TAR_PATH\" ]; then
              echo 'Docker image TAR archive was not found on the server.'
              exit 1
            fi

            if ! docker load -i \"\$TAR_PATH\"; then
              echo 'Docker failed to load the uploaded image archive.'
              exit 1
            fi
          "

      - name: Start container on remote server
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            APP_NAME='${APP_NAME}'
            IMAGE_TAG='${IMAGE_TAG}'
            REMOTE_TAR_DIR='${REMOTE_TAR_DIR}'
            TAR_PATH=\"\$HOME/\$REMOTE_TAR_DIR/\$APP_NAME-\$IMAGE_TAG.tar\"
            ENV_PATH=\"\$HOME/.env\"

            docker rm -f \"\$APP_NAME\" >/dev/null 2>&1 || true
            if ! docker run -d \
              --name \"\$APP_NAME\" \
              --restart always \
              --env-file \"\$ENV_PATH\" \
              --cpus=\"0.50\" \
              --memory=\"512m\" \
              --memory-swap=\"512m\" \
              --pids-limit=100 \
              --network host \
              \"\$APP_NAME:\$IMAGE_TAG\"; then
              echo 'Docker failed to start the application container.'
              exit 1
            fi

            rm -f \"\$TAR_PATH\"
          "

      - name: Verify application health
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            APP_NAME='${APP_NAME}'
            ENV_PATH=\"\$HOME/.env\"

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

            docker ps --filter name=\"\$APP_NAME\"
            docker inspect --format='started={{.State.StartedAt}} status={{.State.Status}} restart={{.RestartCount}}' \"\$APP_NAME\"

            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 the application to respond... attempt \$i/\$ATTEMPTS\"
              sleep \$SLEEP_SECONDS
            done

            if [ -z \"\$SUCCESS\" ]; then
              echo 'Application health check failed after all attempts.'
              docker logs --tail 200 \"\$APP_NAME\"
              exit 1
            fi
          "

      - name: Remove old application images
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            set -e
            APP_NAME='${APP_NAME}'
            IMAGE_TAG='${IMAGE_TAG}'
            CURRENT_IMAGE=\"\$APP_NAME:\$IMAGE_TAG\"
            OLD_IMAGE_IDS=\"\$(docker images \"\$APP_NAME\" --format '{{.Repository}}:{{.Tag}} {{.ID}}' | awk -v current=\"\$CURRENT_IMAGE\" '\$1 != current { print \$2 }' | sort -u)\"

            if [ -n \"\$OLD_IMAGE_IDS\" ]; then
              echo \"Removing old images for \$APP_NAME...\"
              echo \"\$OLD_IMAGE_IDS\" | xargs docker rmi >/dev/null 2>&1 || true
            fi

            docker image prune -f >/dev/null 2>&1 || true
          "

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

          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            APP_NAME='${APP_NAME}'
            IMAGE_TAG='${IMAGE_TAG}'
            REMOTE_TAR_DIR='${REMOTE_TAR_DIR}'
            TAR_PATH=\"\$HOME/\$REMOTE_TAR_DIR/\$APP_NAME-\$IMAGE_TAG.tar\"

            rm -f -- \"\$TAR_PATH\"
          "

      - name: Show running container
        run: |
          ssh -i ~/.ssh/server_key "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "
            docker ps --filter name='${APP_NAME}'
          "

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

Depois:

git add Dockerfile .dockerignore .github/workflows/ci-cd-docker-pipeline.yml
git commit -m "Adiciona fluxo de CI/CD com Docker e GitHub Actions"
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 validar o ambiente, criar o ~/.env, conferir o Docker 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>

Confirme também com a hospedagem se o usuário da aplicação consegue rodar Docker sem prompt interativo. Este pipeline depende disso para carregar imagens e recriar containers.

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

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 imagem, caso a aplicação precise de variáveis já durante o processo de build
  • no runtime do container, via -env-file ~/.env

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.

PORT deve ser sempre definida manualmente nesse arquivo para evitar conflito com outras aplicações na mesma instância.

Sempre que você alterar o ~/.env do servidor, basta disparar um novo deploy para rebuildar a imagem e recriar o container.

Escolha uma porta livre e fixa para cada aplicação. A aplicação dentro do container também precisa escutar em PORT, porque o health check e o proxy reverso dependem desse valor.

4. Enviar a mensagem para o suporte da Cloud

Depois de concluir a configuração, envie esta mensagem ao suporte:

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

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?

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-docker-pipeline.yml
  • Dockerfile criado no projeto
  • .dockerignore criado no projeto
  • aplicação criada como Node.js na hospedagem
  • acesso manual por SSH testado
  • Docker confirmado e liberado para o usuário da aplicação
  • 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
  • teste local com docker build e docker run funcionando
  • 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 Docker Pipeline > Run workflow

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

▸ Comandos úteis de monitoramento

O nome do container é sempre o nome exato do repositório no GitHub.

docker ps
docker logs -f <nome-do-repositorio>
docker stats <nome-do-repositorio>
docker restart <nome-do-repositorio>
curl http://127.0.0.1:<porta-da-aplicacao>/

Se quiser inspecionar o ambiente dentro do container:

docker exec -it <nome-do-repositorio> sh

▸ Como saber que deu certo

Use os comandos da etapa anterior e confira estes resultados:

  • docker ps: mostra o container em execução
  • docker logs -f <nome-do-repositorio>: não mostra erro de inicialização ou restart contínuo
  • docker stats <nome-do-repositorio>: mostra uso de CPU e memória dentro dos limites definidos
  • curl http://127.0.0.1:<porta-da-aplicacao>/: responde sem erro no servidor
  • 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
  • Docker image build failed.: Dockerfile, dependências, script de build ou variáveis de build precisam ser corrigidos
  • Could not upload the Docker image TAR archive to the server.: falha de SSH, permissão ou espaço em disco no servidor
  • Docker is not installed on the server.: Docker não está disponível na hospedagem
  • The SSH user cannot access Docker.: usuário não tem permissão para executar Docker sem prompt interativo
  • PORT is not defined in ~/.env.: faltou PORT no ~/.env do servidor
  • Docker image TAR archive was not found on the server.: upload não foi concluído ou o caminho remoto está incorreto
  • Docker failed to load the uploaded image archive.: arquivo .tar corrompido, incompleto ou sem permissão de leitura
  • Docker failed to start the application container.: porta em uso, imagem inválida ou erro no comando de inicialização
  • Application health check failed after all attempts.: container subiu, mas a aplicação não respondeu em http://127.0.0.1:<porta>/