Pular para o conteúdo
← voltar

Tirando a produção de dentro da máquina de dev

O tapout.gg e a Nimbus rodavam no mesmo servidor onde eu deixo dev server ligado. Montei a máquina de produção em NixOS, com um repo pensado pra ser mantido por agentes, e a primeira coisa que os testes acharam foram bugs que o setup antigo escondia.

nix nixos infra claude-code tapout

O app-api.tapout.gg rodava da pasta onde eu programo. A unit do systemd apontava pra ~/robocore/robocore-scraper e subia uv run robocore-api na branch que estivesse checada naquele momento. Se eu fizesse git checkout numa feature pra testar uma coisa, produção passava a servir essa feature no próximo restart. Tinha até um cron que conferia se o checkout estava na main antes de rodar o backfill de resultados, porque alguém (eu) já tinha esquecido uma branch lá.

Esse é o barreto-server. Ele roda o Portless, todo *.barreto.sh que eu abro pra mostrar coisa em andamento, o painel de processos, os containers do simulador de futebol de robô, e no meio disso tudo as APIs do tapout.gg, o SSO, o bolão, o servidor de PeerJS do app de juiz e as três ferramentas da Nimbus (wiki, fórum e o Plane de tarefas). O INFRA.md dessa máquina tem a história de quando isso dá errado. Um pkill -f "robocore-api" pra matar uma API de teste também matou a de produção, porque as duas tinham o mesmo nome de comando. Um SIGTERM externo derrubou o bbb e o bolão por uma hora e quarenta, porque as units estavam com Restart=on-failure, e pro systemd SIGTERM é saída limpa.

Nenhum desses incidentes é difícil de corrigir isolado. O problema é que todos vêm da mesma origem: a máquina onde eu mexo o dia inteiro é a mesma que atende usuário. Então vai ter uma máquina só pra produção.

O que sai e o que fica

A divisão saiu simples. Tudo que tem usuário real vai pra máquina nova, que chamei de barreto-prod: as APIs do tapout (app-api, auth, bbb-api, bet-api, peer) e as três coisas da Nimbus. O barreto-server vira só playground: Portless, *.barreto.sh, plan.barreto.sh, o build de teste do arena em robots.barreto.sh. Esses precisam ficar perto do código porque o ponto deles é mostrar uma coisa dez segundos depois de eu salvar o arquivo.

O caminho de uma requisição muda menos do que parece. Cloudflare continua na frente, um túnel cloudflared continua levando o tráfego até um Caddy na porta 8890, e o Caddy continua decidindo pelo Host pra onde mandar. O que muda é em qual máquina cada hop roda, e de onde vem o processo que responde:

  1. 1
    Cloudflare

    zona tapout.gg, CNAME pro túnel barreto-dev

  2. 2
    cloudflared barreto-devbarreto-server

    systemd --user, o mesmo túnel do *.barreto.sh

  3. 3
    Caddy :8890barreto-server

    sites/tapout-auth.caddy

  4. 4
    tapout-sso.servicebarreto-server

    bun run src/server.ts em ~/tunnel/deploys/tapout-sso, atualizado por git pull

  5. 5
    auth.dbbarreto-server

    ~/tunnel/deploys/tapout-sso-data/

O túnel do tapout vira um túnel novo, só da máquina de produção, e o corte de cada serviço é trocar o CNAME daquele hostname de um túnel pro outro. A Nimbus é um caso diferente porque a zona nimbus.tf mora em outra conta da Cloudflare e o túnel dela roda por token. Lá o conector simplesmente muda de máquina, e por isso as três ferramentas da Nimbus migram juntas: se os dois conectores rodarem ao mesmo tempo a Cloudflare divide o tráfego entre as máquinas e os bancos divergem.

NixOS, sem interface, pelo SSH

Eu já usava Nix pra toolchain de firmware (tem um post sobre o modelo mental e outro sobre o dia a dia), então a escolha do sistema foi a parte fácil. A configuração inteira da máquina mora num flake no repo home-server, e a instalação vai ser nixos-anywhere: boto a ISO mínima do NixOS 26.05, rodo passwd no console pra ligar o SSH, e daqui da outra máquina um comando particiona o disco (via disko), instala, gera o relatório de hardware com o nixos-facter e reinicia no sistema final.

Os segredos ficam em sops-nix, cifrados com age pra duas chaves: a minha e a chave SSH de host da máquina nova. Essa segunda parte tem um detalhe bom. A máquina ainda não existe, mas a chave de host dela já existe: gerei antes, derivei o destinatário age dela com ssh-to-age, e o nixos-anywhere copia a chave privada pra dentro do sistema na instalação com --extra-files. No primeiro boot a máquina já consegue decifrar os próprios segredos, sem passo manual depois.

