Runbook — o The Band em produção (Contabo + Dokploy)¶
Feature 050 · spec · contrato do pipeline
Escrito para UMA pessoa executar sem esta sessão aberta. Os passos marcados [MARCO — pessoa] são atos que só quem administra faz; todo o resto ou já está no repositório ou é o CD fazendo sozinho. Nenhum segredo aparece neste documento, no repositório ou em chat — nunca.
§1 [MARCO — pessoa] O VPS e o Dokploy¶
- Criar o VPS na Contabo — 8 GB de RAM cobrem painel + app + Postgres na escala de hoje; Ubuntu LTS; chave SSH própria (não senha).
- Instalar o Dokploy (site oficial:
curl -sSL https://dokploy.com/install.sh | sh— conferir o script antes de rodar, como qualquer curl-pipe). - Acessar o painel, criar a conta de administração do PAINEL (não confundir com as contas da plataforma), e anotar o endereço — sem domínio próprio por ora, o domínio gerado do Traefik serve (FR-013; HTTPS incluso).
- Atualizações do próprio Dokploy e snapshots da Contabo ligados — o Dokploy também é infraestrutura (spec: quem opera é uma pessoa; o snapshot cobre o painel e a config dele).
§2 [MARCO — pessoa] Os segredos — a lista é FECHADA (contrato)¶
| Onde | Chave | De onde vem |
|---|---|---|
| GitHub → Settings → Secrets → Actions | DOKPLOY_WEBHOOK_URL |
o webhook de deploy que o §3 gera |
| Painel do Dokploy (env do app) | DATABASE_URL |
o §4 gera ao criar o banco |
| Painel do Dokploy | SECRET_KEY_BASE |
mix phx.gen.secret local, colar direto |
| Painel do Dokploy | THE_BAND_MASTER_KEY |
mix the_band.gen_key — chave NOVA de produção, nunca a de dev |
| Painel do Dokploy | PHX_HOST |
o host do §1.3 |
| Painel do Dokploy (registry) | credencial read:packages |
SÓ se o pacote ghcr for privado |
Nada além disto. Chave que vazar se ROTACIONA (mix the_band.rotate_key para a
mestra), nunca se "monitora".
§3 [MARCO — pessoa] O app no Dokploy¶
- Criar Application → Docker Image →
ghcr.io/the-band-solution/theband:latest. - Auto-deploy on push: DESLIGADO (FR-015 — quem deploya é o CD, depois dos gates; o Dokploy só obedece ao webhook).
- Copiar o webhook de deploy do app → é o valor de
DOKPLOY_WEBHOOK_URL(§2). - Porta interna 4000; domínio do §1.3 apontando para ela; HTTPS automático.
- Env vars do §2.
§4 [MARCO — pessoa] O banco e o backup¶
- Criar Database → PostgreSQL 16 no Dokploy; a URL interna gerada é o
DATABASE_URLdo app (§2). - Backup agendado DIÁRIO no Dokploy para um destino S3-compatível (fora da máquina — FR-007/FR-014); a falha do job aparece no painel, e o §6 diz como conferi-la por rotina.
- Snapshot da Contabo ligado (§1.4) — a segunda camada.
§5 Rollback¶
O Dokploy guarda o histórico de imagens: reimplantar a versão anterior é apontar o
app para ghcr.io/...:vX.Y.(Z-1) e reimplantar — as migrações são só-acréscimo por
princípio da casa (nunca se apaga dado), então a versão anterior sobe sobre o
esquema novo. Migração que quebre isso é decisão de ADR ANTES do release, nunca
descoberta no rollback.
§6 O ensaio de restauração — ANTES de dado real (FR-008/SC-003)¶
O backup só existe depois de restaurado uma vez. O ensaio:
- Anotar TRÊS números da produção AINDA sem dado real (pós-seed): pessoas, issues,
organizações — de
/peoplee/organizations. - Baixar o backup mais recente do destino S3 (o arquivo
.sql/.dumpdo job). - Criar um banco VAZIO no Dokploy (
band_ensaio), restaurar nele (pg_restore/psql < dump), apontar uma SEGUNDA aplicação temporária no Dokploy para ele (mesma imagem,DATABASE_URLdo ensaio). - Conferir os três números NA TELA da instância de ensaio — bateram, o ensaio
passou; anotar data e números em
docs/releases/junto do release. - Derrubar a instância e o banco de ensaio.
Falhou qualquer passo: o backup NÃO existe de verdade — resolver antes de qualquer release com dado real.
Numa restauração DE VERDADE, e não no ensaio, o último passo é o §10: encerrar todas as sessões. É obrigatório (feature 064, decisão P5), e o §10.1 diz por quê. Repetir o ensaio a cada mudança no desenho do backup, e no mínimo uma vez por mês (SC-006: a rotina roda 7 dias e a mais antiga restaura).
Dry-run local (sem VPS — o teste da T006)¶
docker exec 3d665aae71e6 pg_dump -U postgres -d the_band_dev > /tmp/ensaio.sql
docker exec 3d665aae71e6 psql -U postgres -c "CREATE DATABASE band_ensaio"
docker exec -i 3d665aae71e6 psql -U postgres -d band_ensaio < /tmp/ensaio.sql
# subir o contêiner da 050 contra band_ensaio e conferir /people
§7 [MARCO — Product Owner] O primeiro release¶
- Aceitação do sprint confirmada; versão decidida (semver sobre ACEITOS — FR-016).
- PR de release
development → maincom o bump nomix.exs(chore: release vX.Y.Z) e o corpo apontando o registro de aceitação. - Merge = deploy: o CD builda, publica
vX.Y.Zno ghcr, cria a tag e chama o webhook. Acompanhar o workflow — cada passo diz seu veredito no log (L60). - Medir e registrar em
docs/releases/vX.Y.Z.md: SC-001 (entrar e ver painel em <2min), SC-002 (<15min procedimento, <2min indisponibilidade), SC-004 (varredura de segredos em imagem —docker history— e logs), SC-005 (a varredura das 27 rotas da 045 contra o endereço real). - O ensaio do §6 com dado real entra no calendário do mês.
§8 A primeira conta — feature 052¶
A plataforma sobe com o banco vazio, e o seeds.exs levanta em produção de
propósito: senha padrão conhecida seria a porta aberta que a 045 existe para
fechar. Sem este passo, ninguém entra.
- No painel, na aba de ambiente da aplicação, acrescentar às que já existem:
| variável | o quê |
|---|---|
THE_BAND_TENANT_NOME |
nome legível da organização |
THE_BAND_TENANT_SLUG |
identificador estável — minúsculas, números e hífen |
THE_BAND_ADMIN_EMAIL |
e-mail de entrada |
THE_BAND_ADMIN_SENHA |
senha escolhida na hora, no mínimo 12 caracteres |
THE_BAND_ADMIN_NOME |
opcional — sem ele a pessoa preenche em /profile |
- Implantar. No log do contêiner, uma destas quatro linhas:
| o que aparece | o que significa |
|---|---|
primeira conta criada: <email>, admin de <slug>. |
deu certo |
já existe administrador — nada a criar. |
a instalação já tinha conta |
sem <VAR>, <VAR> — nenhuma conta criada. |
variável faltando, e a linha diz quais |
primeira conta recusada: <campo> <motivo>. |
valor inválido, e a linha diz qual regra |
Nos quatro casos a plataforma sobe. Ao contrário de DATABASE_URL, cuja
ausência derruba, aqui a falta não impede: sem banco, subir significaria
servir zero em toda tela; sem primeira conta, a plataforma está correta e
apenas vazia.
-
Entrar pela tela de entrada com esse e-mail e essa senha.
-
REMOVER
THE_BAND_ADMIN_SENHAdo painel.
Enquanto a variável existir, a senha do primeiro administrador é legível por quem tem acesso ao painel. Esse é o custo declarado da escolha por variáveis de ambiente, e não um descuido — uma tela de instalação levaria a senha do teclado ao hash sem parada intermediária, e foi descartada por simplicidade.
Removê-la não afeta nada: no boot seguinte o log dirá já existe
administrador. E trocar a senha pela interface depois disso vale para sempre
— reiniciar não a sobrescreve.
- As contas seguintes nascem em
/accounts, com senha temporária gerada por quem administra. Este passo é só para a primeira.
§9 [MARCO — pessoa] O domínio próprio — feature 054¶
A ordem é a parte que importa. Cada inversão produz um sintoma diferente, e nenhum deles nomeia a causa. Está escrito na ordem em que precisa acontecer.
Antes de tudo: o repositório pronto¶
THE_BAND_ORIGENS_EXTRAS já precisa existir na versão que está em produção.
Publicar o nome novo com uma versão que não conhece a variável entrega um
endereço que responde 200 e não é interativo — as telas não atualizam, e não há
erro visível. É a P1 da 050, e é o defeito que esta feature existe para não
introduzir.
Conferir antes de mexer no DNS:
1. O DNS aponta — SEM o intermediário na frente¶
No provedor do domínio, um registro de endereço:
| Nome | Aponta para | Observação |
|---|---|---|
app.theband.dev |
o IP da produção | com o intermediário desligado — no Cloudflare, a nuvem cinza |
Não toque no apex nem no www. theband.dev serve o site público, hoje
no GitHub Pages (185.199.108.153 e irmãos). Apontá-lo para a produção tiraria o
site do ar; adicioná-lo à aplicação no painel faria os dois serviços disputarem o
mesmo certificado.
Se inverter: quem emite o certificado precisa provar que controla o nome, e a prova chega por requisição ao próprio endereço. Com o intermediário na frente, ela pode nunca chegar — e o sintoma é um certificado que não sai, sem dizer por quê.
2. O nome entra no painel, e o certificado é emitido¶
No painel de quem hospeda, na aplicação: adicionar app.theband.dev — e só
ele — apontando para a porta da aplicação (4000), com HTTPS por Let's Encrypt.
Esperar o certificado.
⚠️ Em 2026-09-01 este passo NÃO funcionou, e o caminho foi outro. O que se mediu: DNS resolvendo direto na origem, rota existindo (
Host: app.theband.dev→ 301 para o host certo), aplicação respondendo 200 por trás do certificado errado, caminho do desafio ACME aberto (404 do handler, não redirecionamento), nenhum CAA no domínio, e o campoCertificatedo domínio já emLet's Encrypt. Mesmo assim a origem seguiu servindoCN=TRAEFIK DEFAULT CERT.A causa não foi diagnosticada — o log do Traefik não chegou a ser lido. Está registrado como o que é: passo que falhou sem explicação, e não passo que funciona. Quem retomar começa por
dokploy-traefik→ Logs, filtrandoacme.O caminho usado no lugar está no §9-B, adiante.
.dev não tem plano B. O TLD está na lista de pré-carregamento de HSTS dos
navegadores: sem certificado válido, o navegador recusa antes de qualquer
requisição sair. Não existe "abre inseguro e a pessoa prossegue" — existe
inalcançável. Não divulgue o nome antes deste passo terminar.
Conferir:
curl -s -o /dev/null -w '%{http_code}\n' https://app.theband.dev/sign-in # 200
curl -s -o /dev/null -D - http://app.theband.dev/sign-in | head -2 # 301 → https
3. As duas origens são declaradas — ANTES de trocar o PHX_HOST¶
No painel, nas variáveis da aplicação:
Nessa ordem, e no mesmo deploy. THE_BAND_ORIGENS_EXTRAS é por onde as
pessoas chegam; PHX_HOST é o endereço que a plataforma escreve nos links.
Trocar só o segundo deixa o endereço antigo sem conexão viva — que é o mesmo
defeito, na direção contrária.
Deixar a medida contínua rodando durante o deploy, para provar o SC-004:
while true; do
printf '%s %s\n' "$(date +%H:%M:%S)" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
https://theband.5.189.161.85.sslip.io/sign-in)"
sleep 5
done
4. O intermediário entra — e a cifra vai até a aplicação¶
Só agora ligar o proxy (nuvem laranja), com:
- modo de cifra: o que cifra também entre o intermediário e a aplicação e
valida o certificado. No Cloudflare,
Full (strict); - conexões vivas (WebSockets): ligadas.
Se escolher o modo que cifra só até o intermediário: a aplicação exige cifra
(force_ssl em config/prod.exs), o intermediário responde que já cifrou, e
nasce um laço de redirecionamento — a página nunca carrega, e o erro não
nomeia a causa.
Se as conexões vivas estiverem desligadas: HTTP responde 200 e nenhuma tela atualiza. O mesmo sintoma da P1, com origem diferente — por isso o passo 5 existe mesmo quando o passo 2 passou.
5. A conferência que decide: o socket, nos dois endereços¶
bash scripts/medir-enderecos.sh https://app.theband.dev
bash scripts/medir-enderecos.sh https://theband.5.189.161.85.sslip.io
Os dois têm de passar. Um 200 no HTTP não é evidência de nada aqui: o
defeito desta feature produz exatamente um 200 com o socket recusado (L85). O
script imprime a leitura dos códigos — 403 é recusa de origem, 400 é o
handshake incompleto do curl com a origem aceita.
§9-B — O caminho alternativo: certificado de origem do intermediário¶
Foi por aqui que a produção subiu em 2026-09-01, depois de o Let's Encrypt não emitir. Vale como alternativa permanente, e não só como remendo — com uma diferença que precisa estar escrita: a renovação deixa de ser automática.
- Cloudflare → SSL/TLS → Origin Server → Create Certificate. Hostnames
*.theband.devetheband.dev— o curinga de um nível cobreapp. Validade padrão de 15 anos; - a chave privada aparece uma vez só. Vai do painel do Cloudflare direto para o do Dokploy: nunca por chat, nunca por arquivo do repositório (FR-011);
- Dokploy → Certificates → certificado customizado, colando certificado e
chave. Depois, Domains →
app.theband.dev→ campoCertificateapontando para ele; - Cloudflare → DNS → registro
app→ Proxied (laranja); - Cloudflare → SSL/TLS → Overview → modo
Full (strict). Na interface nova: Configure → Custom SSL/TLS →Full (strict); - Network → WebSockets: ON.
Quem apresenta o certificado ao navegador passa a ser o Cloudflare, com o
Universal SSL dele — medido em 2026-09-01: CN=theband.dev, emissor
Google Trust Services. O certificado de origem cifra e autentica o trecho
Cloudflare↔servidor.
Não use
Fullsem o(strict). Ele aceita qualquer certificado na origem, inclusive autoassinado: o trecho fica cifrado e não autenticado, e um intermediário ali passa despercebido. Com o certificado de origem instalado,Full (strict)funciona de primeira — não há motivo para o modo fraco.
O que este caminho deixa em aberto: a renovação. O certificado do Let's
Encrypt renovava sozinho; este vence em 15 anos e ninguém será lembrado. Quando o
Let's Encrypt voltar a funcionar, trocar o Certificate do domínio de volta e
seguir em Full (strict).
6. A pendência é encerrada¶
Com os dois endereços medidos, marcar a P1 da 050 como encerrada em
specs/050-em-producao/pendencias.md, com a data e o que a substituiu.
§10 As sessões — o que uma restauração faz com elas, e como encerrar todas¶
Feature 064 (US2, T015 e T016). Desde a T013, cada entrada abre uma linha em
user_sessions, e o banco guarda só o resumo do token. O token que abre a sessão
vive só no cookie do navegador.
§10.1 O que uma restauração faz com as sessões¶
Restaurar um backup devolve as linhas de sessão do instante da cópia. São três casos:
| a sessão foi… | depois de restaurar | por quê |
|---|---|---|
| aberta depois da cópia | cai, e a pessoa entra de novo | a linha não existe no banco restaurado |
| aberta antes da cópia, e ainda dentro dos 7 dias | continua valendo | a linha voltou igual |
| encerrada entre a cópia e o desastre | VOLTA A VALER | a linha voltou sem o ended_at |
O terceiro caso é o que surpreende, e é o perigoso. As sessões encerradas nesse intervalo incluem as encerradas por segurança: alguém que saiu de um computador compartilhado, uma senha trocada por suspeita, uma conta desativada. A restauração as reabre sem ninguém ter pedido.
O mesmo vale para a senha. Uma senha trocada depois da cópia volta a ser a
antiga, porque o password_hash e a época também voltam.
É por isso que o passo abaixo é obrigatório depois de toda restauração, e não recomendado.
§10.2 Encerrar todas as sessões¶
Isto encerra a sessão de TODO MUNDO, em TODAS as organizações, inclusive a de quem roda o comando. Todas as pessoas precisam entrar de novo. Não há como escolher quem fica.
No contêiner do app, pelo Dokploy (Advanced → Terminal, ou docker exec no
contêiner da aplicação):
A saída diz quantas sessões encerrou, e só o número:
O comando escreve ended_at em toda sessão aberta e não apaga nada. As linhas
saem sozinhas 90 dias depois, pela retenção (T020). Rodar duas vezes não faz mal: a
segunda encerra zero.
Quando rodar:
| situação | obrigatório? |
|---|---|
| depois de restaurar um backup | sim (§10.1, decisão P5) |
suspeita de que o SECRET_KEY_BASE vazou |
recomendado. Trocar a chave (§2) é o que protege: todo cookie assinado com a antiga deixa de valer. A chave sozinha não abre sessão, porque o banco não tem o token bruto. Encerrar registra a queda em ended_at |
a varredura de segredos (mix the_band.varre_segredos, FR-010) achou um token de sessão |
sim |
| suspeita de sessão roubada, sem saber de quem | sim |
| uma conta só | não use isto. Desativar a conta (/accounts) encerra as sessões dela, e reativar não as devolve |
Como conferir que funcionou: abra a plataforma num navegador que estava logado.
Ele precisa cair em /sign-in.
§10.3 O que isto não faz¶
- Não derruba um LiveView já conectado até a próxima navegação (achado S14, baixo). A primeira troca de tela, ou o recarregamento, já cai.
- Não troca a chave. Se a suspeita é sobre o
SECRET_KEY_BASE, trocá-la é um passo separado, no §2.