GitLab Self-Hosted: Soberania Total sobre Código e Pipelines

[326] GitLab Self-Hosted: Soberania Total sobre Código e Pipelines

Quando o SaaS não é opção — setores regulados, residência de dados, custo por usuário: a arquitetura de componentes do GitLab, a instalação com Docker Compose para ambientes menores e com Helm para produção, a estratégia de backup que precisa incluir os secrets, e o caminho de atualização entre versões.
DevOps

18 min de leitura

Há contextos em que usar um serviço SaaS para hospedar código e pipelines não é uma opção. Organizações financeiras reguladas, defesa, saúde com dados sensíveis de pacientes, governos e empresas com políticas rígidas de residência de dados frequentemente precisam que toda a cadeia de desenvolvimento de software opere dentro de sua própria infraestrutura. Nenhum código, nenhuma credencial, nenhum log de pipeline deve sair do ambiente controlado.

O GitLab foi desenhado desde o início para ser instalado em infraestrutura própria — não como um produto secundário, mas como o modelo principal de distribuição por anos. O GitLab.com (SaaS) só veio depois. Isso significa que a experiência de self-hosting é madura, bem documentada e com suporte de longo prazo.

Há também uma razão financeira. O GitLab.com cobra por usuário por mês nos planos pagos. Para organizações com centenas ou milhares de desenvolvedores, o custo de uma instância self-hosted com licença Enterprise Edition (ou mesmo a Community Edition gratuita) pode ser significativamente menor do que o SaaS a longo prazo.

Arquitetura do GitLab Self-Hosted

O GitLab é uma aplicação complexa composta de múltiplos componentes. Compreender a arquitetura é essencial antes de instalar, porque as decisões de infraestrutura dependem de quais componentes serão gerenciados internamente e quais serão delegados a serviços gerenciados.

Os componentes principais são: Puma (servidor web Rails que serve a interface e a API), Sidekiq (processamento de jobs em background — notificações, mirrors, CI triggers), GitLab Workhorse (proxy reverso leve que lida com uploads e operações Git pesadas), Gitaly (serviço gRPC que gerencia todos os acessos ao repositório Git — isolado por razões de segurança e performance), PostgreSQL (banco de dados principal), Redis (cache e filas do Sidekiq), e Object Storage (para artifacts, LFS, uploads e backups — S3, Azure Blob ou GCS).

Para instalações pequenas (até 500 usuários), todos esses componentes podem rodar em uma única VM. Para instalações médias e grandes, a arquitetura de referência do GitLab recomenda separar os componentes: banco de dados gerenciado (RDS ou equivalente), Redis gerenciado (ElastiCache), object storage separado, e múltiplos nós Puma/Sidekiq atrás de um load balancer.

Instalação com Docker Compose (Ambientes Menores)

Para ambientes de desenvolvimento, staging e instalações com até algumas dezenas de usuários, o Docker Compose oferece a forma mais rápida de ter um GitLab funcional:

# docker-compose.yml
version: '3.8'

