Migrando meu Home Server com Claude Code | openSUSE MicroOS

31 de março de 2026 · 💬 Participe da Discussão

Meu home server antigo era uma bagunça. Um Intel NUC com Ubuntu Server que eu fui remendando ao longo de dois anos. Containers com caminhos hardcoded, volumes montados em locais aleatórios (/home/akitaonrails/docker/, /home/akitaonrails/sonarr/, /mnt/terachad/), docker-compose files espalhados sem padrão nenhum. Funcionava, mas se eu perdesse o disco, levaria dias pra reconstruir tudo de memória.

Com o novo Minisforum MS-S1 Max que comprei, decidi fazer a migração direito. E decidi usar Claude Code desde o início pra acelerar o processo. É um servidor caseiro, só eu uso, o risco de fazer besteira é baixo. Mas se fosse um servidor de produção real, eu jamais faria isso sem review humano rigoroso em cada passo.

O que segue é o relato da migração e o guia pra quem quiser replicar. Se um dia eu precisar reconstruir do zero, esse post é a documentação.

Escolha do sistema operacional

Por que não Ubuntu Server de novo

Eu usei Ubuntu Server no NUC por praticidade. Mas do-release-upgrade é uma roleta russa. Toda vez que o Ubuntu lança versão nova, a atualização é um risco real de quebrar coisas. Pacotes mudam, configs são sobrescritas, dependências conflitam. Pra um servidor que precisa estar sempre rodando, isso é inaceitável.

Por que não Arch Linux

Eu uso Arch no desktop e gosto. Mas Arch é uma rolling release sem nenhuma garantia de estabilidade. Pra um desktop onde eu posso parar e resolver problemas, ótimo. Pra um servidor headless que roda 49 containers Docker e precisa funcionar depois de cada reboot, não.

Fedora CoreOS vs openSUSE MicroOS

As duas opções modernas pra servidor de containers são Fedora CoreOS e openSUSE MicroOS. As duas são sistemas imutáveis: o root filesystem é read-only, atualizações são atômicas (ou aplicam inteiro ou não aplicam nada), e rollback é instantâneo.

A diferença: Fedora CoreOS usa Ignition (configuração declarativa antes do primeiro boot) e é projetada pra ser provisionada automaticamente. MicroOS usa transactional-update e permite uso interativo normal. Pra um home server onde eu quero SSH e mexer manualmente quando precisar, MicroOS se encaixa melhor.

O que torna MicroOS diferente

O conceito de sistema imutável muda como você opera o servidor:

Toda instalação de pacote ou edição de /etc passa por transactional-update, que cria um snapshot btrfs novo, aplica a mudança nesse snapshot, e no reboot seguinte o sistema boota no snapshot atualizado. Se a mudança quebrar alguma coisa, você faz transactional-update rollback e volta pro snapshot anterior em segundos.

Atualizações são automáticas e diárias. O transactional-update.timer baixa patches, cria snapshot, e o rebootmgr reinicia numa janela configurada (no meu caso, entre 4h e 5h30 da manhã). Se a atualização quebra o boot, o GRUB automaticamente volta pro snapshot anterior.

SELinux vem enforcing por padrão. Isso causou 90% dos problemas durante a migração, mas é a configuração certa pra segurança.

Setup inicial

Hardware

  • AMD Ryzen AI Max+ 395, 128GB LPDDR5X
  • 96GB alocados como VRAM via BIOS (UMA Frame Buffer Size)
  • NVMe de 2TB (sistema + Docker)
  • Rede cabeada 2.5Gbps
  • Synology DS1821+ NAS em 192.168.0.21 (NFS)

Primeiros passos

Instalação do MicroOS é padrão. Depois:

# Criar usuário com UID que bate com o NAS (pra NFS funcionar sem problemas de permissão)
useradd -u 1026 -m akitaonrails
passwd akitaonrails

# Configurar sudo (dentro de transactional-update shell)
sudo transactional-update shell
# dentro: adicionar akitaonrails ao sudoers
exit
sudo reboot

NFS do Synology

O NAS Synology exporta /volume1/TERACHAD via NFS. O ponto de montagem no MicroOS é /var/mnt/terachad (não /mnt/, que fica no root imutável).

No /etc/fstab (aplicado via transactional-update):

192.168.0.21:/volume1/TERACHAD /var/mnt/terachad nfs4 nfsvers=4.1,rsize=262144,wsize=262144,hard,_netdev 0 0

