cd ..
12 min de leitura

Reformulando meu homelab #07: NAS dedicado, GitOps no ar e a primeira experiência com Kubernetes e ArgoCD

HOMELAB TRUENAS ARGOCD GITOPS TROUBLESHOOTING

Fazia um tempo que eu sabia que o storage do homelab precisava de uma solução melhor. Eu tinha uma VM storage-server rodando dentro do próprio Proxmox, com um disco SATA passado via passthrough, exportando NFS pros outros serviços. Funcionava, mas era gambiarra: storage rodando no mesmo hardware que o cluster, ocupando recursos de CPU e RAM, a única redundância que tinha era um backup em um HD externo e sem interface decente pra gerenciar.

A solução chegou na forma de um Lenovo V50s que encontrei barato na OLX: i3-10100, 8GB de RAM, slot PCIe livre. Virou o NAS dedicado. E com o NAS novo configurado, finalmente tinha o ambiente certo pra fazer o primeiro deploy real do homelab-gitops, o repositório que venho construindo há alguns meses e que nunca tinha rodado de verdade num cluster.

Esse post cobre as duas frentes: configurar o TrueNAS do zero no V50s e executar o primeiro deploy do ArgoCD com Nextcloud, Immich e Jellyfin. Com todos os erros no caminho.

# O hardware do NAS

O plano era colocar quatro discos: dois SSDs de 1TB pra dados ativos e dois HDDs de 1TB pra backup. Comprei uma placa PCIe SATA (ASM1166) pra ter portas suficientes, e uma placa de rede 2.5GbE que já tinha por aqui pra ter mais banda no NFS.

Aqui já apareceu o primeiro problema: as duas placas PCIe juntas não funcionavam. O sistema reconhecia uma ou outra, nunca as duas ao mesmo tempo. Limitação do barramento PCIe do V50s. Tive que escolher: ficou o SATA card, perdeu a 2.5GbE. Por enquanto o NAS roda com a NIC onboard de 1GbE, pra resolver depois com um adaptador M.2 de rede.

# TrueNAS 25.10

Instalei o TrueNAS 25.10 no M.2 dedicado do V50s. A instalação em si não tem muito segredo, mas tem alguns detalhes de BIOS que fazem diferença:

Depois do boot inicial configurei o IP fixo 10.10.10.15 e habilitei SSH. Uma coisa que aprendi rápido: o filesystem root do TrueNAS é read-only. Não dá pra instalar software via script shell. O Tailscale, por exemplo, precisou ser instalado via Apps → Discover → Tailscale pela UI, não pelo curl | sh que uso no Ansible.

Pools e datasets

Criei dois pools ZFS stripe: data-ssd com os dois SSDs e backup-hdd com os dois HDDs. Stripe sem redundância: se um disco falhar, o pool inteiro é perdido. Sei do risco e aceito conscientemente porque tenho replicação automática entre os pools.

Dentro do data-ssd criei os datasets por serviço: nextcloud, immich, jellyfin. Cada um virou um NFS share. E configurei uma Periodic Snapshot Task de 24h com retenção de duas semanas mais uma Replication Task que copia tudo do data-ssd pra o backup-hdd automaticamente depois de cada snapshot.

Ajustes no homelab-infra

Com o NAS novo, o storage-server deixou de existir. Removi a VM do Terraform (terraform state rm antes de deletar o código), tirei do inventário do Ansible e atualizei as variáveis que apontavam para os paths antigos. O nfs_server no vars.yml passou a apontar para o TrueNAS em vez da VM.

Dois bugs apareceram nos serviços que dependiam do NFS antigo. O MotionEye no Pi4 parava de subir depois de reboot porque o diretório /run/motioneye é efêmero e sumia. Fix: adicionar RuntimeDirectory=motioneye no service do systemd via Ansible. O UpSnap reclamava de permissão no mount NFS porque o NFS exportava com root_squash por padrão, convertendo root do cliente para nobody. Fix: chmod 777 no dataset pelo TrueNAS como root e no_root_squash nas opções de export.

# Primeiro deploy do homelab-gitops

Com o NAS no ar e os paths atualizados no homelab-gitops, era hora de rodar de verdade o que eu tinha escrito nos últimos meses. A sequência do README parecia simples, mas na prática, levei várias horas debugando.

