Do GitLab ao Gitea: como o CI/CD do meu homelab foi de 3,4 GB para 100 MB
Um dos meus repositórios tem um histórico de commits que lê como uma história em três atos. O primeiro é sobre ambição pessoal. O segundo é sobre um Raspberry Pi que ficou mudo no meio da noite . O terceiro é sobre encontrar a ferramenta certa para a carga certa.
Ato I: GitHub Actions com um runner self-hosted
O ponto de partida era simples: eu queria CI/CD rodando no Pi, builds acontecendo localmente, deploys automatizados. A escolha natural era continuar usando GitHub Actions — mas com um runner self-hosted no cluster.
O Actions Runner Controller (ARC) parecia elegante: um operator do Kubernetes que gerencia pods de runner automaticamente, escalando sob demanda. Configurei, funcionou. Mas algo me incomodava: toda vez que um build era necessário, um pod subia do zero, puxava a imagem, rodava o job, e desligava.
O ciclo era lento demais para um Pi rodando num cartão SD — e parecia que eu mal estava tocando no que o hardware realmente conseguia fazer. Quinze watts de computação sempre ligada, e eu estava roteando builds pelo GitHub só pela orquestração.
Além disso, o modelo de autenticação do ARC com o GitHub exige um GitHub App, tokens escopados, e uma dança de configuração que eu tinha que refazer toda vez que algo expirava. Para um homelab pessoal, não era o tipo de problema que eu queria ficar mantendo.
Ato II: GitLab CE e o Pi que não voltou
A lógica parecia sólida: se o problema é depender do GitHub, por que não hospedar o próprio Git? GitLab Community Edition é open source, maduro, tem tudo — Git, CI/CD, registry, issues, MRs.
Instalei o GitLab Omnibus no cluster. Funcionou. Por algumas horas.
Na manhã seguinte, o Pi estava mudo. SSH sem resposta, cluster inalcançável. Fui até a prateleira, pluguei um monitor — estava rodando, mas travado. O GitLab tinha consumido toda a RAM disponível e o kernel tinha disparado OOM (Out of Memory), matando processos aleatoriamente até o sistema ficar inoperante.
Os números, quando investiguei depois:
| Componente | RAM em idle |
|---|---|
| GitLab Web | ~1,2 GB |
| Puma (app server) | ~800 MB |
| Sidekiq (jobs em background) | ~600 MB |
| PostgreSQL (embutido) | ~400 MB |
| Redis (embutido) | ~200 MB |
| Gitaly (Git RPC) | ~200 MB |
| Total | ~3,4 GB |
O Pi tem 4 GB. Isso deixava 600 MB para o k3s, Traefik, e tudo mais. Qualquer build de CI matava o nó.
O GitLab é uma solução excelente — para servidores com 8 GB ou mais. Num Raspberry Pi, é como colocar um motor de F1 numa bicicleta.
Ato III: Gitea e a leveza que muda tudo
O Gitea é um servidor Git escrito em Go. Um único binário estático, sem dependências de runtime, sem PostgreSQL embutido, sem Sidekiq, sem Gitaly. SQLite como banco padrão.
O número que me convenceu: ~100 MB de RAM em idle.
Não é uma estimativa otimista. É o que vejo no kubectl top pod no dia a dia:
NAMESPACE NAME CPU(cores) MEMORY(bytes)
apps gitea-xxx 8m 94MiIsso libera espaço para o que realmente importa: os builds.
O deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
namespace: apps
spec:
replicas: 1
strategy:
type: Recreate # importante: evita que dois pods tentem montar o mesmo PVC
template:
spec:
containers:
- name: gitea
image: gitea/gitea:latest
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
env:
- name: GITEA__database__DB_TYPE
value: sqlite3
- name: GITEA__packages__ENABLED
value: "true"
- name: GITEA__service__DISABLE_REGISTRATION
value: "true"O strategy: Recreate não é um detalhe: com RollingUpdate (o padrão), o Kubernetes tenta subir o pod novo antes de matar o antigo. Dois pods tentando montar o mesmo PVC com SQLite resulta em corrupção de banco de dados. Aprendi isso durante um rollout de atualização.
O acesso SSH para push/pull vive num NodePort separado:
apiVersion: v1
kind: Service
metadata:
name: gitea-ssh
spec:
type: NodePort
ports:
- port: 22
targetPort: 22
nodePort: 30022O remote do git vira ssh://git@<ip-do-pi>:30022/org/repo.git. Funciona exatamente como o GitHub.
O runner: act_runner e o problema do buildkitd
O Gitea usa o act_runner — um runner compatível com a sintaxe do GitHub Actions. Você escreve .gitea/workflows/deploy.yml com a mesma estrutura de jobs e steps que usaria no GitHub, e funciona.
O runner roda como um pod no cluster, com acesso ao containerd do k3s via um socket montado como volume. Isso permite builds de imagem sem um daemon Docker — usando nerdctl + buildkitd.
Essa parte deu trabalho. Os commits contam a história:
Problema 1: o buildkitd por padrão usa o worker do containerd, mas o containerd do k3s vive em /run/k3s/containerd/ em vez de /run/containerd/. O primeiro build falhou silenciosamente.
Problema 2: o worker containerd do buildkitd precisava de acesso a /var/lib/buildkit/ no host — um diretório que não existe no Pi por padrão. Solução: trocar para o worker OCI com runc, que é autocontido.
Problema 3: dentro do pod do runner, 127.0.0.1:6443 é o loopback do próprio container, não o servidor da API do k3s. O kubeconfig gerado pelo k3s aponta para 127.0.0.1, então kubectl apply de dentro do pod falha. Solução: usar sed no kubeconfig para substituir por kubernetes.default.svc.cluster.local antes de usar.
sed 's|https://127.0.0.1:6443|https://kubernetes.default.svc.cluster.local|g' \
/etc/rancher/k3s/k3s.yaml > /tmp/kubeconfigProblema 4: o buildkitd precisa de acesso aos snapshots do containerd do k3s em /var/lib/rancher. Montar esse diretório como um volume hostPath resolveu.
No fim, o workflow de build fica assim:
# Inicia o buildkitd apontando para o worker OCI
buildkitd --addr unix:///run/buildkit/buildkitd.sock \
--oci-worker=true \
--containerd-worker=false \
--root /data/buildkit &
# Build com cache persistente
nerdctl \
--address /run/k3s/containerd/containerd.sock \
--namespace k8s.io \
build \
--cache-from type=local,src=/data/buildcache \
--cache-to type=local,dest=/data/buildcache,mode=max \
-t "${IMAGE}" .O cache com PVC faz uma diferença real: um segundo build de uma aplicação cujas dependências não mudaram é 80% mais rápido que o primeiro.
Organizando repositórios como namespaces
Uma coisa que o Gitea herda do GitHub (e o GitLab chama de “groups”) são organizações. No meu homelab, usei isso como convenção:
- Organização
docs→ repositórios de site/documentação, deployados no namespacedocs - Organização
apps→ aplicações, deployadas no namespaceapps
O workflow de deploy lê o nome da organização e usa como namespace no kubectl apply. Fica automático: criar um repositório na organização certa já determina onde ele termina no cluster.
Backup: simples porque é simples
Uma das vantagens do SQLite e de um único PVC é que o backup é trivial:
kubectl cp "apps/$POD:/data" "$BACKUP_DIR/$TIMESTAMP"
tar -czf "$BACKUP_DIR/$TIMESTAMP.tar.gz" -C "$BACKUP_DIR" "$TIMESTAMP"Tudo o que é o Gitea vive em /data dentro do pod: banco de dados, repositórios, uploads, configuração. Um kubectl cp captura tudo. Roda via cron às 5h, mantém os últimos 7 dias, com opção de criptografar com GPG antes de armazenar.
Com o GitLab, o backup envolvia scripts específicos da plataforma, exports separados de PostgreSQL e Redis, coordenação entre múltiplos serviços. Aqui é tar.
O que ficou para trás
Não é uma migração sem perdas. O GitLab tem coisas que o Gitea não tem (ou tem de forma mais simples):
- Revisões de Merge Request com comentários inline mais ricos
- Pipelines visuais com DAG de jobs
- Container Registry com garbage collection automático
- Pages embutido para sites estáticos
Para uso pessoal em homelab, nenhum desses é um bloqueador. O Gitea tem issues, PRs, webhooks, CI/CD básico, registry OCI — suficiente para tudo o que faço.
Trocar 3,4 GB por 100 MB num hardware com 4 GB disponíveis não foi uma preferência. Foi uma necessidade. E o resultado foi um cluster que sobrevive a um build sem bater em OOM — que é exatamente o que eu precisava.
Os manifestos, a configuração do runner e os scripts de build que fazem essa configuração funcionar estão disponíveis em gresas/setup-tools .