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:

ComponenteRAM 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           94Mi

Isso 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: 30022

O 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/kubeconfig

Problema 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 namespace docs
  • Organização apps → aplicações, deployadas no namespace apps

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 .