Olá! Neste post iremos mostrar como criar um pipeline para uma aplicação simples usando o CI/CD do Gitlab, com publicação de pacotes NPM, imagens Docker e Helm Charts no registry de um projeto, e algumas estratégias de deploy. Esta atividade foi proposta como um desafio para o processo seletivo do SIGA UFRJ (Sistema Integrado de Gestão Acadêmica), no período de 2025/2. O desafio está descrito aqui: https://gitlab.com/equipesiga/processo-seletivo/infra-2025-pipeline.
De um modo geral, toda pipeline é construída de acordo com um fluxo de trabalho pré-determinado. Construiremos uma pipeline com o seguinte o fluxo de trabalho em mente, contendo algumas considerações a mais do que a especificada no desafio:

- Duas branches representam um estado mais ‘permanente’ da aplicação: a master/main e a develop.
- As branches mais dinâmicas, onde são implementadas novas funcionalidades, são chamadas de features branches. Tais mudanças são integradas primeiro no ramo ‘develop’, e só depois no ramo principal. Ambas integrações necessitam de validação humana, por meio da aprovação do merge request.
As especificações do nosso pipeline é a seguinte:
- Todo push executa testes unitarios e builda o projeto. Nas branchs que não sejam a master, a tarefa de build é considerada apenas um teste, ou seja, não cria um artefato persistente para a publicação de pacotes.
- Merge Requests, tanto na develop quanto na master, precisam passar pela pipeline antes de serem aprovados. (isto foi configurado nas configurações do projeto, em Settings -> Merge Request)
- Mudanças na branch ‘master’ devem ativar a publicação de diferentes pacotes e o deploy em uma VPS.
Ambiente de Desenvolvimento
Antes de tudo, é importante termos uma ferramenta para nos auxiliar desenvolvimento local do pipeline. Sem isto, precisaremos realizar diversos commits em nosso repositório remoto para testar nosso pipeline. O programa que instala ‘runners’ (computadores registrados como “trabalhadores” para rodarem os pipelines do seu projeto) tinha um comando “exec” que testava pipelines localmente, porém ele foi depreciado nas versões mais recentes.
A alternativa que encontrei foi usar esta ferramenta, o GitLab-CI-Local. Para o que faremos, parece suficiente.
O Pipeline
O diagrama abaixo apresenta uma visão geral do funcionamento da pipeline, com alguns detalhes e seus produtos finais, pacotes e estratégias de deploy. Vamos entendê-lo em mais detalhes adiante.

