Pular para conteúdo

Operação no Coolify

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-10-01

Aplica-se a: perfis app e config com recurso no Coolify.

Como criar, configurar e operar um recurso no Coolify da X-Adm, e o baseline do host onde ele roda.

Como a rede funciona

O Traefik (proxy do Coolify) termina o TLS e encaminha para a porta interna do container. O PostgreSQL roda em outro projeto (000-compartilhado, recurso postgresql18) — o host JDBC é o nome do serviço Docker na rede interna, nunca localhost nem IP externo.

flowchart TD
    I[Internet] -- "HTTPS 443" --> T[Traefik]
    T -- "HTTP porta interna" --> A[Container do app]
    A -- "JDBC 5432, rede interna" --> P[(postgresql18<br/>000-compartilhado)]

Criar uma aplicação nova

  1. Projects → o projeto → + New Resource.
  2. App com imagem: Build Pack Docker Image, apontando a tag móvel que o pipeline.yml publica (fonte.xadm.biz/xadm/<slug>:native-amd64, ou jar-amd64/web-amd64). O Coolify puxa, não builda. Recurso que o Coolify builda do git (stack compose, site de docs): fonte Private Repository (with Deploy Key), com deploy key só-leitura do próprio repo e URL SSH sem credencial — nunca usuário:senha nem token na URL, que o GET /api/v1/applications devolve em claro para qualquer token da API (Segurança §Segredos).
  3. Auto-deploy desligado. O deploy é o job deploy do pipeline.yml, pelo control-plane (Deploy); a primeira imagem vem do primeiro release ou de um workflow_dispatch.
  4. Domains → https://<subdominio>.xadm.biz (DNS A no servidor; certificado automático).
  5. Environment Variables → senhas e tokens marcados como Secret.
  6. Storages → volume para o que sobrevive a redeploy (/app/data, uploads). Servidor loga em stdout: volume em /app/logs é o arquétipo errado. Histórico que a UI exibe (o log de cada processamento, por exemplo) é dado do app, mesmo com nome de log: mora em /app/data ou no banco. O diretório existe na imagem com dono app (o Dockerfile): volume nomeado herda o dono; bind mount não herda, e o dono no host tem de ser 1001:1001. Directory Mount do host é bind rw (a API do Coolify não tem read-only): pré-condição obrigatória — o container roda não-root (USER app) e só lê; recurso compose declara :ro; nunca montar o /proc do host em container root, porque o bind não herda o read-only que o Docker aplica a /proc/sys e /proc/sysrq-trigger.
  7. Limite e reserva de memória (baseline).
  8. Advanced → Custom Docker Options = --dns 10.0.1.1 --dns 1.1.1.1 (chamada de app para app).
  9. Health check: /health na porta interna; o HEALTHCHECK e a cadência já vêm na imagem.

Build Arguments × Environment Variables

Duas perguntas decidem onde cada valor vive: quando ele é lido e quem builda.

  • Build-time é o que o build assa no artefato — ARG do Dockerfile, XADM_COMMIT, todo --dart-define do Flutter. Runtime é o que o processo lê — DATASOURCES_*, tokens inter-app.
  • Recurso pull-only (server jar/native, Flutter web): o Coolify não builda, e build-arg dele não chega a lugar nenhum. O build-time vive onde o CI alcança — ARG com default no git, docs/app.json, ou secret do repo no GitHub passado como --build-arg nos jobs build_*. No recurso fica só runtime: o do server Micronaut é cheio, o do Flutter web fica sem env de build. Env de build-time esquecida no recurso pull é vestigial e engana — apague.
  • Recurso que o Coolify builda (site de docs, ho-scraper, stack compose): valor de build é Build Argument com Available at Buildtime; valor de runtime é Environment Variable. Segredo de runtime marcado como build-arg vaza como ARG na imagem.

Carimbar o commit do deploy: NÃO leia o .git/HEAD no build

