NickaelDeveloper & Product Builder

Duas instâncias Appwrite numa VPS só

Operar duas instalações completas e independentes do Appwrite 2.0 numa única máquina atrás de um Traefik externo — e a colisão de hostname no Docker que exigiu desmontar nove camadas para encontrar.

9 min de leitura

Eu precisava de mais de um backend. Produtos separados, dados separados, raio de dano separado — um erro num deles não pode alcançar o outro. As respostas óbvias eram uma segunda VPS ou migrar para Kubernetes. As duas resolvem isolamento gastando: uma em custo mensal, a outra em complexidade operacional que eu carregaria para sempre.

Então fiz uma pergunta mais estreita: duas instalações completas do Appwrite podem dividir uma máquina sem dividir nada que importe? A resposta acabou sendo sim — mas a dificuldade não tem nada a ver com o Appwrite, e tudo a ver com o que duas stacks idênticas fazem uma com a outra dentro de um mesmo daemon Docker.

A decisão que moldou todo o resto

O Appwrite vem com o próprio Traefik. Minha VPS já tinha um, servindo todo o resto da máquina. Dois reverse proxies querendo as portas 80 e 443 é um conflito que precisa ser resolvido de propósito, e havia dois caminhos.

  • Remover o Traefik interno do Appwrite e religar toda a stack ao externo.
  • Manter o Appwrite próximo da configuração oficial e colocar o Traefik externo na frente dele.

Escolhi o segundo. O Traefik interno do Appwrite não é decoração — ele roteia console, API, Sites, Functions, previews e custom domains. Arrancá-lo significa assumir cada uma dessas regras de roteamento para sempre, a cada upgrade. Em vez disso, o proxy externo virou uma borda pura: entrada pública, TLS e a qual stack um hostname pertence. Tudo abaixo do hostname continuou sendo problema do Appwrite.

                    Internet
                       │
                ┌──────▼──────┐
                │   Traefik   │  :80 / :443
                │   externo   │  TLS · DNS-01 · wildcards
                └──────┬──────┘
              ┌────────┴────────┐
              ▼                 ▼
    127.0.0.1:PORT_A   127.0.0.1:PORT_B
              │                 │
     ┌────────▼───────┐ ┌───────▼────────┐
     │ Traefik interno│ │ Traefik interno│
     │   Appwrite A   │ │   Appwrite B   │
     └────────┬───────┘ └───────┬────────┘
       API · Sites · Fn   API · Sites · Fn
Uma borda pública; cada instalação mantém router interno, banco, Redis, workers, volumes e redes próprios.

O que colocar algo na frente do Appwrite exige de verdade

O router interno escolhe a regra a partir do hostname original. Se o proxy externo encaminha sem preservar o header Host, o Traefik interno enxerga o endereço de loopback em vez do nome real — e Sites, Functions e custom domains silenciosamente deixam de resolver. Uma flag decide se o arranjo inteiro funciona.

loadBalancer:
  passHostHeader: true     # sem isso, o Traefik interno vê Host: 127.0.0.1
  servers:
    - url: "https://127.0.0.1:<PORT>"

Os Traefiks internos então escutam apenas no loopback, de modo que nada além do proxy externo os alcança. O ACME próprio do Appwrite fica desligado — certificados são emitidos uma vez, na borda, via DNS-01, o que também contorna inteiramente o desafio HTTP-01 e permite que um wildcard cubra um namespace inteiro.

O bug que exigiu nove camadas para ser encontrado

A segunda instalação subiu. Um Site de teste foi deployado, buildou, e então não fez nada. O DNS resolvia, o TLS completava, a requisição saía — e zero bytes voltavam até o timeout. Nenhum log dizia o porquê.

Um timeout é a falha menos informativa que existe, então o primeiro movimento foi convertê-lo numa mais específica. Requisitei um hostname que não existia:

random.sites.example-b.com   →  rule_not_found   (rápido)
hello.sites.example-b.com    →  timeout          (trava)

Essa única comparação eliminou quatro camadas de uma vez. Um erro de aplicação provou que a requisição chegava ao Appwrite, o que significa que DNS, TLS, router externo e a regra wildcard estavam todos corretos. A falha só aparecia quando um Site real tentava alcançar um runtime — ou seja, o problema morava depois de tudo que eu tinha construído e dentro da parte que eu não tinha.

Dali desci camada a camada. O container de runtime era criado sob demanda. Os logs dele mostravam o servidor Next.js iniciando. Alcançá-lo direto, sem credencial, devolvia um erro de autorização — prova de que o processo estava vivo e escutando. Alcançá-lo com o secret correto devolvia o HTML completo.

artifact ✅   Open Runtimes ✅   Next.js ✅
runtime  ✅   porta         ✅   conteúdo ✅

Tudo funcionava. A aplicação estava certa, o build estava certo, o conteúdo estava certo. Então parei de testar a partir do host e testei de dentro do container do Executor — o único componente que de fato precisa falar com o runtime em produção.

host     → runtime    ✅ funciona
executor → runtime    ❌ timeout

Esse foi o ponto de virada. Não era aplicação, nem Appwrite, nem Next.js, nem Traefik, nem TLS. Era networking do Docker.

Causa raiz: uma colisão de hostname

O Executor secundário não tinha nenhuma rede em comum com os próprios runtimes. Inspecionar a primeira instalação explicou o motivo — a rede de runtimes da segunda instalação tinha sido anexada ao Executor da primeira.

Executor A                    Executor B
├─ appwrite-primary           └─ appwrite-secondary
├─ runtimes-primary              (faltava runtimes-secondary)
└─ runtimes-secondary  ← errado

Eu tinha dado à segunda stack um container_name exclusivo e presumi que bastava. Não bastava. O container manteve o hostname padrão, e o Executor se conecta à própria rede de runtimes procurando a si mesmo por esse hostname. Dois containers respondiam pelo mesmo nome no mesmo daemon, e a busca encontrou o errado — o Executor da primeira instalação, que obedientemente entrou numa rede que não era dele.

Dar um hostname exclusivo ao Executor secundário corrigiu o caminho Executor→runtime — e imediatamente quebrou Appwrite→Executor, porque a plataforma ainda estava configurada para alcançar o nome antigo. O sintoma mudou de timeout para um 500 rápido.

Apontar a variável de host do executor para o nome novo, e recriar apenas os serviços que a leem, fechou o ciclo. As duas instalações servem desde então.

O que generaliza

  • Teste de onde a produção realmente chama. Do host o runtime era alcançável; do Executor não era. Só o segundo teste descrevia a realidade.
  • Não faça redeploy sem evidência. O build já tinha se provado de ponta a ponta — um deploy novo não corrigiria um problema de rede, e só adicionaria variáveis.
  • Confie no estado atual, não em logs antigos. Um log ACME mostrando falha não dizia nada sobre o certificado de fato instalado; o certificado servido é a fonte da verdade.
  • Isolamento é por identificador, não por instalação. Nomes de container, hostnames, redes, volumes e segredos internos precisam, cada um, ser tornados únicos de propósito.

O resultado são duas instalações completas do Appwrite — dois bancos, dois Redis, dois Executors, duas redes de runtime, dois conjuntos de Sites e Functions — coexistindo numa máquina, sem Kubernetes e sem bifurcar a stack oficial. Este site roda nela.