Algumas Pré-definições
Os jobs (etapas do pipeline) onde executaremos nossos scripts serão executados em contêineres Docker. A maioria deles precisam das dependências JavaScript do projeto instaladas, e este processo de inicialização pode demorar consideravelmente, se precisar se repetir diversas vezes.
Destas considerações, os seguintes parâmetros foram escolhidos como default para os jobs:
- Imagem docker: node alpine, por ser mais leve.
- Execução preliminar do npm install, em todos os jobs.
- Criação de regras para disparo dos jobs, no caso, em pushes ou merge requests.
- Para otimizar a instalação do projeto, podemos usar cache para guardar as depedências instaladas pelo npm entre os jobs. A reutilização desse cachê está condicionada a não mudança,na branch ativadora do job, das dependências listadas no ‘package.json’.
Estas configurações estão descritas nas chaves “image” e “default” no arquivo .gitlab-ci.yml.
# Pré definições:
# Imagem docker
# Instalação das dependências antes de todo job, e cacheamento das mesmas.
image: node:alpine
default:
before_script:
- echo "Instalando dependências"
- npm install
cache:
key:
files:
- package.json
paths:
- node_modules/Alguns outros estágios tem diferentes especificações para os trabalhos, como na criação e publicação de imagens Docker, Helm Charts e acesso SSH à VPS. Nestes jobs as suas configurações sobrescrevem o valor default listado acima.
Estágios
O pipeline foi construído em 3 estágios: test, publish, e deploy. Sendo um para testes, outro para a publicação de pacotes, e outro para as estrateǵias de deploy.
O estágio ‘test’ possui 3 jobs: unit-test, eslint, e build-test, que executam os testes unitários, análise estática de código (Linter para JS, com ESLint), e uma testagem da build.
O estágio ‘publish’ possui 3 jobs. O primeiro job faz a build do projeto e cria um artefato (a pasta com arquivos estáticos) para compartilhar com outros jobs da pipeline, disponibilizando o artefato por 1h. Os outros trabalhos dependentes da build publicam um pacote node, uma imagem Docker e um Helm Chart no registry do projeto.
No estágio ‘deploy’, temos diferentes estratégias de deploys. Veja a seção abaixo para mais detalhes.
A publicação dos pacotes NPM foi feita seguindo a seção pertinente da documentação do GitLab para isto, de forma bem direta e utilizando as variáveis pré-definidas para autenticação no arquivo .npmrc.
Arquitetando alguns deploys
Após cumprir as exigências mínimas do projeto, planejei algumas formas de servir continuamente a aplicação que pudessem me servir de aprendizado. Pensei em começar pelo o que me parece mais fácil e familiar, indo em direção ao que fosse mais difícil e que necessitasse de mais pesquisas da minha parte. Dividi então as estratégias de deploy da aplicação em 3 níveis de dificuldade:
- Usar a configuração já existente, que hospeda este blog.
- Criar uma imagem docker no registry do projeto, e servir a aplicação contêinerizada na VPS.
- Usar um orquestrador de containeres, como Kubernetes, para manter a aplicação atualizada na VPS.
Com as tarefas se avolumando, aqui eu precisei me organizar, colocando cada um desses objetivos como milestones do projeto, estimando sua realização só depois de eu cumprir pelo menos os requisitos mínimos. Também criei Issues com as tarefas/pesquisas necessárias para cada milestone, como por exemplo; configurar o acesso via SSH à VPS e o NGINX, cadastrar subdomínios, quais imagens utilizar, autenticação nos diferentes registries e etc.
Deploy 1: Usando configurações já existentes
Como já tive experiência configurando um VPS para hospedar este blog, pensei em alguma forma de aproveitar o ambiente existente para servir diretamente os arquivos estáticos. Para este milestone eu me dediquei a aprender a configurar o acesso SSH de um job à VPS, configurar o NGINX e abrir um subdomínio “siga” neste domínio já existente.
Configurando acesso SSH
Minha maior dificuldade nesta etapa foi lidar com as incertezas relativas à segurança. A idéia de permitir que um runner acesse e modifique diretamente o host dos seus serviços me parece muito arriscado, além da preocupação com o armazenamento de chaves. Lendo a documentação proposta pelo GitLab, fiquei imensamente surpreso que a solução recomendada deles é exatamente a mesma que eu pensei, e bem simples: guardar as chaves como variáveis protegidas dentro do projeto, e acessar a VPS diretamente pelo Job mesmo. O acesso às chaves fica restrito somente para integrantes do projeto, e o GitLab oferece proteção e mascaração de variáveis nos logs, ( embora isto só adicione uma fraca camada de obfuscação ). Para maior segurança, criei um usuário para o Job acessar a VPS que não seja um ‘sudoer‘.
Usei o próprio script fornecido na documentação pelo GitLab, mudando somente alguns detalhes; o uso de ‘File Variables’ não estava dando certo, e acabei configurando a chave como uma variável comum mesmo, encodando os espaços com base64.
Como resultado, criei uma seção do manifesto chamado “.access-vps-ssh”; um padrão que ser reutilizado por jobs que precisem do acesso à VPS. As variáveis criadas são VPS_SSH_KEY, VPS_HOSTNAME e VPS_USER.
## Configurações padrões de um job com acesso a VPS
.access-vps-ssh:
image: ubuntu
before_script:
## Instalar o ssh-agent.
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
## Executa o agente dentro do ambiente de deploy
- eval $(ssh-agent -s)
## Cadastro da chave privada
- echo "$VPS_SSH_KEY" | base64 -d | ssh-add -
## Cria o diretório ssh com as devidas permissões
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- ssh-keyscan $VPS_HOSTNAME > ~/.ssh/known_hostsConfigurando NGINX
Para a configuração do NGINX, um pequeno bloco de servidor com algumas diretivas resolveu o problema. Note que somente o acesso HTTP está configurado ( o servidor só escuta a porta 80 ); a configuração na porta HTTPS (443) eu vou deixar para o certbot.
Lembrando que este NGINX é o que está rodando diretamente no Host.
server {
server_name siga.afonsolpjr.blog.br;
# Local da aplicação e indicação do index
root /home/gitlab_deploy/todo-app;
index index.html;
access_log /home/gitlab_deploy/access.log;
error_log /home/gitlab_deploy/error.log;
listen 80;
listen [::]:80
}E com isto, é só configurarmos um Job para colocar os arquivos dentro da pasta pertinente, /home/gitlab_deploy/todo-app ! É justamente o que o job ‘deploy-lvl1’ faz, usando o artefato da build.
## Deploy lvl 1: simplesmente copiando arquivos estáticos para o servidor
deploy-lvl1:
extends: .access-vps-ssh
stage: deploy
environment: production
only:
- master
script:
- test -d dist
- scp -r dist/* ${VPS_USER}@${VPS_HOSTNAME}:/home/${VPS_USER}/todo-app
needs:
- job: build
artifacts: trueSe tudo deu certo, esta aplicação estará disponível em siga.afonsolpjr.blog.br 🙂
Edit: passado um tempo da avaliação, eu removi a aplicação do servidor, em todas suas versões. Depois eu vejo se dá para subir ela estaticamente dentro do WordPress.
Deploy 2: Usando uma imagem docker para a aplicação
Bem, alavancando o conhecimento fresco das configurações da VPS, podemos planejar a construção de uma imagem Docker para o projeto, que serve os arquivos estáticos. Ela pode ficar publicada no registry do projeto, igual ao pacote, e ser usada pela VPS.
Aqui está o arquivo Dockerfile, que utiliza o artefato buildado préviamente (a pasta /dist) e utiliza o serve para servi-lo.
FROM node:alpine
RUN npm install -g serve
COPY ./dist /dist
ENTRYPOINT ["serve", "-p", "31917", "-s", "/dist"]Configurando container para a build da imagem Docker
Executar comandos docker dentro de um container não foi uma tarefa trivial; é necessário que o container seja iniciado com privilégios, para acessar o daemon docker. Até temos como configurar isto no nosso runner host, ou então fazê-lo executar os jobs diretamente no ambiente shell da máquina host, ao invés de contêineres. Mas vamos tentar alternativas que não mexam com tantos privilégios nos nossos runners – que aliás, estão cadastrados na VPS. Uma delas é cadastrar um runner na VPS que dê acesso ao daemon docker do host, dando um bind nos sockets pertinentes – tudo isto está na própria documentação do GitLab.
Para autenticação no registry, podemos seguir as especificações encontradas na documentação do GitLab. E para a publicação do pacote, a própria página do registry de contêineres nos dá os comandos necessários, localizada no menu lateral do projeto -> Deploy -> Container Registry.
O job da build e publicação da imagem pode ser visto abaixo. Ele ‘taggeia’ a imagem como ‘latest’ e também com uma assinatura SHA do commit. São utilizados algumas variáveis pré-definidas do CI/CD e uma variável contendo o caminho da imagem dentro do registry.
## Publica imagem docker no registry do projeto. Necessita de um runner específico.
docker-img:
stage: publish
tags:
- socket-binding-docker-runner
image: docker:28.4.0
only:
- master
before_script:
# Evitando o before_script default.
- echo "Buildando imagem docker"
script:
# Autenticação com o registry
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- docker build -t "${APP_IMAGE_PATH}:${CI_COMMIT_SHORT_SHA}" -t "${APP_IMAGE_PATH}:latest" .
- docker push $APP_IMAGE_PATH:$CI_COMMIT_SHORT_SHA
needs:
- job: build
artifacts: trueCriando o deploy e configurando o NGINX
O job para o deploy na VPS é simples; só é preciso baixar a imagem e inicializar o container! Uma configuração do NGINX fará com que esta “segunda versão do app” fique disponível em siga.afonsolpjr.blog.br/v2 .
A aplicação do container é exposta em loopback, ou seja, auto-comunicação com o próprio host, sem precisar expor uma porta. Agora é só configurar a rota no NGINX para esta porta interna, usando a diretiva proxy_pass para a localidade /v2.
location /v2/ {
proxy_pass http://127.0.0.1:31917;
}Para esta estratégia de deploy, o Job exporta executa um script no host que inicializa o container, exportando algumas variáveis pertinentes antes.
## Deploy lvl 2: executando um container na VPS
deploy-lvl2:
extends: .access-vps-ssh
stage: deploy
environment: production
only:
- master
script:
- |
ssh ${VPS_USER}@${VPS_HOSTNAME} << EOF
# Com o comando abaixo, caso algum comando multilinha falhe, o comando ssh falha também.
set -e
# Exportar variáveis para o script shell remoto
export CI_DEPLOY_PASSWORD="${CI_DEPLOY_PASSWORD}"
export CI_DEPLOY_USER="${CI_DEPLOY_USER}"
export CI_REGISTRY_URL="registry.gitlab.com"
export CI_REGISTRY_IMAGE="$APP_IMAGE_PATH:$CI_COMMIT_SHORT_SHA"
export SERVE_APP_PORT="${SERVE_APP_PORT}"
# Executando o script de deploy
/home/${VPS_USER}/todo-app-docker-deploy.sh
EOF
needs:
- job: docker-imgDeploy 3: Usando Kubernetes
Note que no script da etapa anterior, há uma checagem para nos certificarmos de que só haja um container da aplicação em execução, que deve ser interrompido e removido, para então subirmos a nova versão da aplicação. Dependendo da escala da aplicação, este gerenciamento de contêineres pode ser complexo demais para o uso de scripts, e qualquer imprevisto pode levar a longos períodos de tempo onde a aplicação fica fora do ar. É nesse caso que orquestradores de contêineres, como o Kubernetes, podem nos ajudar.
O Kubernetes é um orquestrador de aplicações distribuídas em contêineres, que podem estar distribuídas em diversos nós (máquinas) agrupadas dentro de um Cluster. De um modo geral, ele automatiza várias tarefas associadas ao gerenciamento e deploy de contêineres, como gerenciar a escala da aplicação, fazer o balanceamento de cargas e definir estratégias de updates.
No nosso caso ele não é tão necessário – visto que só temos uma máquina -, mas ele poderia nos ajudar na estratégia de update da aplicação.
O Kubernetes funciona com arquivos que descrevem o estado desejável da aplicação. E há como ‘empacotar’ estas descrições de uma aplicação em Charts, atráves do Helm – o gerenciador de pacotes do Kubernetes. Daqui já podemos pensar em uma arquitetura básica: publicar um Helm Chart de nossa aplicação, e usá-lo para manter a aplicação atualizada dentro do nosso Cluster ( de uma máquina só 😛 ).
Na verdade, para fins didáticos, vou limitar bastante a configuração de um Cluster, usando minikube, que virtualiza um Cluster na VPS. A lógica do deploy será a seguinte:
- Nem todo commit publicará um Helm Chart. Ele só deve ser publicado se houverem mudanças nos templates da Chart, que refletem o desejo de mudar a arquitetura da aplicação no Cluster.
- Um novo commit na master, refletindo uma nova versão da aplicação, irá criar os pacotes e imagens pertinentes definidos na etapas anteriores.
- Após a criação e publicação de uma nova imagem docker no registry do projeto, a aplicação no Cluster será atualizada com o comando ‘helm upgrade –set image.tag=${CI_COMMIT_SHORT_SHA}’, sinalizando que a tag da imagem mudou e precisará ser baixada novamente.
O chart define a aplicação de uma forma simples, com 2 contêineres expondo um serviço “NodePort” ( não há exposição de portas porque o Minikube roda em uma VM ). A publicação de um Chart está bem descrita e documentada na documentação do GitLab, e o job construído é uma aplicação direta do que está lá, logo omitirei.
Já para o deploy, depois de muitas tentativas de como autenticar o Helm e o Docker no registry de charts e contêiner, a solução foi usar secrets definidos dinâmicamente em cada job. No final, ficou assim:
# Deploy via minikube
deploy-lvl3:
extends: .access-vps-ssh
stage: deploy
only:
- master
script:
- |
ssh ${VPS_USER}@${VPS_HOSTNAME} << EOF
set -e
# Criando autenticacao do kubernetes com o gitlab
kubectl create secret docker-registry gitlab-registry-secret \
--docker-server=https://registry.gitlab.com \
--docker-username=gitlab-ci-token \
--docker-password="$CI_JOB_TOKEN" --save-config --dry-run=client -o yaml | kubectl apply -f -
# Adicionando registry de charts
helm repo add --username gitlab-ci-token --password "$CI_JOB_TOKEN" "$CI_PROJECT_NAME" "https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/helm/develop" --force-update
# Fazendo upgrade, usando a última imagem disponível
helm upgrade --install todo-app-release "$CI_PROJECT_NAME"/todo-app --set image.tag="$CI_COMMIT_SHORT_SHA"
EOF
needs:
- docker-imgComentários sobre melhores práticas
Pela simplicidade da aplicação e pelo fim didático que eu dei a ele, este projeto não está em tanta conformidade com melhores práticas. Algumas observações quanto a isto podem ser feitas:
- A quantidade de testes é pequena. Geralmente há mais testes na integração das features-branches na develop ( testes de integração ).
- Não há separação entre o desenvolvimento da aplicação e produção. Pode ser interessante criar um repositório próprio para todas as configurações de deploy do projeto, o que não iria expor segredos de produção para os desenvolvedores. A VPS está significativamente exposta para os desenvolvedores neste caso.
- Ao invés de rodar comandos em um nó do cluster, é recomendada que a integração do GitLab com o Kubernetes seja feito usando agentes, que são registrados no GitLab ( https://docs.gitlab.com/user/clusters/agent/ ).
- Materiais de referência para segurança no deploy e no CI/CD:
Variáveis CI/CD
- APP_IMAGE_PATH: caminho da imagem docker da aplicação
- SERVE_APP_PORT: porta onde o serve serve a aplicação dentro do contêiner
- VPS_HOSTNAME: endereço da VPS
- VPS_SSH_KEY: chave SSH da VPS
- VPS_USER: usuario do deployment na VPS
Considerações sobre IA
Nenhuma sentença deste texto foi escrita por uma IA Generativa.
Durante o projeto, IAs Generativa foram utilizadas para troubleshooting.
2 respostas para “Criando um pipeline CI/CD no GitLab”
muito bom! mas os links tão quebrados kkkk
ex: http://siga.afonsolpjr.blog.br/v2
Eu deixei todas as mudanças que engatilham os deploys prontas no ramo develop, e sugeri à equipe de avaliação que completasse o merge request dela na master. Assim poderiam ver o pipeline em funcionamento e os links funcionando, um a um. Mas acho que não rolou