Os primeiros dois passos

kubectl apply -f bootstrap/namespaces.yml
kubectl apply -f bootstrap/argocd-app-of-apps.yml

Isso foi tranquilo. Os namespaces foram criados, o App of Apps registrou e o ArgoCD começou a puxar os Application objects do diretório apps/. Em segundos os cinco apps apareceram no watch: cert-manager, cnpg-operator, nextcloud, immich, jellyfin. Todos Progressing.

cnpg-operator OutOfSync

O primeiro problema foi o cnpg-operator travando em OutOfSync depois de alguns minutos. O erro no kubectl describe:

CustomResourceDefinition "clusters.postgresql.cnpg.io" is invalid:
metadata.annotations: Too long: may not be more than 262144 bytes

Os CRDs do CNPG são grandes demais pra caber na annotation last-applied-configuration que o kubectl usa por padrão — é um problema conhecido que descobri pesquisando. O fix é adicionar ServerSideApply=true nos syncOptions do apps/cnpg-operator.yml e sincronizar via UI do ArgoCD (Sync → Server-Side Apply, sem marcar Force, porque --force e --server-side são mutuamente exclusivos). Após alguns minutos o operator subiu e virou Synced/Healthy.

PVCs que não bindavam

Com os namespaces criados, apliquei os PVCs:

kubectl apply -f charts/nextcloud/pvc.yml
kubectl apply -f charts/immich/pvc.yml
kubectl apply -f charts/jellyfin/pvc.yml

Dois ficaram em Pending. O immich-pvc pedia 200Gi mas o PV tinha capacidade de 100Gi declarada. Kubernetes não binda quando o PVC pede mais do que o PV oferece. Fix simples: corrigir o PVC pra 100Gi.

O jellyfin-media-pvc não tinha storageClassName: "" no manifest, então herdava o StorageClass padrão do cluster (local-path) em vez de binder com o PV que tinha campo vazio explícito. Fix: adicionar o campo no PVC.

A morte das imagens Bitnami

O Nextcloud no Helm usa um subchart do PostgreSQL da Bitnami. O problema: as imagens bitnami/postgresql com tags nominais como 16-debian-12 não existem mais no Docker Hub. A Bitnami migrou pra um modelo de imagens pagas. O cluster ficava em ImagePullBackOff sem saída.

A solução foi mover o banco do Nextcloud também pro CNPG, igual ao Immich. Criei um charts/nextcloud/postgres-cluster.yml com um Cluster CNPG, desabilitei o subchart da Bitnami no values.yml e ativei o externalDatabase apontando pro service do CNPG.

Mas o chart do Nextcloud sempre gera um init container que tenta resolver o host nextcloud-postgresql antes de iniciar, independente de você ter habilitado banco externo ou não. A solução foi criar um Service ExternalName que redireciona esse nome pro CNPG:

apiVersion: v1
kind: Service
metadata:
  name: nextcloud-postgresql
  namespace: nextcloud
spec:
  type: ExternalName
  externalName: nextcloud-postgres-rw.nextcloud.svc.cluster.local

Imagem do Postgres do Immich incompatível com o CNPG

O postgres-cluster.yml do Immich estava usando ghcr.io/immich-app/postgres:17-vectorchord0.4.3-pgvectors0.4.0. Dois problemas: a tag não existia mais, e a imagem do Immich não tem o UID 26 que o operator CNPG exige pra inicializar o banco.

A solução foi trocar pela imagem ghcr.io/tensorchord/cloudnative-vectorchord:17-0.3.0, que é construída especificamente pra rodar com CNPG e já tem os UIDs corretos e as extensões vectorchord disponíveis.

Mesmo com a imagem certa, as extensões precisaram ser criadas manualmente como superuser depois do banco subir, porque o usuário immich não tem permissão pra isso:

kubectl exec -it immich-postgres-1 -n immich -- psql -U postgres -d immich -c "CREATE EXTENSION IF NOT EXISTS vchord CASCADE;"
kubectl exec -it immich-postgres-1 -n immich -- psql -U postgres -d immich -c "CREATE EXTENSION IF NOT EXISTS cube CASCADE;"
kubectl exec -it immich-postgres-1 -n immich -- psql -U postgres -d immich -c "CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE;"