Detalhes que importam: nfsvers=4.1 porque 4.2 não funcionou com o Synology. rsize=262144,wsize=262144 (256KB buffers) foi a maior melhoria de performance NFS. hard em vez de nofail pra que o mount retente indefinidamente se o NAS desconectar temporariamente.

GPU / ROCm

Esse passo deu trabalho. O Radeon 8060S do AI Max+ 395 é gfx1151, que o ROCm não suporta oficialmente. Foram necessários três passos, e os três são obrigatórios:

  1. BIOS: setar UMA Frame Buffer Size pra 96GB
  2. Kernel: adicionar amdttm.pages_limit=25165824 amdttm.page_pool_size=25165824 em /etc/kernel/cmdline
  3. Docker: usar HSA_OVERRIDE_GFX_VERSION=11.5.1 em todo container ROCm

Sem o passo 2, o ROCm só vê 15.5GB mesmo com a alocação do BIOS. Os números são 96GB / 4KB (page size) = 25.165.824 pages.

sudo transactional-update shell
echo "amdttm.pages_limit=25165824 amdttm.page_pool_size=25165824" >> /etc/kernel/cmdline
exit
sudo sdbootutil update-all-entries  # FORA do transactional shell
sudo reboot

Verificação:

cat /sys/class/drm/card1/device/mem_info_vram_total
# 103079215104 (96 * 1024^3)

Docker no MicroOS

sudo transactional-update --non-interactive pkg install docker
sudo reboot

sudo systemctl enable --now docker
sudo usermod -aG docker akitaonrails
# logout e login pra o grupo pegar

# Instalar docker-compose standalone (o pacote openSUSE não inclui)
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64" \
  -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
mkdir -p ~/.docker/cli-plugins
ln -s /usr/local/bin/docker-compose ~/.docker/cli-plugins/docker-compose

daemon.json

{
  "log-level": "warn",
  "log-driver": "local",
  "log-opts": {"max-size": "10m", "max-file": "5"},
  "selinux-enabled": true,
  "live-restore": true,
  "userland-proxy": false,
  "exec-opts": ["native.cgroupdriver=systemd"]
}

live-restore: true faz containers sobreviverem restart do daemon Docker. userland-proxy: false usa iptables direto em vez de processos proxy (menos overhead). selinux-enabled: true é obrigatório no MicroOS.

SELinux e Docker: a maior fonte de problemas

Isso merece uma seção inteira porque foi responsável por 90% dos bugs durante a migração.

No MicroOS com SELinux enforcing, todo container que escreve em diretório bind-mounted do host precisa de tratamento especial. Existem duas abordagens: o sufixo :Z nos volumes e a opção security_opt: label:disable.

NUNCA use :Z. Use security_opt: label:disable.

O :Z diz pro Docker relabeling o diretório do host com o contexto SELinux do container. Parece a coisa certa. Na prática:

  • Bancos SQLite quebram. O relabeling muda o contexto do arquivo e o SQLite pode recusar abrir o WAL journal.
  • Mounts NFS ignoram :Z silenciosamente. O NFS não suporta xattrs do SELinux. O kernel ignora o flag sem erro, mas o container continua sem permissão.
  • Mounts :ro,Z tentam relabeling mesmo sendo read-only, o que falha em NFS e pode corromper contexto em paths locais.

A solução correta pra todo container nesse sistema:

services:
  meuservico:
    security_opt:
      - label:disable     # desliga enforcement SELinux pra esse container
    volumes:
      - ./data:/data      # SEM :Z
      - ./config.yml:/etc/config.yml:ro  # SEM :Z mesmo em :ro

label:disable desliga enforcement de labels SELinux apenas pra aquele container, não pro sistema inteiro. Combinado com o isolamento de rede e processos do Docker, é seguro pra home server.

A migração dos stacks

Todos os stacks Docker foram reorganizados em /var/opt/docker/<stack>/docker-compose.yml. No servidor antigo, estavam espalhados em /home/akitaonrails/docker/, /home/akitaonrails/<servico>/, sem padrão.

Substituições aplicadas em todos os compose files

AntesDepois
/mnt/terachad//var/mnt/terachad/
192.168.0.145192.168.0.90
/home/akitaonrails/<servico>//var/opt/docker/<stack>/<servico>/
OLLAMA_BASE_URL=http://192.168.0.14:11434OLLAMA_BASE_URL=http://192.168.0.90:11434

Stack de media (Plex, Radarr, Sonarr, etc.)