O deploy é deploy-rs, escolhido por um motivo só: o magic rollback. Ele ativa a geração nova, abre uma conexão SSH nova pra confirmar que ainda consegue entrar, e se não conseguir em 60 segundos volta pra geração anterior sozinho. Numa máquina que vai ser mexida por agente via SSH, a mudança mais perigosa é a que corta o próprio acesso (firewall, sshd, rede), e é exatamente essa que o rollback cobre.

Uma rota, três configurações

A parte do flake de que eu mais gosto é a menor. Cada serviço declara o hostname dele num lugar só:

hs.gateway.routes."bbb-api.tapout.gg" = {
  port = 8702;
  tunnel = "tapout";
};

O módulo do gateway pega todas as rotas e gera três coisas: o site no Caddy, a regra de ingress no túnel do tapout, e uma asserção que falha o build se a rota não tiver nem porta nem config própria. No setup antigo, adicionar um produto era editar um .caddy, editar o config.yml do cloudflared e lembrar de manter o INFRA.md em dia, três arquivos em três lugares que ninguém conferia entre si. Aqui o ingress não tem como esquecer um hostname, porque ele é derivado das mesmas rotas.

Os apps viram pacote

O git pull em pasta de deploy acabou. Cada app é um input do flake apontando pro repo dele, e o commit que roda é o commit que está no flake.lock. Quando travei os inputs pela primeira vez, os quatro bateram exatamente com o que estava rodando no servidor (o 66a3ee0 do SSO, o 3ff8a6c do bbb e assim por diante), o que foi um bom sinal de que o inventário estava certo. O servidor de PeerJS nunca tinha tido repo, era uma pasta solta em ~/tunnel/deploys, e ganhou um (tapout-peer) pra entrar do mesmo jeito.

Os apps em Bun usam bun2nix, que lê o bun.lock e gera um bun.nix com o hash de cada pacote, e o build instala o node_modules offline a partir disso. A API do tapout, que é Python com uv, usa uv2nix em cima do uv.lock. Todos rodam do Nix store, que é só leitura, com ProtectSystem=strict: a única pasta gravável de cada serviço é o /var/lib/<nome> dele. App que tentar gravar do lado do próprio código vai falhar alto em vez de sujar uma pasta de deploy.

E foi aqui que o primeiro teste pagou o próprio custo. O flake tem um teste de VM (runNixOSTest) que sobe a máquina com estado vazio e faz request em cada hostname pelo Caddy. A API do tapout morreu no boot com ModuleNotFoundError: No module named 'robocore_api'. O projeto tem cinco pacotes Python dentro de src/, e o backend de build do uv, por padrão, só empacota o módulo com o nome do projeto. Isso nunca apareceu antes porque uv run instala o projeto em modo editável, e aí o src/ inteiro fica no path. O bug estava lá desde sempre, escondido pelo jeito de rodar. A correção foi listar os módulos no module-name do uv_build durante o build do Nix.

Nimbus em container

Wiki.js, NodeBB e Plane continuam em Docker, com as imagens fixadas pelo digest exato que rodava no servidor antigo (uma tag como ghcr.io/requarks/wiki:2 é um upgrade silencioso esperando acontecer). Wiki e fórum passam pelo compose2nix, que transforma o compose em units do systemd, uma por container, com os segredos entrando como arquivo de ambiente gerado pelo sops.

O Plane ficou de fora da conversão de propósito. São treze containers e umas sessenta variáveis de ambiente, várias com nome diferente entre o .env e o que o container lê, e o upstream distribui cada versão como um docker-compose.yml novo. Converter isso significaria portar à mão a cada upgrade. Ele roda com o compose original do upstream, um override nosso por cima, e uma unit do systemd chamando docker compose up. Isso está escrito num ADR no repo, junto com o motivo, pra nenhum agente “consertar” depois.

Na primeira versão, o tema, o plugin do fórum, as views remendadas da wiki e o compose do Plane moravam numa pasta nimbus/ dentro do repo do servidor. Olhando o repo, fiquei na dúvida se cada post escrito no fórum ia acabar no meu git. Não ia: post, upload e página da wiki ficam no Postgres, nos volumes e no repo git da própria wiki, todos no servidor. Mas a pergunta apontava pra uma coisa certa, que é misturar o que muda por motivo da Nimbus com o que muda por motivo do servidor. A pasta virou o repo nimbus-ops (com o histórico, via git subtree split) e entra no flake como input, travado no flake.lock igual aos apps. Submódulo era a outra opção, mas flake lida mal com eles e agente erra o fluxo de git submodule update com frequência.

Um repo pra ser mantido por agente