O .git não vai ao contexto de build: COPY .git/HEAD quebra o deploy. Quem builda passa o sha:

  • Pull-only: o CI passa --build-arg XADM_COMMIT=<sha>. Nunca SOURCE_COMMIT: em recurso pull-only o Coolify injeta SOURCE_COMMIT=HEAD em runtime, por cima do ENV da imagem (smoke de produção).
  • Coolify builda (o site de docs): SOURCE_COMMIT com Available at Buildtime — lá o valor é real.

Variável declarada sem o marcador é pior que ausente. No recurso, a variável pode existir vazia: o build não recebe build-arg nenhum, cai no default do ARG e o deploy fica verde — só o /commit.txt denuncia, e só se alguém olhar. Trate vazio como ausente no Dockerfile (${SOURCE_COMMIT:-dev}), para o carimbo dizer "não fui preenchido" em vez de sair em branco. Conferência pós-deploy: /commit.txt responde o sha; dev significa que o marcador Available at Buildtime não está ligado naquele recurso.

Dockerfile que lê docs/app.json: case a exceção no .dockerignore

O .dockerignore costuma excluir docs/. Dockerfile que lê docs/app.json (a ponte de config da Central de Apps: jq … docs/app.json → --dart-define) precisa da exceção, depois da linha que exclui — as duas metades no mesmo PR:

docs
!docs/app.json

Banco no PostgreSQL compartilhado

Banco e usuário dedicados por app, no postgresql18:

CREATE DATABASE meu_app;
CREATE USER meu_app WITH PASSWORD 'senha-forte';
GRANT ALL PRIVILEGES ON DATABASE meu_app TO meu_app;
\c meu_app
GRANT ALL ON SCHEMA public TO meu_app;  -- PostgreSQL 15+

Volume do Postgres 18: confira o ponto de montagem. A imagem oficial 18 usa PGDATA=/var/lib/postgresql/18/docker e VOLUME /var/lib/postgresql; volume montado em /var/lib/postgresql/data fica vazio e o dado vai para um volume anônimo que um down + up não reaproveita. No host: docker inspect <container-do-postgresql18> --format '{{json .Mounts}}' — o volume nomeado tem Destination em /var/lib/postgresql.

Banco que é fonte do PowerSync

A replicação lógica pede uma flag do servidor (vale para todos os bancos do postgresql18 e exige restart) e permissões próprias:

-- no servidor (uma vez, com janela): ALTER SYSTEM SET wal_level = logical;
--                                    ALTER SYSTEM SET max_replication_slots = 10;  -- folga
ALTER ROLE meu_app WITH REPLICATION BYPASSRLS;   -- não é superuser
\c meu_app
CREATE PUBLICATION powersync FOR ALL TABLES;     -- nome FIXO "powersync"
GRANT SELECT ON ALL TABLES IN SCHEMA public TO meu_app;

FOR ALL TABLES custa WAL e memória e põe tabela nova na publication sozinha; FOR TABLE a, b economiza e cobra ALTER PUBLICATION … ADD TABLE em toda migration que cria tabela sincronizada — e não se troca de regime sem dropar a publication. Conferir: SHOW wal_level;, SELECT * FROM pg_publication;, SELECT slot_name, active, restart_lsn FROM pg_replication_slots;. Slot inativo retém WAL e enche o disco do servidor compartilhado — é incidente de plataforma (PowerSync).

Recurso compose

Stack de serviço self-hosted (as stacks PowerSync, perfil config), buildada pelo Coolify a partir do git e disparada pelo control-plane com target=compose:

  • limite e reserva de memória em todo serviço do compose; o recurso Coolify fica 0 por desenho, e a auditoria confere o docker inspect;
  • init: true em serviço cujo healthcheck dispara processo;
  • dns: [10.0.1.1, 1.1.1.1] em todo serviço: recurso compose não tem Custom Docker Options (chamada de app para app);
  • todo serviço declara healthcheck, imagem de terceiro inclusive;
  • status diferente de running:healthy por mais de um deploy é incidente;
  • remover um serviço do compose inclui apagar o sub-app no Coolify no mesmo deploy;
  • build no host só como FROM <imagem>@sha256:<digest> + COPY da config, sem RUN; o healthcheck usa o runtime da imagem (ex. node -e "fetch(...)") em vez de instalar curl;
  • exclude_from_hc fica no compose do Coolify, e a validação local roda sobre a cópia gerada pelo compose-sem-coolify.py.

O lint do pipeline-config.yml cobra essa lista; a falta de dns: sai como aviso.

Chamada de app para app

App chama outro app da casa pela URL pública (https://<fqdn>.xadm.biz, da env <APP>_URL), nunca por IP, alias de rede Docker nem --add-host (0039). Sem ajuda, essa chamada sai do host e volta pelo roteador (hairpin NAT), que falha em um dos dois IPs da zona: No route to host, de forma intermitente.

O xadm-dns de cada host (provisionamento) responde os *.xadm.biz roteados pelo Traefik daquele host com 10.0.1.1, o gateway da rede coolify. O container fala com o Traefik local, com o mesmo TLS de fora. O que o host não serve segue para o DNS público.

  • Recurso de app: Custom Docker Options = --dns 10.0.1.1 --dns 1.1.1.1 e redeploy. O Coolify converte para dns: no compose que gera. Pela API: PATCH /api/v1/applications/{uuid} com custom_docker_run_options, depois POST /api/v1/deploy?uuid=<uuid>.
  • Recurso compose: dns: [10.0.1.1, 1.1.1.1] em cada serviço (Recurso compose).
  • Flutter web com túnel: o resolver 127.0.0.11 do nginx é o DNS embutido do Docker, que repassa ao --dns do container. O nginx não muda; o recurso web leva o --dns como qualquer outro.
  • Se o xadm-dns cair, o container usa o 1.1.1.1, que é o caminho antigo. Nome de container e de serviço (banco, coolify) segue no DNS embutido do Docker, sem passar pelo xadm-dns.
  • Diagnóstico: docker exec <container> getent hosts <fqdn> responde 10.0.1.1 para domínio do host e o IP público para o resto. Resposta pública para domínio do host = recurso sem --dns ou lista desatualizada (grep <fqdn> /data/xadm-dns/hosts).
  • Checagem periódica — recurso sem o --dns:
curl -s -H "Authorization: Bearer $TOK" "$URL/api/v1/applications" \
  | python -c "import sys,json;[print(a['name']) for a in json.load(sys.stdin) if '10.0.1.1' not in (a.get('custom_docker_run_options') or '')]"

API do Coolify de dentro de um container

App no mesmo daemon Docker do Coolify chama a API pela URL interna http://coolify:8080, que dispensa TLS e allowlist. É a exceção à chamada pela URL pública. Pelo admin.xadm.biz, a chamada só passa com o --dns do recurso, que a faz chegar com IP interno (10.0.1.0/24 está na allowlist); sem ele, o hairpin NAT chega com IP de fora e leva 403.

Setar env programaticamente

PATCH /api/v1/applications/{uuid}/envs/bulk faz upsert em lote (Bearer; POST .../envs cria uma, PATCH .../envs atualiza uma). "Secret" é env marcada, não tipo à parte; cada env tem os flags Build Variable e Runtime Variable. É o endpoint que o broker do /xadm-setup usa para injetar a env de runtime (Central de Apps).

Ciclo de vida e auditoria de env

  • Env nova se cria no recurso antes do deploy da release que a lê; env que sumiu se apaga depois que todo deploy estiver na versão nova — é a seção ### Migração do CHANGELOG (Coolify — antes / depois do deploy), que a /xadm-release preenche.
  • Auditoria: env com is_shown_once=true e valor vazio é "travada, valor desconhecido", nunca "vazia"; env travada só se copia pela UI.

Atualizar, rollback e logs

  • Auto-deploy desligado; gatilho único = job deploy do pipeline.yml → control-plane. Os recursos que seguem em auto-deploy (site de docs, ho-scraper) estão listados em Deploy.
  • O deploy termina no smoke: o POST /api/ci/deploy é assíncrono, e o último step do job deploy verifica produção (smoke de produção).
  • Rollback automático: pendente no control-plane. O smoke reprova e avisa; o operador reverte apontando <sha>-<target> no recurso e fazendo Redeploy.
  • O rollback recria a imagem, não o schema: a migration da versão nova já rodou e o Flyway não desfaz — por isso toda migration é expand/contract (Java/Micronaut).
  • Redeploy manual: botão Redeploy no recurso. Logs: painel Logs (stdout/stderr do container).

Tags de imagem

Todo build publica duas tags em fonte.xadm.biz/xadm/<slug> (0024):

  • <target>-amd64 (native-amd64, jar-amd64, web-amd64) — a móvel, que o recurso puxa, escrita exatamente assim no recurso e no workflow;
  • <sha>-<target> — a imutável, alvo de reversão. O registro retém as dez últimas por imagem e alvo, e nunca poda a que está no ar.

:latest é proibida no recurso: o pipeline.yml não a publica, e um recurso nela puxa outra imagem. O digest efetivamente puxado aparece no log do deploy do Coolify — confira contra o do job de build.

Dois recursos no mesmo domínio

Dois recursos atrás do mesmo domínio acontecem na troca blue-green, com native e jar do mesmo app, e no religamento do fallback JVM:

  • O novo assume o FQDN antes de o antigo ser apagado. A ordem inversa leva router e certificado junto com o recurso apagado → 503.
  • O native é dono do domínio principal (<app>.<cli>); o jar vai para <app>-jar.<cli>. No app multi-instância o jar vai para int-jar.<cli>.xadm.biz, que o reconcile lê como a mesma instância, e o jar fica no build.targets até a última instância trocar: até lá, cada release redeploya os -jar-pull que existirem.
  • Split só se afirma medido — N ≥ 20 requisições contando o flavor do /health —, nunca pela config do Traefik.
  • Roteamento fora do FQDN do Coolify mora num arquivo dinâmico próprio do Traefik, que referencia os containers por uuid (https-0-<uuid>@docker) e sobrevive ao regen; editar à mão um arquivo que o Coolify gera é perdê-lo na próxima ação do recurso.

Alias auth.xadm.biz

O domínio canônico do central-backend é central-backend.xadm.biz. auth.xadm.biz é alias permanente (issuer do JWT e bundles publicados), roteado para o mesmo serviço pelo arquivo dinâmico /data/coolify/proxy/dynamic/central-backend-alias-auth.yaml, que referencia o container por uuid; o certificado está no acme.json. Recurso recriado (uuid novo) → atualizar o arquivo do alias — sem isso o alias fica órfão, em silêncio.

Renomear um slug ou recurso — a disciplina

Nome na casa é contrato (docs, imagem, reconcile). Dois eventos distintos:

  • Rename do recurso (só o nome no Coolify): edite o campo name no lugar (uuid estável — nunca apagar e recriar). O reconcile casa pelo slug, então nada mais muda.
  • Rename do slug (o identificador público), no mesmo movimento:
  • publicar a imagem e o prefixo docs-sites/<slug-novo>/ com o nome novo;
  • apagar à mão (rclone) o prefixo docs-sites/<slug-velho>/ — senão ele segue servindo doc congelada;
  • reapontar todo ponteiro para o slug velho (docs.xadm.biz/aplicacoes/<slug-velho>/… e os _importado/ dos consumidores — URL absoluta, que o --strict não valida);
  • renomear o recurso e rodar o reconcile;
  • manter o app_id: identidade do broker não muda com o slug.

Checklist pré-go-live

  • [ ] Banco e usuário criados no postgresql18; URL do banco no host interno
  • [ ] DNS do domínio no servidor Coolify
  • [ ] Variáveis configuradas; segredos como Secret; recurso pull-only sem env de build
  • [ ] Recurso do git com deploy key só-leitura e URL SSH sem credencial
  • [ ] Auto-deploy desligado; primeiro deploy pelo control-plane
  • [ ] Volume de dados mapeado, com o diretório do dono app
  • [ ] Limite e reserva de memória configurados (nunca 0)
  • [ ] Custom Docker Options com --dns 10.0.1.1 --dns 1.1.1.1
  • [ ] Health check verde e curl -I https://…/health
  • [ ] Recurso recriado atrás de arquivo dinâmico (alias) → arquivo atualizado

Baseline de operação do host

A máquina Linux onde o Coolify roda tem um baseline: sem swap, sem daemon de OOM e com recurso sem limite, um build ou um leak congela a VM inteira — inclusive o shutdown.

Swap dimensionado

Host de produção tem swap ≥ RAM/2 (mínimo 8G num host de 15G). Sem swap, o pico vira thrash: o kernel pagina infinito e o OOM-killer nem dispara.

# adiciona swapfile SEM desligar o existente (swapoff sob pressão de memória é arriscado)
fallocate -l 8G /swap2.img && chmod 600 /swap2.img
mkswap /swap2.img && swapon /swap2.img
echo '/swap2.img none swap sw 0 0' >> /etc/fstab
sysctl vm.swappiness=10
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf

earlyoom — mata antes do livelock, poupa os bancos

apt-get update && apt-get install -y earlyoom
# /etc/default/earlyoom
EARLYOOM_ARGS="-r 3600 --avoid '(^|/)(mongod|postgres|dockerd|containerd|systemd)$' --prefer '(^|/)(java|gradle|native-image)$'"
systemctl enable --now earlyoom

DNS local do host (xadm-dns)

Todo host Coolify roda o xadm-dns, o CoreDNS da chamada de app para app. Pré-condição: a rede coolify em 10.0.1.0/24, o padrão do Coolify — docker network inspect coolify --format '{{(index .IPAM.Config 0).Gateway}}' responde 10.0.1.1. Host fora disso se corrige antes, não se parametriza: o --dns dos recursos é o mesmo em todo host.

/data/xadm-dns/Corefile — escuta só no gateway, responde a lista e repassa o resto ao DNS do host:

.:53 {
    bind 10.0.1.1
    hosts /data/xadm-dns/hosts {
        reload 15s
        ttl 30
        fallthrough
    }
    forward . /run/systemd/resolve/resolv.conf
    cache 30
    errors
    loop
}

/data/xadm-dns/gerar-hosts.sh — lista os *.xadm.biz roteados pelo Traefik deste host (labels dos containers e arquivos dinâmicos); lista vazia mantém a anterior:

#!/bin/sh
set -eu
DIR=/data/xadm-dns
TMP=$(mktemp)
{
  docker ps -q | xargs -r docker inspect \
    --format '{{range $k,$v := .Config.Labels}}{{$v}}{{"\n"}}{{end}}' | grep -F 'Host(' || true
  find /data/coolify/proxy/dynamic -maxdepth 1 -name '*.yaml' -exec grep -hF 'Host(' {} + || true
} | grep -oE 'Host(SNI)?\([^)]*\)' | grep -oE '`[^`]+`' | tr -d '`' \
  | tr 'A-Z' 'a-z' | grep -E '^[a-z0-9.-]+\.xadm\.biz$' | sort -u \
  | awk '{print "10.0.1.1", $1}' > "$TMP"
if [ ! -s "$TMP" ]; then rm -f "$TMP"; exit 0; fi
if ! cmp -s "$TMP" "$DIR/hosts"; then mv "$TMP" "$DIR/hosts"; chmod 644 "$DIR/hosts"; else rm -f "$TMP"; fi

Instalação, como root, com os dois arquivos em /data/xadm-dns/:

chmod 755 /data/xadm-dns/gerar-hosts.sh && /data/xadm-dns/gerar-hosts.sh
echo '* * * * * root /data/xadm-dns/gerar-hosts.sh >/dev/null 2>&1' > /etc/cron.d/xadm-dns
ufw allow proto udp from 10.0.0.0/8 to 10.0.1.1 port 53 comment 'xadm-dns'
ufw allow proto tcp from 10.0.0.0/8 to 10.0.1.1 port 53 comment 'xadm-dns'
docker run -d --name xadm-dns --restart unless-stopped --network host \
  -v /data/xadm-dns:/data/xadm-dns:ro -v /run/systemd/resolve:/run/systemd/resolve:ro \
  coredns/coredns:1.11.3 -conf /data/xadm-dns/Corefile
# validação: domínio do host → 10.0.1.1; externo → IP público
docker run --rm --network coolify --dns 10.0.1.1 --dns 1.1.1.1 curlimages/curl:latest \
  sh -c 'getent hosts central-backend.xadm.biz google.com'

Reverter: docker rm -f xadm-dns, apagar /etc/cron.d/xadm-dns e /data/xadm-dns, e ufw delete das duas regras.

Limite de memória por recurso

Recurso no Coolify nasce com limits_memory=0 — ilimitado. Todo recurso declara limite e reserva; aplicar é restart (recria o container, sem rebuild). Referência por perfil:

Perfil Limite Reserva
Server Micronaut native 256M 128M
Ingestão native 768M 256M
Fallback JVM (com -XX:MaxRAMPercentage=75) 768M 256M

Checagem periódica — nenhum recurso em 0:

curl -s -H "Authorization: Bearer $TOK" "$URL/api/v1/applications" \
  | python -c "import sys,json;[print(a['name']) for a in json.load(sys.stdin) if not a.get('limits_memory')]"

Zumbi curl <defunct> — é do container, não do host

Dezenas de curl <defunct> com PPID num container de app são o curl do HEALTHCHECK reparenteando para o PID1 do container, que não colhe órfão. A cura é o tini como PID1 dos Dockerfiles do kit (Java/Micronaut); verificação: ps -eo stat | grep -c '^Z' perto de zero.

Não buildar no host de produção

A imagem é buildada pelo job build_<alvo> do pipeline.yml, num runner fora do host de produção; o Coolify só puxa (Build Pack Docker Image). Build no host — jar ou native — põe o pico de RAM e CPU em cima de quem serve produção e incha o build-cache. Runner na mesma VM não resolve: o build tem de rodar em outra máquina.

Monitorar o host de dentro de um container

App que observa o host (disco, memória, containers) nunca monta o socket Docker cru — /var/run/docker.sock é root no host.

  • Docker via wollomatic/socket-proxy, pinado por digest; -allowGET com regex ancorada só nas rotas lidas; -allowfrom com as sub-redes IPv4 e IPv6 da rede coolify (o tráfego entre containers sai por IPv6); sem porta publicada; read_only, cap_drop: [ALL], no-new-privileges; /system/df só com ?type=build-cache. Proibido o tecnativa com CONTAINERS=1, que libera o Env de todos os containers.
  • Host por mount de superfície mínima, com a pré-condição do Directory Mount (Criar); para disco, monte um único arquivo público do filesystem-alvo e sonde o FileStore dele.
  • Stats do Docker: GET /containers/{id}/stats?stream=false (one-shot, com precpu_stats já populado). CPU% = (cpuΔ / systemΔ) × online_cpus × 100, com cpuΔ = cpu_stats.cpu_usage.total_usage − precpu_stats.cpu_usage.total_usage e systemΔ = cpu_stats.system_cpu_usage − precpu_stats.system_cpu_usage. RAM = memory_stats.usage − memory_stats.stats.inactive_file (cgroup v2; sem a subtração o page-cache infla a conta).
  • Disco em Java: Files.getFileStore(path) → getTotalSpace()/getUsableSpace().
  • Degradação parcial: fonte que falha não derruba a resposta — o endpoint devolve 200 com { fontes: {...}, erros: {...} }, e o consumidor trata erros ausente como vazio.
  • Capture o /stats real da instância antes de escrever o parser: o shape varia por versão da Engine e por cgroup.

Runbook — travou ou ficou lento

free -h; swapon --show                 # swap saturado em idle = alerta
df -h /; docker system df              # disco / build-cache inchado
journalctl -k --since today | grep -iE "oom|killed process|out of memory"  # confirma exaustão
docker stats --no-stream               # quem come RAM/CPU