services:
  gitlab:
    image: gitlab/gitlab-ee:latest  # EE para funcionalidades enterprise; CE para community
    container_name: gitlab
    restart: always
    hostname: 'gitlab.empresa.com'
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'https://gitlab.empresa.com'

        # PostgreSQL externo (recomendado para produção)
        gitlab_rails['db_adapter'] = 'postgresql'
        gitlab_rails['db_host'] = 'postgres'
        gitlab_rails['db_port'] = 5432
        gitlab_rails['db_database'] = 'gitlabhq_production'
        gitlab_rails['db_username'] = 'gitlab'
        gitlab_rails['db_password'] = ENV['DB_PASSWORD']

        # Redis externo
        gitlab_rails['redis_host'] = 'redis'
        gitlab_rails['redis_port'] = 6379

        # Object storage (S3 ou compatível — MinIO no self-hosted)
        gitlab_rails['object_store']['enabled'] = true
        gitlab_rails['object_store']['connection'] = {
          'provider' => 'AWS',
          'region' => 'us-east-1',
          'aws_access_key_id' => ENV['MINIO_ACCESS_KEY'],
          'aws_secret_access_key' => ENV['MINIO_SECRET_KEY'],
          'endpoint' => 'http://minio:9000',
          'path_style' => true
        }
        gitlab_rails['object_store']['objects']['artifacts']['bucket'] = 'gitlab-artifacts'
        gitlab_rails['object_store']['objects']['lfs']['bucket'] = 'gitlab-lfs'
        gitlab_rails['object_store']['objects']['uploads']['bucket'] = 'gitlab-uploads'
        gitlab_rails['object_store']['objects']['packages']['bucket'] = 'gitlab-packages'

        # SSL — Let's Encrypt automático (precisa de porta 80 pública)
        # letsencrypt['enable'] = true
        # Para certificado próprio:
        nginx['ssl_certificate'] = '/etc/gitlab/ssl/gitlab.empresa.com.crt'
        nginx['ssl_certificate_key'] = '/etc/gitlab/ssl/gitlab.empresa.com.key'

        # SMTP para notificações
        gitlab_rails['smtp_enable'] = true
        gitlab_rails['smtp_address'] = 'smtp.empresa.com'
        gitlab_rails['smtp_port'] = 587
        gitlab_rails['smtp_user_name'] = 'gitlab@empresa.com'
        gitlab_rails['smtp_password'] = ENV['SMTP_PASSWORD']
        gitlab_rails['smtp_domain'] = 'empresa.com'
        gitlab_rails['smtp_authentication'] = 'login'
        gitlab_rails['smtp_enable_starttls_auto'] = true

        # Integração com Active Directory / LDAP (muito comum em enterprise)
        gitlab_rails['ldap_enabled'] = true
        gitlab_rails['ldap_servers'] = YAML.load <<-EOS
          main:
            label: 'Active Directory'
            host: 'ad.empresa.com'
            port: 636
            uid: 'sAMAccountName'
            encryption: 'simple_tls'
            bind_dn: 'CN=gitlab-svc,OU=ServiceAccounts,DC=empresa,DC=com'
            password: '#{ENV["LDAP_PASSWORD"]}'
            base: 'OU=Users,DC=empresa,DC=com'
            group_base: 'OU=Groups,DC=empresa,DC=com'
        EOS

        # Limites de performance
        puma['worker_processes'] = 4
        sidekiq['concurrency'] = 20

    ports:
      - '80:80'
      - '443:443'
      - '22:22'  # SSH para Git
    volumes:
      - gitlab-config:/etc/gitlab
      - gitlab-logs:/var/log/gitlab
      - gitlab-data:/var/opt/gitlab
      - ./ssl:/etc/gitlab/ssl:ro
    env_file:
      - .env

  postgres:
    image: postgres:16-alpine
    restart: always
    environment:
      POSTGRES_DB: gitlabhq_production
      POSTGRES_USER: gitlab
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    restart: always
    volumes:
      - redis-data:/data

  minio:
    image: minio/minio:latest
    restart: always
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
      MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
    volumes:
      - minio-data:/data
    ports:
      - '9001:9001'  # Console MinIO (apenas interno)

volumes:
  gitlab-config:
  gitlab-logs:
  gitlab-data:
  postgres-data:
  redis-data:
  minio-data:

Instalação no Kubernetes com Helm (Produção)

Para instalações de produção com alta disponibilidade, o GitLab oferece um chart Helm oficial que distribui os componentes em pods separados, com escalonamento independente e alta disponibilidade:

# values.yml para o GitLab Helm Chart
# helm repo add gitlab https://charts.gitlab.io/
# helm install gitlab gitlab/gitlab -f values.yml -n gitlab --create-namespace