Quem vai mexer nesse repo no dia a dia são agentes, então ele foi montado pensando em quem chega sem contexto. O AGENTS.md é o contrato: layout, os comandos que verificam uma mudança, as portas, onde cada estado mora, e uma lista curta de regras. O CLAUDE.md só importa ele com @AGENTS.md e adiciona o que é específico do Claude Code. Os procedimentos viraram skills (/deploying, /updating-an-app, /adding-a-service, /cutting-over), e as que mexem em produção têm disable-model-invocation: true: o modelo não chama sozinho, só quando eu peço. Tem também um subagente verifier read-only, que revisa a mudança num contexto limpo antes dela ser dada como pronta.

O que dá mais segurança são os hooks, porque instrução em markdown é conselho e hook é regra. Um PreToolUse olha cada comando de shell: decifrar secrets/prod.yaml é negado, deploy, migração com --execute e qualquer systemctl --user stop no servidor antigo pedem minha aprovação. Ele tem uma bateria de 49 casos de teste que roda como check do flake, e se o próprio hook quebrar ele bloqueia em vez de deixar passar.

Os segredos ficaram fora do agente de propósito. O próprio Claude Code que montou o repo não leu nenhum valor secreto: dos .env ele leu o nome de todas as variáveis e o valor só das que não são segredo (porta, URL, ID de admin), todo segredo no prod.yaml começou com o valor UNSET, e o script que copia os valores reais é executado por mim. Quando o agente tentou ler a config do cloudflared do jeito ingênuo, um hook global que eu já tinha bloqueou na hora.

Rodadas de /improve

Com a primeira versão de pé, rodei uma auditoria com a skill /improve: dois agentes lendo o repo em paralelo, um caçando bug nos módulos e outro olhando práticas, testes e documentação contra a doc oficial atual. Os achados viraram seis planos autocontidos, cada um executado por um agente mais barato (Sonnet) num git worktree isolado, e eu revisando o diff de cada um antes de entrar.

Os achados mais interessantes eram todos do tipo que não aparece em lugar nenhum até o dia em que dá errado:

  • O compose2nix gera os containers com upheldBy no banco, na rede e no volume. Isso quer dizer que o systemd religa o container do app sempre que ele está parado, inclusive logo depois de um systemctl stop manual. O script de migração parava o Wiki.js pra restaurar o Postgres, e o Wiki.js ia voltar no meio do pg_restore. A migração agora segura os apps com systemctl mask --runtime.
  • O módulo do Caddy no nixpkgs vem com RestartPreventExitStatus=1 e um limite de 10 reinícios em 4 horas. O meu Restart=always por cima não garantia nada: uma falha de startup com código 1 deixaria todos os sites fora do ar sem retry.
  • A migração do banco do tapout trocava o arquivo SQLite com o coletor do Challonge ainda escrevendo nele a cada 60 segundos.
  • E a mais perigosa, que era minha: o container onde o Nix roda tinha a chave age de admin montada, e o agente tinha permissão pra rodar qualquer nix eval nele sem pedir. Um builtins.readFile bastava pra imprimir a chave que decifra tudo. Agora são dois containers: hs-nix-agent, sem segredo nenhum, onde os agentes fazem build e teste, e hs-nix-admin, com as chaves, que só eu uso.

Depois de cada rodada, os checks também cresceram: um que monta a configuração com o ID do túnel preenchido (até ali esse caminho nunca tinha sido avaliado, e a primeira vez seria no meio da migração), um que falha se alguma imagem não estiver fixada por digest, um que roda o script de migração em modo de plano e confere se cada unit que ele cita existe no sistema, e o gitleaks.

Como está agora

A máquina ainda não chegou. O repo monta o sistema inteiro dela, com mais ou menos 1.900 linhas de Nix escritas à mão e o resto gerado, passa nos checks e no teste de VM. O CI no GitHub já roda formatação e higiene de segredos a cada push, e roda o resto (incluindo o teste de VM com KVM) assim que tiver um token pra ler os repos privados dos apps. Nada foi migrado e nada no barreto-server foi desligado ou apagado: os serviços antigos continuam atendendo até cada corte ser feito, um por vez, com aprovação.

O que depende de mim está numa tabela no docs/STATUS.md: importar os segredos, criar o token de leitura pros repos privados no CI, criar o túnel novo do tapout, descobrir o nome do disco quando a máquina estiver na minha frente, e decidir se o login pela Tailscale fica ligado. Esse último apareceu na auditoria também: o --ssh da Tailscale passa por fora do sshd, então quem decide quem entra é a ACL da tailnet, que não está no repo.

Da próxima vez que eu fizer git checkout numa branch qualquer no barreto-server, a pior coisa que vai acontecer é um *.barreto.sh mostrar a branch errada pra mim mesmo. A produção vai estar rodando o commit que está no flake.lock, de um store que ninguém consegue editar, numa máquina onde eu não deixo dev server ligado.