O stack de media é o mais complexo. Plex precisa de IP próprio na LAN (macvlan) pro streaming direto funcionar. O setup:

docker network create -d macvlan \
  --subnet=192.168.0.0/24 \
  --gateway=192.168.0.1 \
  -o parent=enp97s0 \
  plex_macvlan

No compose, o Plex precisa estar em duas redes: a macvlan (pro IP 192.168.0.6) e a bridge default (pra outros containers como Seerr conseguirem se comunicar):

plex:
  networks:
    plex_macvlan:
      ipv4_address: 192.168.0.6
      mac_address: "02:42:c0:a8:00:06"
    default: {}    # obrigatório — sem isso, Seerr não enxerga o Plex

Detalhe que quase me quebrou: o Plex guarda paths absolutos no banco de dados. Se o volume interno do container mudou de /media pra /data, o Plex não encontra mais nada. Tem que usar exatamente o mesmo mount target do compose antigo.

Ollama com ROCm

Stack novo, não existia no servidor anterior:

ollama:
  image: ollama/ollama:rocm
  container_name: ollama
  devices:
    - /dev/kfd:/dev/kfd
    - /dev/dri:/dev/dri
  security_opt:
    - seccomp:unconfined
    - label:disable
  group_add:
    - "485"   # render group GID
    - "488"   # video group GID
  environment:
    - HSA_OVERRIDE_GFX_VERSION=11.5.1
    - PYTORCH_ROCM_ARCH=gfx1151
    - OLLAMA_KEEP_ALIVE=30m
    - OLLAMA_NUM_PARALLEL=4
    - OLLAMA_FLASH_ATTENTION=1
    - OLLAMA_KV_CACHE_TYPE=q8_0
  volumes:
    - /var/lib/ollama:/root/.ollama
  ports:
    - "11434:11434"

OLLAMA_FLASH_ATTENTION=1 ativa flash attention. OLLAMA_KV_CACHE_TYPE=q8_0 usa KV cache em 8-bit, cortando bandwidth necessária por token pela metade. São otimizações de performance gratuitas.

Monitoramento (Grafana + Prometheus)

O Grafana usa named volume (grafana_data) que NÃO é incluído em backups normais de filesystem. Foi o motivo pelo qual perdi todos os dashboards na primeira tentativa. A solução é backup explícito do named volume:

# No servidor antigo:
docker run --rm -v grafana_data:/data:ro -v /tmp:/backup alpine \
  tar czf /backup/grafana_data.tar.gz -C /data .

# Transferir e restaurar no novo:
docker run --rm -v grafana_data:/data -v /tmp:/backup alpine \
  sh -c "cd /data && tar xzf /backup/grafana_data.tar.gz"

Mesma coisa pro Portainer (portainer_data). Qualquer volume definido no bloco volumes: do compose sem host path precisa desse tratamento.

Cloudflare Tunnel

Eu uso Cloudflare Tunnel pra acessar todos os serviços fora de casa sem abrir portas no roteador. A migração foi a mais fácil: copiar o arquivo de credenciais JSON do túnel e o config.yml, atualizar os IPs de .145 pra .90, e subir o container. O túnel mantém o mesmo ID, não precisa recriar DNS.

Os hostnames ficam em config.yml: portainer, grafana, plex, seerr, qbittorrent, syncthing, radarr, sonarr, bazarr, prowlarr, vault, gitea, kavita, e outros. Tudo acessível via https://<servico>.example.com de qualquer lugar.

Gitea (registry de imagens)

O Gitea funciona como registry Docker privado na porta 3007. Os projetos Frank FBI, Frank Mega, Frank Yomik e Mila têm imagens Docker que são buildadas e pushadas pro Gitea. Pra funcionar, o daemon.json do Docker precisa de:

{
  "insecure-registries": ["192.168.0.90:3007"]
}

O SSH do Gitea deu problema na migração: o app.ini antigo tinha SSH_LISTEN_PORT=22, mas o entrypoint do container também inicia sshd na porta 22. Conflito. Solução: GITEA__server__SSH_LISTEN_PORT=2222 como variável de ambiente no compose.

Todos os 49 containers rodando