NFS com root_squash bloqueando tudo

O Immich subiu, conectou no banco, rodou as migrations e então:

Failed to create /data/encoded-video/.immich: Error: EACCES: permission denied

O Jellyfin:

Access to the path '/config/log' is denied.

Mesmo problema do UpSnap no Pi4. O TrueNAS exporta NFS com root_squash ativo por padrão, que mapeia UID 0 para nobody. Os pods rodavam como root, o TrueNAS bloqueava a escrita. Fix: nos NFS shares de cada serviço no TrueNAS, configurar Maproot User: root e Maproot Group: root. Após isso, restart dos pods e tudo passou a escrever normalmente.

bjw-s common library e as env vars ignoradas

O chart do Immich usa a biblioteca bjw-s common library. Eu tinha as variáveis de ambiente no nível raiz do values.yml:

env:
  DB_HOSTNAME: immich-postgres-rw

Essas variáveis simplesmente eram ignoradas. O servidor subia tentando conectar num host chamado database, que é o default hardcoded. A estrutura correta pro bjw-s é:

controllers:
  main:
    containers:
      main:
        env:
          DB_HOSTNAME: immich-postgres-rw

Depois desse ajuste o servidor conectou no banco pela primeira vez.

Nextcloud derrubado pelo liveness probe

Na primeira inicialização, o Nextcloud precisa criar todas as tabelas do banco. Isso leva mais tempo do que o default do liveness probe. O Kubernetes matava o container antes de terminar a inicialização, o pod entrava em CrashLoopBackOff e nunca conseguia terminar o setup.

Fix: aumentar o initialDelaySeconds pra 120 e o failureThreshold pra 10 nos probes do Nextcloud. Na próxima tentativa o container subiu e o Nextcloud inicializou corretamente.

# TLS e DNS

Com todos os serviços Healthy, faltava acesso externo. O ClusterIssuer do cert-manager estava criado mas os challenges ficaram travados em pending por horas. O motivo: criei o ClusterIssuer antes de criar o secret cloudflare-api-token. O cert-manager não conseguia autenticar na Cloudflare pra criar o registro TXT de validação.

Fix: criar o secret e deletar os challenges antigos pra forçar reemissão:

kubectl delete challenge -n nextcloud nextcloud-tls-1-...
kubectl delete challenge -n immich immich-tls-1-...
kubectl delete challenge -n jellyfin jellyfin-tls-1-...

Em alguns minutos os três certificados viraram Ready: True.

Para o DNS, criei registros A na Cloudflare apontando pra o IP Tailscale do k3s-node, todos com proxy desabilitado (DNS only). O IP Tailscale não é roteável publicamente, então na prática só quem está na minha rede Tailscale consegue acessar, mesmo que o nome resolva pra qualquer um que consulte o DNS.

O último detalhe foi o MagicDNS do Tailscale interceptando as consultas DNS. Ativando Override DNS servers no painel do Tailscale com o Cloudflare como global nameserver, o problema sumiu e os três domínios passaram a resolver corretamente.

# Estado final

App Status Endereço
cert-manager Synced / Healthy
cnpg-operator Synced / Healthy
nextcloud Synced / Healthy nextcloud.homelab.luisbrancher.dev
immich Synced / Healthy immich.homelab.luisbrancher.dev
jellyfin Synced / Healthy jellyfin.homelab.luisbrancher.dev

TLS emitido via Let's Encrypt pra todos os três. Acesso via Tailscale de qualquer dispositivo na minha rede.

# O que ficou pendente

Nem tudo fechou nessa sessão. O immich-nextcloud-photos-pvc está criado e bound, mas ainda não montado no pod do Immich. Falta adicionar extraVolumes e extraVolumeMounts no values.yml e configurar o path como External Library na UI. O Jellyfin também está no ar mas sem conteúdo: os arquivos de mídia precisam ser enviados via SFTP pro /mnt/media no k3s-node, que é onde o hostPath PVC aponta.

São os próximos passos. Por enquanto, o que importa é que saí de zero pra tudo healthy em um cluster real, com storage dedicado, TLS automático e GitOps funcionando. Qualquer mudança de configuração agora é um git push.

O código está no homelab-gitops e a infraestrutura no homelab-infra.