global:
  hosts:
    domain: empresa.com
    # gitlab.empresa.com, registry.empresa.com, minio.empresa.com

  ingress:
    configureCertmanager: true
    class: nginx

  # PostgreSQL externo — usar RDS ou Azure Database em produção
  psql:
    host: rds-gitlab.xxxxxx.sa-east-1.rds.amazonaws.com
    port: 5432
    database: gitlabhq_production
    username: gitlab
    password:
      secret: gitlab-postgres-secret
      key: password

  # Redis externo
  redis:
    host: gitlab-redis.xxxxxx.cache.amazonaws.com
    auth:
      secret: gitlab-redis-secret
      key: password

  # Object storage — S3
  minio:
    enabled: false  # Usar S3 real em produção
  appConfig:
    object_store:
      enabled: true
      proxy_download: true
      connection:
        secret: gitlab-s3-secret
        key: connection

  # LDAP / Active Directory
  appConfig:
    ldap:
      enabled: true
      servers:
        main:
          label: 'Active Directory'
          host: 'ad.empresa.com'
          port: 636
          uid: 'sAMAccountName'
          encryption: 'simple_tls'
          bind_dn: 'CN=gitlab-svc,OU=ServiceAccounts,DC=empresa,DC=com'
          base: 'OU=Users,DC=empresa,DC=com'

# Componentes gerenciados pelo chart
gitlab:
  webservice:
    replicaCount: 3
    resources:
      requests:
        cpu: "500m"
        memory: "1.5Gi"
      limits:
        cpu: "2"
        memory: "3Gi"

  sidekiq:
    replicaCount: 2
    resources:
      requests:
        cpu: "250m"
        memory: "1Gi"

  gitaly:
    persistence:
      size: 500Gi
      storageClass: gp3

# Nginx Ingress Controller
nginx-ingress:
  enabled: true

# Cert-Manager para SSL automático
certmanager-issuer:
  email: ops@empresa.com

Backups e Recuperação

O backup do GitLab self-hosted é uma responsabilidade que não existe no SaaS — e é onde muitas instalações falham. O GitLab fornece um rake task oficial para backups que cobre todos os dados da aplicação:

#!/bin/bash
# scripts/backup-gitlab.sh
# Executado diariamente via cron ou pipeline agendado

set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BUCKET="s3://empresa-gitlab-backups"

echo "[$TIMESTAMP] Iniciando backup do GitLab..."

# Para instalação Docker:
docker exec gitlab gitlab-backup create \
  BACKUP=backup_$TIMESTAMP \
  SKIP=registry  # Registry tem volume próprio — backup separado

# Para instalação Kubernetes:
# kubectl exec -n gitlab deploy/gitlab-webservice -- \
#   gitlab-backup create BACKUP=backup_$TIMESTAMP

echo "Backup criado. Enviando para S3..."

# O GitLab já envia para object storage se configurado
# Mas para garantia extra, copiar também para um bucket de backup:
aws s3 cp \
  /var/opt/gitlab/backups/backup_${TIMESTAMP}_gitlab_backup.tar \
  $BUCKET/gitlab/ \
  --storage-class STANDARD_IA

# Backup das configurações (não incluídas no backup padrão)
# ATENÇÃO: contém chaves secretas — criptografar antes de armazenar
tar -czf /tmp/gitlab-config-$TIMESTAMP.tar.gz /etc/gitlab/
aws kms encrypt \
  --key-id alias/gitlab-backup-key \
  --plaintext fileb:///tmp/gitlab-config-$TIMESTAMP.tar.gz \
  --output text \
  --query CiphertextBlob | \
  aws s3 cp - $BUCKET/config/gitlab-config-$TIMESTAMP.tar.gz.enc

# Limpar arquivos temporários
rm -f /tmp/gitlab-config-$TIMESTAMP.tar.gz

echo "[$TIMESTAMP] Backup concluído."

# Verificar que backups antigos estão sendo removidos (retenção 30 dias)
aws s3 ls $BUCKET/gitlab/ | \
  awk '{print $4}' | \
  sort | \
  head -n -30 | \
  xargs -I{} aws s3 rm $BUCKET/gitlab/{}

Atualizações: O Maior Desafio do Self-Hosted

Manter um GitLab self-hosted atualizado é o aspecto operacional mais trabalhoso. O GitLab lança uma nova versão minor todo mês (no dia 22) e patches de segurança conforme necessário. Saltar versões sem seguir o caminho de upgrade pode corromper o banco de dados.

A regra fundamental é: nunca pular mais de uma versão minor por vez, e sempre passar pelas versões marcadas como "upgrade stops" pelo GitLab. O path de upgrade de 16.0 para 17.2, por exemplo, precisa passar por versões intermediárias específicas.

Para instalações Docker, o processo de atualização é:

# Verificar a versão atual
docker exec gitlab gitlab-rake gitlab:env:info | grep "GitLab version"

# Verificar o caminho de upgrade recomendado em:
# https://gitlab-com.gitlab.io/support/toolbox/upgrade-path/

# Fazer backup antes de qualquer upgrade
docker exec gitlab gitlab-backup create

# Atualizar a imagem (substituir pela versão específica do upgrade path)
docker pull gitlab/gitlab-ee:17.1.0-ee.0

# Atualizar o docker-compose.yml e recriar o container
docker compose up -d gitlab

# Aguardar o GitLab reconfigure e as migrações de banco
docker logs -f gitlab | grep -E "gitlab Reconfigured|Running reconfigure"

 

Vimos a instalação e operação do GitLab self-hosted — do Docker Compose para ambientes menores até o Helm Chart para produção em Kubernetes, passando por backups e estratégia de atualizações. O próximo artigo sai do ecossistema GitLab e cobre o Bitbucket, a plataforma Git do ecossistema Atlassian, relevante para organizações que já usam Jira e Confluence como ferramentas centrais de gestão.

Referências para Aprofundamento

Exercícios

Exercício 1

No values.yml do Helm Chart, a chave appConfig aparece duas vezes sob global — uma configurando object_store e outra configurando ldap. O que acontece na instalação?

Ver resposta

✓ Resposta: Uma das duas é descartada. Em YAML, uma chave duplicada no mesmo nível não faz merge: dependendo do parser, ou a última definição sobrescreve silenciosamente a primeira, ou o arquivo é rejeitado com key already set. Nos dois casos, a configuração pretendida não é aplicada.

No pior cenário — a sobrescrita silenciosa — o ldap vence e o object_store é perdido. O GitLab sobe, o LDAP funciona, todo mundo autentica normalmente, e nada denuncia o problema. Só que artifacts, LFS, uploads e pacotes passam a ser gravados no disco local dos pods, em vez do S3.

As consequências aparecem em ordem crescente de gravidade, dias ou semanas depois:

  • O volume dos pods enche, porque artifacts de CI crescem rápido e não eram para estar ali.
  • Com replicaCount: 3 no webservice, cada réplica tem seu próprio disco: o upload feito na réplica A não é encontrado quando a requisição seguinte cai na réplica B. O sintoma é intermitente — o artifact "às vezes" existe.
  • Um pod reagendado perde os dados, porque o volume não é o mesmo.

A correção é fundir as duas seções em uma só:

global:
  appConfig:
    object_store:
      enabled: true
      proxy_download: true
      connection:
        secret: gitlab-s3-secret
        key: connection
    ldap:
      enabled: true
      servers:
        main:
          label: 'Active Directory'
          # …

Chave duplicada é um erro que nenhuma revisão de código pega por leitura, porque as duas definições costumam estar separadas por dezenas de linhas e cada uma parece correta isoladamente. Quem pega é ferramenta: yamllint reporta duplication of key, e helm template executado no CI mostra o manifesto final — onde a ausência do object storage fica visível antes de chegar ao cluster.

Exercício 2

O script de backup faz duas coisas separadas: o gitlab-backup create e, depois, um tar de /etc/gitlab/ criptografado com KMS. Por que esse segundo passo não é um detalhe — e o que acontece ao restaurar um backup sem ele?

Ver resposta

✓ Resposta: Sem o conteúdo de /etc/gitlab/ — especificamente o gitlab-secrets.json — o backup é praticamente inútil. Restaura-se um GitLab que sobe, mas com boa parte dos dados ilegíveis para sempre.

A razão é que o GitLab guarda no banco várias colunas criptografadas, e a chave que as decifra não está no banco: está no gitlab-secrets.json. O gitlab-backup create salva o banco, os repositórios e os uploads — e deliberadamente não inclui esse arquivo, para que o backup possa ser armazenado com menos risco.

O que se perde ao restaurar sem ele:

  • Todas as variáveis de CI/CD — as credenciais de deploy de todos os projetos, ilegíveis. Todo pipeline quebra.
  • Tokens de acesso pessoais e de runner — todos os runners precisam ser registrados de novo.
  • Credenciais de integrações, chaves de deploy e configurações de autenticação de dois fatores dos usuários.

E o modo de falha é cruel: nada avisa durante a restauração. O processo termina com sucesso, a interface abre, os repositórios estão lá. O problema só aparece quando alguém roda um pipeline e a variável chega vazia — no meio de um desastre, quando ninguém tem tempo de descobrir que o backup estava incompleto desde o começo.

O script acerta ao criptografar esse arquivo antes de enviá-lo, porque ele é literalmente a chave de tudo. Vale um cuidado adicional que o script não tem: guardar os dois em lugares com controle de acesso diferente. Quem tiver o .tar do banco e o gitlab-secrets.json no mesmo bucket tem, na prática, acesso a todas as credenciais da organização — e o desenho de backup passa a ser o elo mais fraco da segurança.

O corolário operacional é o de sempre: backup que nunca foi restaurado não é backup. O único jeito de descobrir que falta o gitlab-secrets.json é tentar restaurar em um ambiente de teste — que é justamente o item mensal do checklist de produção do capstone.

Exercício 3

O docker-compose.yml publica '22:22' com o comentário "SSH para Git". Que problema isso cria em praticamente qualquer servidor Linux, e como resolver sem abrir mão do acesso Git via SSH?

Ver resposta

✓ Resposta: A porta 22 do host já está ocupada pelo sshd — o próprio serviço pelo qual o administrador acessa a máquina. O docker compose up falha com address already in use, e o GitLab não sobe.

O perigo real não é a falha, que é imediata e clara. É a "correção" tentadora: mudar o sshd do host para outra porta e liberar a 22 para o container. Quem faz isso arrisca perder o acesso ao servidor se errar a configuração — e, pior, passa a ter o acesso administrativo dependendo de um serviço que agora convive com um container reiniciável.

A solução padrão é publicar o SSH do GitLab em outra porta do host e informar essa porta ao GitLab, para que ele mostre a URL de clone correta aos usuários:

    ports:
      - '80:80'
      - '443:443'
      - '2222:22'      # SSH do GitLab; a 22 do host continua com o sshd

    environment:
      GITLAB_OMNIBUS_CONFIG: |
        gitlab_rails['gitlab_shell_ssh_port'] = 2222

O gitlab_shell_ssh_port é o detalhe que costuma faltar: sem ele, a interface exibe git@gitlab.empresa.com:grupo/projeto.git, e todo clone falha porque o cliente tenta a porta 22. Com ele, a URL passa a incluir a porta correta.

Para os usuários, o caminho mais confortável é o ~/.ssh/config, que torna a porta transparente e permite clonar com a URL padrão:

Host gitlab.empresa.com
  Port 2222
  User git

Em produção com Kubernetes, o problema desaparece de outra forma: o chart expõe o SSH por um Service do tipo LoadBalancer, com IP próprio, e a porta 22 daquele IP não conflita com nada. É uma das razões práticas — junto com a alta disponibilidade — que empurram instalações sérias para o Helm em vez do Compose.

Exercício 4

O artigo alerta: "nunca pular mais de uma versão minor por vez, e sempre passar pelas versões marcadas como upgrade stops". Um administrador com GitLab 16.0 puxa a imagem 17.2 diretamente. Por que isso pode corromper a instalação?

Ver resposta

✓ Resposta: Por causa das background migrations — migrações de dados que rodam de forma assíncrona depois que a nova versão já está no ar.

O GitLab distingue dois tipos de migração. As estruturais alteram o schema e rodam durante o reconfigure, de forma síncrona. As de dados — reescrever colunas, popular tabelas novas, reparticionar históricos — envolvem milhões de linhas e travariam a instância por horas; então são enfileiradas no Sidekiq e processadas ao longo de dias, com a aplicação funcionando normalmente.

Cada versão assume que as migrações da versão anterior foram concluídas. Ao saltar de 16.0 para 17.2, a 17.2 espera encontrar dados no formato que as migrações de 16.1 a 17.1 produziriam — e esse trabalho nunca foi executado. As migrações da nova versão então operam sobre estruturas que não existem ou têm formato antigo, e o resultado vai de falhas no reconfigure a dados corrompidos de forma difícil de detectar.

É por isso que existem os upgrade stops: versões em que é obrigatório parar, esperar as migrações drenarem e só então prosseguir. E é por isso que existe a ferramenta de upgrade path citada no artigo — o caminho não é dedutível, ele é publicado.

O passo que falta no procedimento do artigo é verificar que as migrações terminaram antes de partir para a versão seguinte:

# precisa retornar 0 antes do próximo salto
docker exec gitlab gitlab-rails runner \
  'puts Gitlab::Database::BackgroundMigration::BatchedMigration.where.not(status: 3).count'

Sem essa checagem, um administrador diligente que respeite o caminho de versões ainda pode quebrar a instância, apenas por avançar rápido demais entre os saltos.

O procedimento acerta em dois pontos importantes: fazer backup antes de qualquer upgrade — e aqui o backup precisa incluir o gitlab-secrets.json, como no exercício anterior — e fixar a versão exata da imagem (17.1.0-ee.0) em vez de latest. Usar latest em um GitLab self-hosted é convidar um salto de várias versões no primeiro docker pull distraído.

Exercício 5

O gitlab-backup create é executado com SKIP=registry, e o comentário diz "Registry tem volume próprio — backup separado". Avalie o script de backup como um todo à luz dessa linha.

Ver resposta

✓ Resposta: O comentário promete um backup separado que o script não faz. O registry fica sem cobertura nenhuma — e o comentário é justamente o que impede alguém de notar, porque dá a entender que o assunto foi tratado em outro lugar.

Pular o registry é uma decisão defensável: ele guarda imagens de container, costuma ser o maior volume da instalação, e boa parte das imagens é reconstruível a partir do código. Mas "reconstruível" não é "recuperável": as imagens exatas que estão em produção agora podem não ser reproduzíveis se as tags base mudaram, se uma dependência saiu do ar ou se o commit correspondente foi reescrito. Perder o registry num desastre pode significar não conseguir subir a versão que estava rodando.

Há outros pontos frágeis no mesmo script, e vale enumerá-los porque são típicos de rotinas de backup escritas uma vez e nunca revisadas:

  • Nenhuma verificação de sucesso. O set -euo pipefail aborta em erro, mas ninguém é avisado. Um backup que parou de rodar há três semanas é indistinguível de um que roda bem — o cron falha em silêncio. Falta uma notificação de falha e, melhor ainda, um heartbeat: alertar quando o backup não acontecer.
  • Nenhuma validação de integridade. O .tar é enviado sem que se confira tamanho mínimo ou se ele sequer abre. Um backup truncado por disco cheio sobe para o S3 exatamente como um bom.
  • A limpeza de retenção é arriscada. O trecho com aws s3 ls | awk '{print $4}' | head -n -30 | xargs … rm depende do formato da saída do ls: se alguma linha vier sem o quarto campo, o comando executa aws s3 rm s3://…/gitlab/ — sem nome de arquivo. É o tipo de construção que não deve apagar nada; retenção é trabalho para uma lifecycle policy do bucket, que é declarativa e não depende de parsing de texto.

Vale notar o que o script faz de certo, e que muita gente esquece: envia para um bucket separado mesmo com o object storage já configurado. Backup guardado apenas no mesmo storage que a aplicação usa não protege contra o cenário que mais importa — alguém, ou algo, apagando o storage.

Comentários

Mais em DevOps

Boas Práticas de Imagens: Leveza, Segurança e Camadas
Boas Práticas de Imagens: Leveza, Segurança e Camadas

Os princípios que separam uma imagem funcional de uma imagem de produção…

Route53, CloudFront e ACM: Rede e Entrega de Conteúdo
Route53, CloudFront e ACM: Rede e Entrega de Conteúdo

A camada de entrega que fica entre o usuário e a aplicação: hosted zones e…

Grafana: Dashboards e Alertas que Fazem Sentido
Grafana: Dashboards e Alertas que Fazem Sentido

Dashboards que respondem perguntas em vez de acumular gráficos…