O servidor migrado roda 49 containers em 15 stacks. O stack de media sozinho tem 10 containers (Plex, Radarr, Sonarr, Bazarr, Prowlarr, qBittorrent, SABnzbd, Jackett, FlareSolverr, Seerr). Os projetos pessoais (Frank FBI, Frank Mega, Frank Yomik, Mila) somam mais 11. Monitoramento com Grafana, Prometheus, node-exporter e cAdvisor. Utilitários como Portainer, Vaultwarden, Syncthing, Organizr, Watchtower. Gitea como registry Docker privado. Immich como Google Photos self-hosted. Kaizoku pra manga com Kavita como reader. Ollama com ROCm. E o Bitcoin Core/Fulcrum indexando a blockchain do NAS.

Backups: duas camadas

Camada 1: snapshots btrfs locais (snapper)

O /var vive numa partição btrfs de 3.7TB. O snapper cria snapshots automáticos: 7 diários + 1 semanal. São crash-consistent, não application-consistent (postgres pode ficar levemente inconsistente se tiver write pesado no momento do snapshot).

Pra recuperar um arquivo apagado acidentalmente:

sudo snapper -c var list
sudo cp /var/.snapshots/5/snapshot/opt/docker/media/radarr/appdata/config/radarr.db \
        /var/opt/docker/media/radarr/appdata/config/radarr.db

Pra rollback completo de um stack:

sudo docker compose -p media down
sudo snapper -c var undochange 7..0 /var/opt/docker/media
sudo docker compose -p media up -d

Camada 2: restic pro NAS (off-machine)

O restic roda toda noite às 3h, faz backup incremental pra /var/mnt/terachad/homelab-backups/. Retenção: 7 diários + 4 semanais. Deduplicação por conteúdo, então Plex config (19GB) e Gitea repos (12GB) transferem apenas deltas.

Antes do restic rodar, um pg_dump exporta os bancos postgres (Immich, Kaizoku). Os dumps vão pra /tmp/homelab-db-dumps/ e são incluídos no backup.

O que NÃO é incluído no backup (re-downloadável): blockchain Bitcoin (785GB no NAS), imagens Docker (re-pullable), modelos Ollama (re-downloadable), cache do HuggingFace/EasyOCR, transcoding temporário do Plex.

Diretórios grandes e re-downloadáveis foram convertidos em subvolumes btrfs pra que o snapper os ignore: /var/lib/ollama e /var/opt/docker/bitcoin/fulcrum/fulc2_db.

Tuning de performance

btrfs com compressão zstd

Adicionei compress=zstd:1 no fstab pra partição /var. O zstd nível 1 tem overhead de CPU quase zero em NVMe e comprime bem metadata Docker, JSON configs e logs. Dados incompressíveis (SQLite, postgres) são ignorados automaticamente pelo btrfs.

zram swap

Com ~30GB de RAM disponível pro sistema (96GB vão pra VRAM), swap comprimido em memória ajuda. O zram cria um dispositivo de swap de ~15GB (ram/2) com compressão zstd, muito mais rápido que swap em disco.

# /etc/systemd/zram-generator.conf
[zram0]
zram-size = ram / 2
compression-algorithm = zstd
swap-priority = 100

btrfs nodatacow em diretórios de banco de dados

Copy-on-write + escrita aleatória de banco de dados = write amplification. Desabilitei CoW nos diretórios que guardam SQLite e postgres:

sudo chattr +C /var/opt/docker/gitea/data/gitea.db
sudo chattr +C /var/opt/docker/immich/db/
sudo chattr +C /var/opt/docker/media/radarr/appdata/config/

CPU em modo performance

Num servidor headless, não faz sentido economizar energia:

echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference

Persistido via systemd service cpu-epp.service.

Docker shutdown fix

Problema que descobri: o Docker vem com KillMode=process, que significa que no shutdown do sistema, o systemd mata só o dockerd e deixa todos os containerd-shim (um por container, ~49 no meu caso) órfãos. O systemd-shutdown precisa caçar eles um a um depois que o journal já parou, causando um hang silencioso de vários minutos.

Fix:

# /etc/systemd/system/docker.service.d/shutdown.conf
[Service]
KillMode=control-group
TimeoutStopSec=30

Os problemas que encontramos

Essa é a tabela de problemas reais que tivemos durante a migração. Se você está planejando algo parecido, leia antes de começar:

ProblemaCausaSolução
ROCm vê só 15.5GB de VRAMKernel TTM limita pages mesmo com BIOS em 96GBAdicionar amdttm.pages_limit=25165824 no kernel cmdline
Todos containers: permission denied em volumesSELinux container_t não escreve em paths sem labelsecurity_opt: label:disable em todo serviço
NFS com :Z falha silenciosamenteNFS não suporta xattr do SELinuxNunca usar :Z em paths NFS
SQLite quebra com :ZRelabeling muda contexto, WAL mode falhaRemover :Z, usar label:disable
Radarr/Sonarr mostraram tela de setupBackup em appdata/config/ mas compose montava appdata/Corrigir: appdata/config:/config
Grafana perdeu dashboardsNamed volume não incluído no backup de filesystemBackup explícito do named volume
Plex não encontra mídiaPath interno mudou de /media pra /dataRestaurar path original no compose
Seerr não conecta no Plexmacvlan isolada da bridge networkAdicionar default: {} nas networks do Plex
Fulcrum crash: “option -b missing”Env vars não suportadas pela imagemUsar flags CLI em command:
bitcoind rejeita RPCBind em ::1 por padrãoAdicionar -rpcbind=0.0.0.0 -rpcallowip=172.0.0.0/8
sdbootutil warning no transactional shellDeve rodar fora da transaçãoExecutar sdbootutil update-all-entries no shell normal
Watchtower permission denied em docker.sockSELinux bloqueia acesso ao socketlabel:disable
Gitea SSH crashConflito: entrypoint sshd porta 22 + app porta 22GITEA__server__SSH_LISTEN_PORT=2222
docker-compose não instalado com DockerPacote openSUSE só instala o daemonInstalar binário standalone manualmente

O que dizer pro Claude Code antes de começar

Se eu fosse refazer a migração do zero, daria estas instruções pro Claude Code na primeira mensagem. Na ordem de importância:

Diga que SELinux está enforcing e que ele NÃO deve usar :Z em nenhum volume Docker, e sim security_opt: label:disable em todo serviço. Diga que /var/mnt/terachad/ é mount NFS e que :Z nunca deve aparecer em paths NFS. Diga pra sempre olhar o compose original antes de reescrever e só mudar IPs, paths e nomes de container, sem inventar novos layouts de volume. Avise que named volumes precisam de backup explícito (Grafana, Portainer). Explique que Plex roda em macvlan e precisa de default: {} nas networks. Informe que a GPU é gfx1151, não suportada oficialmente, e que precisa de UMA 96GB no BIOS + kernel TTM params + HSA_OVERRIDE_GFX_VERSION=11.5.1. E diga que Bitcoin/Fulcrum não processam variáveis de ambiente, tudo vai como argumento no command:.

Essas instruções teriam evitado 80% dos problemas que encontramos.

Layout final do servidor

/var/opt/docker/
├── bitcoin/          (bitcoind + fulcrum)
├── cloudflared/      (túnel Cloudflare)
├── frank_fbi/        (análise de fraude por email)
├── frank_mega/       (clone do Mega)
├── frank_yomik/      (tradução de mangás)
├── gitea/            (registry Docker)
├── immich/           (Google Photos self-hosted)
├── kaizoku/          (manga downloader + reader)
├── media/            (Plex + *arr stack)
├── mila/             (bot Discord)
├── monitor/          (Grafana + Prometheus)
├── ollama/           (LLM local com ROCm)
├── rip/              (HandBrake)
└── utils/            (Portainer, Vaultwarden, Syncthing, etc.)

/var/mnt/terachad/    (NFS do Synology)
├── Bitcoin/data/     (blockchain, 785GB)
├── Downloads/        (torrents + nzbget)
├── Videos/           (Radarr movies + Sonarr series)
└── Ollama/models/    (overflow de modelos se disco local encher)

Aviso sobre usar IA pra administrar servidor

Eu usei Claude Code pra acelerar a migração. Criou compose files, escreveu scripts de backup, configurou firewall, diagnosticou problemas de SELinux. Funcionou bem pro meu caso: home server, só eu uso, e eu estava revisando cada passo.

Mas tem armadilhas. O Claude não sabe que :Z quebra SQLite a menos que você diga. Ele não sabe que o Fulcrum não aceita env vars a menos que já tenha visto o Dockerfile. Ele vai inventar layouts de volume “melhores” que quebram o Plex porque o Plex guarda paths absolutos no banco de dados.

Se fosse produção real: não faça isso sem review. Cada compose file que o Claude gerar, leia inteiro antes de aplicar. Cada comando destrutivo (rollback, delete, recreate), confirme manualmente. E tenha backups testados antes de começar. O Claude é bom pra gerar a primeira versão e diagnosticar erros, mas as decisões de arquitetura e as validações de segurança são suas.

Os posts anteriores sobre o home server que podem dar contexto adicional: