Um workflow do ComfyUI como serviço público, sem expor o ComfyUI
Autora: Anastasiia Butova, engenheira de ComfyUI e modelos de difusão, Belgrado · publicado em 2026-09-23
Tradução da versão em inglês: English
O estúdio de carros em /mashinki/ deixa qualquer pessoa colocar um carro num showroom, apertar renderizar e receber de volta um quadro do pipeline real: FLUX.2 Klein 9B com nossa LoRA de restauração de geometria, na máquina GPU do mashinki, em 30 a 60 segundos. O ComfyUI que faz a renderização não é publicado na internet de forma alguma. Entre os dois fica um pequeno serviço em Python, e este post é sobre o que ele aceita, o que recusa e o túnel que não funcionou no primeiro lugar em que o pus.
Todo pipeline de ComfyUI em produção que construí roda assim: o grafo é enviado por código, não clicado no editor (mais sobre esse tipo de trabalho em /comfyui/). Normalmente quem chama é um backend. Aqui quem chama é qualquer pessoa com um navegador, o que deixa todas as perguntas de projeto mais agudas.
Por que o /prompt nunca fica exposto à internet
A API HTTP do ComfyUI é feita para quem é dono da máquina. POST /prompt recebe um grafo inteiro em formato de API e o executa. Um grafo é um programa: um nó de carregamento recebe um nome de arquivo, um nó de salvamento escreve um arquivo, e cada nó customizado instalado no servidor é mais uma função que quem chama pode invocar com os argumentos que quiser. Então, quem consegue alcançar /prompt pode carregar qualquer modelo da máquina, ler o que o processo do ComfyUI conseguir ler e escrever onde ele conseguir escrever. Não há campo naquela requisição que você possa validar, porque a requisição é o código.
Para mim, isso não é teórico. Em fevereiro de 2026, um contêiner do ComfyUI num servidor de GPU com que eu trabalhava estava minerando Monero. A porta de API estava aberta à internet sem autenticação, alguém mandou um workflow com um nó de eval de um pacote de terceiros, e o Python dessa pessoa reescreveu o próprio execution.py do ComfyUI. Eu escrevi o runbook de limpeza, e a história completa está no post sobre nós customizados em produção. Em abril, a Censys descreveu uma campanha contra ComfyUI expostos à internet, da qual ainda encontra mais de mil instâncias, e o mesmo nó está na lista dela.
Então o ComfyUI da máquina do mashinki não é publicado em lugar nenhum. O serviço o alcança na própria máquina, em 127.0.0.1, e o público fala apenas com o serviço, que não tem como expressar um grafo.

Seis valores, e o grafo é montado do nosso lado
O botão de renderizar envia POST /compose com cinco campos. O serviço conhece seis, porque um salão e o seu próprio fundo são alternativas. Cada um é limitado antes de tocar em qualquer coisa:
| campo | aceito |
|---|---|
car |
um dos quatro ids, usado como chave de dicionário |
scene |
um dos sete ids de salão, usado como chave de dicionário; obrigatório a menos que bg seja enviado |
x |
número, limitado a 12–88 (por cento da largura do quadro) |
y |
número, limitado a 40–96 (a linha do chão, por cento da altura) |
scale |
número, limitado a 34–94 (largura do carro, por cento do quadro) |
bg |
imagem still opcional em base64, veja abaixo |
Um carro ou salão desconhecido, um JSON que não faz parse, ou uma coordenada que não é número recebe um 400. Campos que o serviço não conhece nunca são lidos. A checagem é tão simples quanto parece; este é o código de produção:
car = str(spec.get("car", ""))
scene = str(spec.get("scene", ""))
bg = spec.get("bg")
if bg is not None and not isinstance(bg, str):
return self._json(400, {"error": "bg must be a base64 string"})
if car not in CARS:
return self._json(400, {"error": "unknown car"})
# A hall is required only when the caller did not bring their own plate.
if not bg and scene not in SCENES:
return self._json(400, {"error": "unknown scene"})
try:
x = float(spec.get("x", 50)); y = float(spec.get("y", 82)); sc = float(spec.get("scale", 62))
except (TypeError, ValueError):
return self._json(400, {"error": "x, y and scale must be numbers"})
x = max(12.0, min(88.0, x)); y = max(40.0, min(96.0, y)); sc = max(34.0, min(94.0, sc))
O grafo é um dicionário Python dentro do serviço, e todos os seus valores, menos um, vêm da produção, e não de mim. Os arquivos de modelo, a LoRA no passo 2.000, 6 passos, o sampler euler com o scheduler beta e o prompt de produção, palavra por palavra, foram copiados em 28 de agosto do contêiner do despachante em produção, e o código diz de qual arquivo veio cada um. A influência de quem chama sobre esse grafo é pequena e indireta: o composite que ele carrega é um arquivo que o serviço escreveu sob um nome que ele mesmo gerou, e o tipo de carroceria e as dimensões no prompt vêm de uma tabela de quatro entradas, os valores que nosso serviço de identidade de carros devolveu para esses quatro quadros. A semente também é nossa.
Uma diferença em relação à produção é deliberada. A produção roda o adaptador em 0,7. No site eu rodo em 1,0, porque um quadro de demonstração pode forçar mais do que o lote de uma concessionária, e a página mostra esse número enquanto o job renderiza.
Recusar cedo, decodificar tarde
A maior parte do raciocínio foi na ordem das checagens. Elas rodam da mais barata para a mais cara:
Content-Lengthacima de 8 MB: 413, sem nem parsear o corpo.- Um endereço que já gastou a sua hora e envia mais de 2 KB: 429, também sem parsear o corpo. Esta checagem não consome nada.
- Parsear o JSON e validar os campos.
- O orçamento: oito jobs por endereço por hora, ou 429. O endereço é o que a Cloudflare passa em
CF-Connecting-IP. Um job é contado aqui, então um 503 na etapa seguinte ainda consome um dos oito. - A fila: um job roda e no máximo três esperam, ou 503.
- Só agora, se um fundo foi enviado, decodificá-lo.
Uma imagem é a coisa mais cara que o serviço faz por um desconhecido, e não devemos esse trabalho a alguém que já íamos recusar de qualquer forma.
O 413 me ensinou algo que eu não esperava. Se o serviço responde antes de o cliente terminar de enviar o upload, o upload é resetado e o túnel na frente responde com seu próprio 502. Medido: um corpo de 20 MB recebeu um 502 da Cloudflare, e não o nosso 413, então quem enviou viu um gateway quebrado em vez do motivo. Agora o serviço lê o corpo e o descarta em pedaços de 64 KB, até 64 MB, e então responde com o seu próprio 413; para qualquer coisa declarada maior, ele para em 64 MB, responde e fecha a conexão. Um timeout de socket de 20 segundos derruba quem fica em silêncio no meio do upload.
A única coisa que um cliente pode enviar
Um visitante pode trazer uma foto do próprio salão. Ela chega em base64 no mesmo JSON e nunca é repassada como chegou. Trecho de save_plate, com três comentários curtos meus acrescentados:
im = Image.open(io.BytesIO(raw)) # reads the header, not the pixels
if im.format not in PLATE_FORMATS: # JPEG, PNG, WEBP
raise PlateRefused(400, "background must be a jpeg, png or webp still")
if getattr(im, "n_frames", 1) > 1:
raise PlateRefused(400, "background must be a still image, not an animation")
w, h = im.size
if w < 64 or h < 64:
raise PlateRefused(400, "background is too small to place a car in")
if w > MAX_SIDE or h > MAX_SIDE: # 6,000 px
raise PlateRefused(413, "background is larger than %d px on a side" % MAX_SIDE)
im = im.convert("RGB") # decodes here, inside the bound checked above
im.save(path, "JPEG", quality=92) # our encoder, our bytes, from this point on
O tamanho é lido do cabeçalho antes que um único pixel seja descomprimido, então um arquivo de 79 KB que declara 8000×8000 é recusado sem nunca ser inflado. Só três formatos são aceitos, porque são os que um seletor de arquivos de navegador realisticamente entrega para uma fotografia, e cada outro decodificador que o PIL carrega é superfície que abriríamos sem benefício para ninguém. Uma animação é recusada, então o segundo quadro de qualquer coisa nunca é decodificado. Depois a imagem é recodificada como nosso próprio JPEG. O que quer que estivesse escondido no contêiner, um payload depois dos dados da imagem ou um bloco de metadados hostil, não sobrevive, porque nada disso são pixels.
O fundo é gravado sob o id do job e apagado quando o job termina, com sucesso ou não. Um job ainda na fila quando o processo reinicia nunca chega à sua limpeza, então uma varredura remove qualquer fundo com mais de uma hora.
Um detalhe a mais mudou o prompt. Para os nossos sete salões, o prompt carrega uma dica de fundo onde medimos uma: os dois salões escuros recebem "preserve the dark drapes and dark ceiling exposure". Para a foto de um desconhecido, a dica fica vazia em vez de ser chutada. Não medimos nada sobre aquela sala, e uma dica errada empurraria a exposição de um cômodo que o modelo já consegue ver.
O que é guardado, e por quanto tempo
Um id de job tem 16 caracteres hexadecimais, cortados de um UUID aleatório. /job/<id> e /img/<id>.jpg casam com um padrão rígido, e /img/ não serve nada que não seja um job finalizado deste processo. O JPEG que ele serve é guardado por uma hora; uma varredura a cada cinco minutos o apaga e esquece o job. O composite de 2048 pixels que foi para a pasta de entrada do ComfyUI é apagado em um bloco finally. Antes dessa correção, uma renderização com falha deixava o composite lá para sempre.
Access-Control-Allow-Origin é fixo em https://happyin.work. Vale ser exato sobre o que isso compra. O CORS só impede que um script de outro site leia nossas respostas num navegador; não impede um curl. O que de fato limita o abuso é a entrada estreita, o orçamento por hora e a fila. O CORS só impede que a página de outra pessoa conduza o estúdio como se fosse o backend dela.
Estados que a página pode mostrar
POST /compose responde na hora com 202 e um id de job; o trabalho na GPU acontece depois. A página consulta GET /job/<id> a cada 2,5 segundos, e o rótulo sob o quadro é o próprio estado da máquina: waiting, compositing, queued-on-gpu, rendering, depois done ou error. Quis que as palavras viessem da máquina, porque um cronômetro e a GPU discordam no momento em que a GPU fica ocupada. A barra de progresso só segue o tempo decorrido, numa curva que nunca chega a 100%: um quadro devolvido é a única coisa que pode encerrá-la.
Por dentro, o serviço consulta o /history do ComfyUI a cada meio segundo. Antes era a cada quatro segundos. O despachante em produção consulta a cada 0,2 s, e com quatro o visitante esperava até quatro segundos extras depois que a GPU já tinha terminado, o que, num passe de cerca de 45 segundos, era a maior parte do overhead que ele conseguia ver. Dois passes medidos em 28 de agosto levaram 29,5 s e 57,2 s.
Se a máquina recusar ou ficar em silêncio, a página compõe o carro no navegador e avisa isso no quadro. Uma renderização devolvida aparece como rendered on the gpu, uma local como browser composite · draft, e a legenda dá o motivo a partir do código de status: os oito quadros desta hora já foram gastos, a fila da GPU está cheia, o arquivo é grande demais, a máquina recusou este quadro ou a GPU não respondeu. Depois de quatro minutos de consulta, a página para de esperar. Um visitante nunca deve confundir uma colagem do navegador com saída de modelo.
O túnel, e onde ele não pôde rodar
O nome público é studio.happyin.app, servido por um túnel da Cloudflare. O lugar óbvio para o cloudflared era a própria máquina GPU: um túnel disca para fora, então o NAT não deveria importar. Importou. Atrás do NAT do host do mashinki, o cloudflared registrava conexões e as perdia em segundos, tanto sobre QUIC (failed to dial to edge with quic: timeout: no recent network activity) quanto sobre http2, e a borda respondia 1033 e 502 o tempo todo.
Não briguei com esse NAT. O túnel agora roda em outra máquina nossa, com conexão estável, como uma unit do systemd em volta da imagem oficial do cloudflared, com host networking, e alcança a máquina GPU pelo Tailscale. O serviço não sabe nem se importa onde o túnel roda.
O NAT não foi o erro mais caro, porém. Antes de qualquer coisa disso funcionar, eu tinha anotado que o bloqueio era um escopo de token da Cloudflare que faltava para a zona happyin.work. Não era. Um token que eu já tinha conseguia criar túneis e registros DNS em outra conta, e o endpoint nunca precisou morar sob happyin.work: um hostname em happyin.app faz o trabalho. O bloqueio era uma suposição sobre o meu próprio acesso, e eu deveria ter verificado o que minhas credenciais podiam fazer antes de escrever "bloqueado".
Limites
- O orçamento e a tabela de jobs vivem na memória. Um reinício esquece quem gastou o quê e descarta jobs na fila; a varredura só garante que os fundos enviados não sobrevivam à hora.
- Ao reler o código para este post, encontrei um arquivo que nada removia: o PNG que o nó de salvamento do ComfyUI escreve na sua pasta de saída, inclusive por cima da própria foto de um visitante. O serviço agora o apaga logo depois de copiar o quadro para fora. A primeira tentativa falhou sem barulho, porque o ComfyUI roda como root e cria essa pasta com dono root; o delete levantava um erro de permissão e o código o engolia. Contar a pasta antes e depois de uma renderização foi o que pegou isso (7 arquivos, depois 8). A pasta agora pertence ao usuário do serviço, e um delete que falha é impresso em vez de ignorado.
- As renderizações de carro rodam uma de cada vez. Com um job rodando e três esperando, um quinto job recebe um 503 e o visitante recebe um rascunho.
- O timeout de socket pega o silêncio, não um gotejamento lento, então uma thread poderia ser mantida aberta dessa forma. O corpo agora tem um prazo próprio, 90 segundos para a requisição inteira, em vez de 20 para cada leitura.
- Não há contas. Qualquer um pode gastar oito quadros por hora a partir de um endereço, o que, para uma demonstração, é o preço certo.
O que eu faria de novo
Montar o grafo a partir de constantes copiadas do pipeline ao vivo, com um comentário dizendo o arquivo de onde cada valor veio; quando a produção mudar, o diff mostra. Verificar em ordem de custo e decodificar por último. Recodificar tudo o que um desconhecido envia: são poucas linhas, e elimina toda uma categoria de perguntas sobre o que mais havia no arquivo. Rotular cada saída pelo lugar de onde ela realmente veio.
E manter o ComfyUI fora da internet. Nada na API dele foi desenhado para desconhecidos, e o serviço estreito na frente dele é um preço pequeno por isso. Se você precisa transformar um workflow próprio numa API ou numa ferramenta pública, esse é o trabalho descrito em /comfyui/.