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¶
Projects→ o projeto →+ New Resource.- App com imagem: Build Pack
Docker Image, apontando a tag móvel que opipeline.ymlpublica (fonte.xadm.biz/xadm/<slug>:native-amd64, oujar-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 oGET /api/v1/applicationsdevolve em claro para qualquer token da API (Segurança §Segredos). - Auto-deploy desligado. O deploy é o job
deploydopipeline.yml, pelo control-plane (Deploy); a primeira imagem vem do primeiro release ou de umworkflow_dispatch. Domains→https://<subdominio>.xadm.biz(DNS A no servidor; certificado automático).Environment Variables→ senhas e tokens marcados como Secret.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/dataou no banco. O diretório existe na imagem com donoapp(o Dockerfile): volume nomeado herda o dono; bind mount não herda, e o dono no host tem de ser1001: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/procdo host em container root, porque o bind não herda o read-only que o Docker aplica a/proc/syse/proc/sysrq-trigger.- Limite e reserva de memória (baseline).
Advanced→ Custom Docker Options =--dns 10.0.1.1 --dns 1.1.1.1(chamada de app para app).- Health check:
/healthna porta interna; oHEALTHCHECKe 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 —
ARGdo Dockerfile,XADM_COMMIT, todo--dart-definedo 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 —
ARGcom default no git,docs/app.json, ou secret do repo no GitHub passado como--build-argnos jobsbuild_*. 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 comoARGna 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>. NuncaSOURCE_COMMIT: em recurso pull-only o Coolify injetaSOURCE_COMMIT=HEADem runtime, por cima doENVda imagem (smoke de produção). - Coolify builda (o site de docs):
SOURCE_COMMITcom 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
0por desenho, e a auditoria confere odocker inspect; init: trueem 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:healthypor 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>+COPYda config, semRUN; o healthcheck usa o runtime da imagem (ex.node -e "fetch(...)") em vez de instalarcurl; exclude_from_hcfica no compose do Coolify, e a validação local roda sobre a cópia gerada pelocompose-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.1e redeploy. O Coolify converte paradns:no compose que gera. Pela API:PATCH /api/v1/applications/{uuid}comcustom_docker_run_options, depoisPOST /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.11do nginx é o DNS embutido do Docker, que repassa ao--dnsdo container. O nginx não muda; o recurso web leva o--dnscomo qualquer outro. - Se o
xadm-dnscair, o container usa o1.1.1.1, que é o caminho antigo. Nome de container e de serviço (banco,coolify) segue no DNS embutido do Docker, sem passar peloxadm-dns. - Diagnóstico:
docker exec <container> getent hosts <fqdn>responde10.0.1.1para domínio do host e o IP público para o resto. Resposta pública para domínio do host = recurso sem--dnsou 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çãodo CHANGELOG (Coolify — antes / depois do deploy), que a/xadm-releasepreenche. - Auditoria: env com
is_shown_once=truee valor vazio é "travada, valor desconhecido", nunca "vazia"; env travada só se copia pela UI.
Atualizar, rollback e logs¶
- Auto-deploy desligado; gatilho único = job
deploydopipeline.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 jobdeployverifica 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
Redeployno recurso. Logs: painelLogs(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 paraint-jar.<cli>.xadm.biz, que o reconcile lê como a mesma instância, e ojarfica nobuild.targetsaté a última instância trocar: até lá, cada release redeploya os-jar-pullque existirem. - Split só se afirma medido — N ≥ 20 requisições contando o
flavordo/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--strictnã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;-allowGETcom regex ancorada só nas rotas lidas;-allowfromcom as sub-redes IPv4 e IPv6 da redecoolify(o tráfego entre containers sai por IPv6); sem porta publicada;read_only,cap_drop: [ALL],no-new-privileges;/system/dfsó com?type=build-cache. Proibido otecnativacomCONTAINERS=1, que libera oEnvde 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, comprecpu_statsjá populado). CPU% = (cpuΔ / systemΔ) ×online_cpus× 100, comcpuΔ = cpu_stats.cpu_usage.total_usage − precpu_stats.cpu_usage.total_usageesystemΔ = 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 trataerrosausente como vazio. - Capture o
/statsreal 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