Deploy: variáveis de ambiente configuráveis + docs dos dois modos do Coolify

TZ (com tzdata), GUNICORN_TIMEOUT e GUNICORN_LOG_LEVEL agora têm default no
Dockerfile e podem ser sobrescritos via Coolify → Environment Variables ou
.env (modelo em .env.example). TZ corrige o carimbo "exportada em"
(sessao.marcar_exportada), que sem isso saía em UTC. Workers continua
literal em -w 1, de propósito — não é variável.

Cabeçalhos do Dockerfile e docker-compose.yml reescritos para descrever os
dois modos de deploy no Coolify (Application/Dockerfile, em produção desde
04/09, e Docker Compose) e o caminho de volume que de fato funciona:
Directory mount em /data/coolify/applications/<uuid>/clientes, dados
enviados por scp da pasta ses-mg/.

.gitattributes fixa LF também em Dockerfile, docker-compose.yml e
.env.example — o autocrlf do Windows já tinha reescrito o Dockerfile para
CRLF uma vez nesta máquina.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 22:00:34 -03:00
co-authored by Claude Sonnet 5
parent a4b1374fad
commit 62ec768d0c
6 changed files with 120 additions and 29 deletions
+3
View File
@@ -17,3 +17,6 @@ scripts/backlog
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml
.dockerignore .dockerignore
.env
.env.example
.gitattributes
+29
View File
@@ -0,0 +1,29 @@
# Variáveis da Exportação da SD. Copie para .env (gitignored) para rodar com o
# compose; no Coolify em modo Application, cadastre em Environment Variables.
# Tudo tem default no Dockerfile — só declare o que quer mudar.
# ── Lidas DENTRO do container (valem nos dois modos do Coolify) ──────────────
# Porta do gunicorn dentro do container. No Coolify, a porta exposta da app
# tem de ser a mesma.
PORT=5000
# Fuso do carimbo "exportada em" (sessao.marcar_exportada usa datetime.now()).
TZ=America/Sao_Paulo
# Segundos por request. Upload + análise de uma SD leva bem menos que isso;
# subir só se o painel mostrar "WORKER TIMEOUT".
GUNICORN_TIMEOUT=120
# debug | info | warning | error
GUNICORN_LOG_LEVEL=info
# ── Lidas pelo COMPOSE ao montar volumes (só no Modo 2 / local) ─────────────
# No Modo 1 (Application) não têm efeito: o volume é o Directory mount da UI.
# Pasta do host com os dados do cliente (tem de conter ses-mg/contrato/itens.yaml).
# Fora da pasta que o Coolify clona — ela é recriada a cada deploy.
#CLIENTES_DIR=/dados/exporta-sd/clientes
# Saída da linha de comando (exporta_sd.py --dir). Opcional.
#DERIVADOS_DIR=/dados/exporta-sd/_derivados
+5
View File
@@ -3,3 +3,8 @@
# autocrlf=true do Git no Windows reescreve o arquivo na próxima operação que # autocrlf=true do Git no Windows reescreve o arquivo na próxima operação que
# o toque e o build local passa a falhar mesmo com a imagem correta. # o toque e o build local passa a falhar mesmo com a imagem correta.
*.sh text eol=lf *.sh text eol=lf
# Mesma razão para os arquivos de deploy: são lidos por ferramentas Linux e o
# autocrlf já reescreveu o Dockerfile para CRLF uma vez nesta máquina.
Dockerfile text eol=lf
docker-compose.yml text eol=lf
.env.example text eol=lf
+3
View File
@@ -18,3 +18,6 @@ clientes/
# Lock de sessão local do Claude Code — efêmero (PID, sessionId), não é config do projeto. # Lock de sessão local do Claude Code — efêmero (PID, sessionId), não é config do projeto.
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
# Variáveis locais do compose — .env.example é o modelo versionado.
.env
+35 -8
View File
@@ -4,10 +4,23 @@
# clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico), # clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico),
# consulta contrato/linhas-os.yaml se existir (fallback da linha de OS; ausente, # consulta contrato/linhas-os.yaml se existir (fallback da linha de OS; ausente,
# `linha_os` é exigido no SD.md) e grava SD.md de volta em # `linha_os` é exigido no SD.md) e grava SD.md de volta em
# clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses # clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses são dados do cliente,
# são dados do cliente, versionados fora do git — entram por VOLUME montado em # versionados fora do git — entram por VOLUME montado em /app/clientes. Sem esse
# /app/clientes. Sem esse volume, a imagem sobe e a tela inicial responde, mas o # volume, a imagem sobe e a tela inicial responde, mas o primeiro upload falha
# primeiro upload falha com "Arquivo canônico ausente". # com "Arquivo canônico ausente". E sem linhas-os.yaml no volume, o VPS se
# comporta diferente do local: o fallback desliga e `linha_os` vira pendência.
#
# DOIS MODOS de deploy no Coolify — a imagem é a mesma, muda onde se configura:
#
# 1. Application (Dockerfile) — o que está em produção (2026-09-04). O Coolify
# IGNORA o docker-compose.yml. Volume: Persistent storage → Add mount →
# "Directory mount", Source /data/coolify/applications/<uuid>/clientes
# (o Coolify sugere esse caminho; ele sobrevive a redeploy) → Destination
# /app/clientes. Variáveis: aba Environment Variables. Réplicas: 1.
# 2. Docker Compose — usa o docker-compose.yml; volume e variáveis vêm de lá.
#
# Por isso TODO default de variável mora AQUI (ENV + ${VAR:-default} no CMD),
# e o compose só espelha: variável que existe só no compose não existe no VPS.
# #
# Build local: docker build -t exporta-sd . # Build local: docker build -t exporta-sd .
# Run local: docker run --rm -p 5000:5000 -v "$PWD/clientes:/app/clientes" exporta-sd # Run local: docker run --rm -p 5000:5000 -v "$PWD/clientes:/app/clientes" exporta-sd
@@ -17,18 +30,31 @@ FROM python:3.13-slim
# PYTHONUNBUFFERED: log do gunicorn sai na hora no painel do Coolify. # PYTHONUNBUFFERED: log do gunicorn sai na hora no painel do Coolify.
# LANG/LC_ALL em UTF-8: identificadores de SD trazem "·" (P2·SD8) e o # LANG/LC_ALL em UTF-8: identificadores de SD trazem "·" (P2·SD8) e o
# escrita_sd monta nome de arquivo a partir deles. # escrita_sd monta nome de arquivo a partir deles.
# TZ: sessao.marcar_exportada carimba datetime.now() na tela ("exportada em");
# sem isto o container está em UTC e o horário sai 3h adiantado.
#
# Variáveis que o operador pode sobrescrever (Coolify → Environment Variables,
# ou .env com o compose — ver .env.example). Os defaults valem nos dois modos:
# PORT porta do gunicorn dentro do container
# TZ fuso do carimbo de exportação
# GUNICORN_TIMEOUT segundos por request (upload + análise da SD)
# GUNICORN_LOG_LEVEL debug | info | warning | error
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \ LC_ALL=C.UTF-8 \
PORT=5000 PORT=5000 \
TZ=America/Sao_Paulo \
GUNICORN_TIMEOUT=120 \
GUNICORN_LOG_LEVEL=info
WORKDIR /app WORKDIR /app
# gosu: o entrypoint precisa iniciar como root (só root faz chown no volume # gosu: o entrypoint precisa iniciar como root (só root faz chown no volume
# recém-montado) e depois derrubar privilégio para `sd` antes do gunicorn. # recém-montado) e depois derrubar privilégio para `sd` antes do gunicorn.
# tzdata: zoneinfo para o TZ acima ter efeito (a slim não garante que venha).
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends gosu \ && apt-get install -y --no-install-recommends gosu tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Dependências em camada própria: mudar código não reinstala pacote. # Dependências em camada própria: mudar código não reinstala pacote.
@@ -67,8 +93,9 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
# é a global ATUAL no processo do servidor.py (uma SD por vez, em memória). # é a global ATUAL no processo do servidor.py (uma SD por vez, em memória).
# Com dois workers, dois requests do MESMO usuário caem em processos # Com dois workers, dois requests do MESMO usuário caem em processos
# diferentes e a sessão desaparece de forma aleatória. Pelo mesmo motivo, o # diferentes e a sessão desaparece de forma aleatória. Pelo mesmo motivo, o
# número de réplicas no Coolify tem de ficar em 1. # número de réplicas no Coolify tem de ficar em 1 — e é por isso que o número
# de workers NÃO é variável de ambiente: não existe valor certo além de 1.
# #
# --chdir app + servidor:app: o servidor.py insere ../scripts no sys.path a # --chdir app + servidor:app: o servidor.py insere ../scripts no sys.path a
# partir de __file__, então funciona igual sob gunicorn e sob `python app/servidor.py`. # partir de __file__, então funciona igual sob gunicorn e sob `python app/servidor.py`.
CMD ["sh", "-c", "exec gunicorn --chdir app servidor:app -w 1 -b 0.0.0.0:${PORT:-5000} --timeout 120 --access-logfile - --error-logfile -"] CMD ["sh", "-c", "exec gunicorn --chdir app servidor:app -w 1 -b 0.0.0.0:${PORT:-5000} --timeout ${GUNICORN_TIMEOUT:-120} --log-level ${GUNICORN_LOG_LEVEL:-info} --access-logfile - --error-logfile -"]
+45 -21
View File
@@ -1,47 +1,71 @@
# Deploy em VPS pelo painel Coolify (recurso do tipo "Docker Compose"). # Deploy da Exportação da SD — a imagem é a do Dockerfile; este arquivo é UM dos
# dois jeitos de subi-la no Coolify, e serve também para rodar local.
# #
# ANTES do primeiro deploy, ponha os dados do cliente em um caminho FIXO do # ── MODO 1 · Application (Dockerfile) — o que está em produção (2026-09-04) ──
# host, FORA da pasta que o Coolify clona — ele re-clona a cada deploy, e
# clientes/ está no .gitignore, então o clone nunca traz esses dados:
# #
# scp -r clientes/ usuario@vps:/dados/exporta-sd/clientes # Neste modo o Coolify IGNORA este arquivo por completo: builda o Dockerfile e
# configura tudo pela interface. O que o volume e as variáveis daqui dizem tem
# de ser reproduzido lá:
# #
# Não precisa de chown manual: o entrypoint.sh da imagem roda como root no # Persistent storage → Add mount → "Directory mount"
# início do container e ajusta o dono do volume para o usuário `sd` antes de # Source Path: /data/coolify/applications/<uuid-da-app>/clientes
# derrubar privilégio e subir o gunicorn — ver entrypoint.sh e o ENTRYPOINT # (o próprio Coolify sugere esse caminho; ele sobrevive
# no Dockerfile. (Uma reconsequência: cada `scp` novo devolve os arquivos ao # a redeploy — é a pasta de storage da app, não o clone)
# usuário do ssh no host; some sozinho no próximo restart do container.) # Destination Path: /app/clientes
# Depois, no Coolify → Environment Variables, aponte o volume para lá: # Mesmo esquema para _derivados, só se for usar a linha de comando.
# #
# CLIENTES_DIR=/dados/exporta-sd/clientes # Dados: a pasta do host nasce VAZIA. Copie o conteúdo para dentro dela —
# DERIVADOS_DIR=/dados/exporta-sd/_derivados # só se usar a linha de comando # aponte para "ses-mg", não para "clientes", ou o nível duplica
# (.../clientes/clientes/ses-mg):
# #
# Sem essas variáveis, o padrão ./clientes cai dentro da pasta do clone, o # scp -r "D:/Exporta-SD/clientes/ses-mg" usuario@vps:/data/coolify/applications/<uuid>/clientes/
# compose cria o diretório VAZIO sem avisar e o primeiro upload morre com
# "Arquivo canônico ausente".
# #
# Resto do painel: porta exposta 5000, réplicas 1 (ver o comentário de -w 1 no # e confira no host: ls /data/coolify/applications/<uuid>/clientes/ses-mg/contrato/
# Dockerfile), e o domínio o Coolify publica via Traefik. # Tem de listar itens.yaml E linhas-os.yaml — sem o segundo, o fallback da
# linha de OS desliga e `linha_os` vira pendência só no VPS.
#
# Variáveis: aba Environment Variables (nomes e defaults em .env.example).
# Réplicas: 1 (ver o comentário de -w 1 no Dockerfile).
#
# ── MODO 2 · Docker Compose (ou local) ──────────────────────────────────────
#
# O Coolify lê este arquivo. Ponha os dados num caminho fixo do host, fora da
# pasta que ele clona, e aponte CLIENTES_DIR para lá (Environment Variables no
# Coolify, ou um .env ao lado deste arquivo — copie .env.example). Sem a
# variável, o padrão ./clientes cai dentro do clone, o compose cria o
# diretório VAZIO sem avisar e o primeiro upload morre com "Arquivo canônico
# ausente".
#
# Nos dois modos não há chown manual: o entrypoint.sh roda como root ao subir
# o container, ajusta o dono do volume para o usuário `sd` e só então derruba
# privilégio e inicia o gunicorn. Um scp novo devolve os arquivos ao usuário do
# ssh; resolve sozinho no próximo restart.
services: services:
exporta-sd: exporta-sd:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: exporta-sd:local
# Sem `ports:` de propósito: no Coolify o Traefik alcança o container pela # Sem `ports:` de propósito: no Coolify o Traefik alcança o container pela
# rede interna. `expose` publica a porta só para dentro dessa rede. # rede interna. `expose` publica a porta só para dentro dessa rede.
# Para rodar fora do Coolify, troque por: ports: ["5000:5000"]. # Para rodar fora do Coolify, troque por: ports: ["5000:5000"].
expose: expose:
- "5000" - "5000"
# Só ESPELHA os defaults do Dockerfile — o valor de verdade mora lá (ENV e
# ${VAR:-default} no CMD), porque no Modo 1 este bloco nem é lido. Aqui a
# única função é deixar o operador trocar pelo .env sem editar YAML.
environment: environment:
PORT: "5000" PORT: ${PORT:-5000}
TZ: ${TZ:-America/Sao_Paulo}
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120}
GUNICORN_LOG_LEVEL: ${GUNICORN_LOG_LEVEL:-info}
volumes: volumes:
# OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada # OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada
# análise (e contrato/linhas-os.yaml, se existir, como fallback da linha # análise (e contrato/linhas-os.yaml, se existir, como fallback da linha
# de OS) e grava SD.md em clientes/ses-mg/projetos/. O caminho no # de OS) e grava SD.md em clientes/ses-mg/projetos/. O caminho no
# container é fixo: caminhos.py resolve clientes/ a partir da raiz do # container é fixo: caminhos.py resolve clientes/ a partir da raiz do
# código, que é o WORKDIR /app. Sem esta montagem, a tela inicial abre # código, que é o WORKDIR /app.
# e o primeiro upload falha com "Arquivo canônico ausente".
- ${CLIENTES_DIR:-./clientes}:/app/clientes - ${CLIENTES_DIR:-./clientes}:/app/clientes
# Saída da linha de comando (exporta_sd.py --dir _derivados/export). # Saída da linha de comando (exporta_sd.py --dir _derivados/export).
# A aplicação web entrega o JSON pelo download, não por aqui. # A aplicação web entrega o JSON pelo download, não por aqui.