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 pushouRun 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 SSHestá 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 privadagithub_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_HOSTValor:<ip-ou-host-do-servidor>SERVER_USERValor:<usuario>SERVER_SSH_KEYValor: cole a chave privada completa do arquivogithub_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 Dockerfilecriado no projeto.dockerignorecriado no projeto- aplicação criada como
Node.jsna hospedagem - acesso manual por
SSHtestado - Docker confirmado e liberado para o usuário da aplicação
- chave pública autorizada no servidor
- secrets
SERVER_HOST,SERVER_USEReSERVER_SSH_KEYcriados no GitHub - arquivo
~/.envcriado no servidor - variável
PORTdefinida no~/.env - teste local com
docker buildedocker runfuncionando - 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çãodocker logs -f <nome-do-repositorio>: não mostra erro de inicialização ou restart contínuodocker stats <nome-do-repositorio>: mostra uso de CPU e memória dentro dos limites definidoscurl 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 secretSERVER_SSH_KEYnão foi criado ou está vazioCould not add SERVER_HOST to known_hosts.:SERVER_HOSTestá vazio, incorreto ou inacessível pelo runnerCould not connect to the server using SSH.: reviseSERVER_HOST,SERVER_USER,SERVER_SSH_KEYe a chave pública autorizada no servidorLoad key ... error in libcrypto: chave privada colada de forma incompleta ou com estrutura alterada no secretSERVER_SSH_KEYPermission denied (publickey,password): chave pública não autorizada no servidor ouSERVER_USER/SERVER_HOSTincorretoRemote ~/.env was not found.: o arquivo~/.envainda não foi criado no servidorRemote ~/.env is empty.: o arquivo existe, mas não tem variáveis salvasDocker image build failed.:Dockerfile, dependências, script de build ou variáveis de build precisam ser corrigidosCould not upload the Docker image TAR archive to the server.: falha de SSH, permissão ou espaço em disco no servidorDocker is not installed on the server.: Docker não está disponível na hospedagemThe SSH user cannot access Docker.: usuário não tem permissão para executar Docker sem prompt interativoPORT is not defined in ~/.env.: faltouPORTno~/.envdo servidorDocker image TAR archive was not found on the server.: upload não foi concluído ou o caminho remoto está incorretoDocker failed to load the uploaded image archive.: arquivo.tarcorrompido, incompleto ou sem permissão de leituraDocker failed to start the application container.: porta em uso, imagem inválida ou erro no comando de inicializaçãoApplication health check failed after all attempts.: container subiu, mas a aplicação não respondeu emhttp://127.0.0.1:<porta>/