# ============================================================================ # Rastro — Compose "INTERNET" (servidor com acesso externo, ex.: Coolify repr-externo). # Espelho do que RODA no Coolify (stack rastro-br) — copiar/colar no Edit Compose File. # • Infra (db/rabbitmq/minio/gotenberg): ESPELHADA no Gitea — pull do Docker Hub neste servidor # corrompia camadas grandes ("magic number mismatch") e a minio/minio saiu do Docker Hub. # • App (rastro-node/analytics): do Gitea EXTERNO (pacotes públicos, pull anônimo). # • Roteamento do /api via LABELS Traefik no backend (NÃO usar o campo Domains do # backend no Coolify — o Domains-com-path corta o /api; o backend registra as # rotas COM /api, então precisa chegar sem strip). web/admin ficam no Domains do Coolify. # • Volume do banco: dbdata2 (o schema já subiu nele; renomear zera o banco). # • Teradata SEFAZ (processor, backend, analytics): nós COP por IP — o DNS da UTIC não # resolve trdtdbcopN. TERADATA_ENABLED/USER/PASSWORD e EVIDENCE_KEYS em Environment # Variables do Coolify. EVIDENCE_KEYS: "1:" (guarde cópia!). # • Certificado A1 (ingestor, dfe-worker, backend): DFE_CERT_PFX_BASE64 + DFE_CERT_PASSPHRASE # como variáveis secretas no Coolify — sem montar arquivo no servidor. # • Drenagem: ingestor (ONE) + dfe-worker (cadeia) + osrm (grafo no volume osrm_data); # processor/dfe-worker roteiam por OSRM_URL = http://osrm:5000. # ============================================================================ x-app-env: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar services: db: image: 'gitea.externo.utic.app.br/ericoalmeida/timescaledb-ha:pg16' restart: unless-stopped environment: POSTGRES_USER: '${POSTGRES_USER}' POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' POSTGRES_DB: '${POSTGRES_DB}' shm_size: 1gb volumes: - 'dbdata2:/home/postgres/pgdata' healthcheck: test: - CMD-SHELL - 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}' interval: 10s timeout: 5s retries: 15 rabbitmq: image: 'gitea.externo.utic.app.br/ericoalmeida/rabbitmq:3-management' restart: unless-stopped environment: RABBITMQ_DEFAULT_USER: '${RABBITMQ_DEFAULT_USER}' RABBITMQ_DEFAULT_PASS: '${RABBITMQ_DEFAULT_PASS}' volumes: - 'rabbitmq_data:/var/lib/rabbitmq' healthcheck: test: - CMD - rabbitmq-diagnostics - '-q' - ping interval: 15s timeout: 5s retries: 10 minio: # Espelho da imagem oficial (quay.io; o Docker Hub não publica mais a MinIO), versão fixa. image: 'gitea.externo.utic.app.br/ericoalmeida/minio:RELEASE.2025-09-07T16-13-09Z' restart: unless-stopped command: 'server /data --console-address ":9001"' environment: MINIO_ROOT_USER: '${MINIO_ROOT_USER}' MINIO_ROOT_PASSWORD: '${MINIO_ROOT_PASSWORD}' volumes: - 'minio_data:/data' gotenberg: image: 'gitea.externo.utic.app.br/ericoalmeida/gotenberg:8' restart: unless-stopped command: - gotenberg - '--chromium-disable-javascript=false' - '--api-timeout=60s' osrm: # Rotas pelas vias (grafo do Brasil, MLD, v5.26.0, --mmap: ~70-700 MB de RAM). # O grafo (15 GB) vive no volume osrm_data e é baixado UMA vez (marca .pronto). # Máquina nova (ou volume perdido) = defina OSRM_DATA_URL nas variáveis do Coolify, # com ~25 GB livres: o serviço baixa de lá, retoma queda e confere sha256. Sem a # variável e sem .pronto o container para e diz isso no log — de propósito. image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-osrm:v1' restart: unless-stopped environment: OSRM_DATA_URL: '${OSRM_DATA_URL:-}' volumes: - 'osrm_data:/data' expose: - '5000' ingestor: # DRENAGEM do ONE (passagens + fotos). Valores de cadência da produção antiga. image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.8' restart: unless-stopped # Só ORDEM de partida, sem `condition: service_healthy`: a espera de verdade é # dentro do container (`wait-for-db schema rabbitmq`). Com a condição aqui, o # `compose up` do Coolify fica parado esperando o RabbitMQ ficar saudável, estoura # o tempo do deploy e o container NUNCA é criado — foi assim que o ingestor # desapareceu ("No such container"), do mesmo jeito que o osrm-data antes dele. depends_on: - db - rabbitmq environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar NODE_ENV: production DB_CONNECTION_LIMIT: '5' ONE_TP_AMB: '1' ONE_VER_APLIC: rastro-1.0 ONE_HOST: one.svrs.rs.gov.br ONE_TIMEOUT_MS: '30000' ONE_TP_DIST: '1,4' ONE_CUF: '41' ONE_CNPJ_OPER: '' ONE_POLL_INTERVAL: '30' ONE_MAX_BATCHES: '40' ONE_BATCH_DELAY_MS: '700' ONE_IND_RESUMO: '0' # Certificado A1 por variável secreta (base64 do .pfx) — sem montar arquivo. DFE_CERT_PFX_BASE64: '${DFE_CERT_PFX_BASE64}' DFE_CERT_PASSPHRASE: '${DFE_CERT_PASSPHRASE}' DFE_TLS_REJECT_UNAUTHORIZED: 'false' MINIO_ENDPOINT: minio MINIO_PORT: '9000' MINIO_ROOT_USER: '${MINIO_ROOT_USER}' MINIO_ROOT_PASSWORD: '${MINIO_ROOT_PASSWORD}' MINIO_BUCKET: rastro-photos command: 'sh -c "wait-for-db schema rabbitmq && pnpm --filter @rastro/ingestor start"' migrate: image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.8' restart: 'no' depends_on: - db environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar NODE_ENV: production DB_CONNECTION_LIMIT: '5' command: 'sh -c "wait-for-db && pnpm --filter @rastro/db migrate && pnpm --filter @rastro/db postgis && pnpm --filter @rastro/db timescale && pnpm --filter @rastro/db intel && pnpm --filter @rastro/db seed:access && pnpm --filter @rastro/db analyze && pnpm --filter @rastro/db restore-users"' processor: image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.8' restart: unless-stopped depends_on: - db environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar NODE_ENV: production DB_CONNECTION_LIMIT: '8' OSRM_URL: '${OSRM_URL}' REROUTE_STALE_INTERVAL_MIN: '15' TERADATA_ENABLED: '${TERADATA_ENABLED:-false}' TERADATA_HOSTS: '10.14.203.21,10.14.203.22,10.14.203.23' TERADATA_USER: '${TERADATA_USER}' TERADATA_PASSWORD: '${TERADATA_PASSWORD}' TERADATA_DATABASE: P_ACCDB DFE_HOT_DAYS: '7' DFE_TD_GRACE_DAYS: '10' DFE_OPEN_MAX_DAYS: '60' DFE_ROUTE_RETENTION_DAYS: '90' DFE_META_RETENTION_DAYS: '400' DFE_LIFECYCLE_ENABLED: 'true' command: 'sh -c "wait-for-db schema rabbitmq && pnpm --filter @rastro/processor start"' dfe-worker: # Cadeia MDF-e → CT-e → NF-e (SEFAZ; Teradata para transporte além de D+1) + rota OSRM. image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.8' restart: unless-stopped depends_on: - db environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar NODE_ENV: production DB_CONNECTION_LIMIT: '12' OSRM_URL: '${OSRM_URL}' DFE_GEN_INTERVAL: '15' DFE_GEN_BATCH: '6' DFE_GEN_CONCURRENCY: '6' DFE_REFRESH_TTL_HOURS: '12' DFE_GEN_ROUTE: 'true' SEFAZ_WS_TP_AMB: '1' SEFAZ_WS_MAX_CTE: '20' SEFAZ_ARQUIVOXML_CUF: '41' SEFAZ_ARQUIVOXML_ORGAO: SEFA-PARANA SEFAZ_ARQUIVOXML_SETOR: AGAA SEFAZ_ARQUIVOXML_VERAPLIC: PR_Rastro_v1 SEFAZ_ARQUIVOXML_MATR: '999999' SEFAZ_ARQUIVOXML_MAX_NFE: '40' # Certificado A1 por variável secreta (base64 do .pfx) — sem montar arquivo. DFE_CERT_PFX_BASE64: '${DFE_CERT_PFX_BASE64}' DFE_CERT_PASSPHRASE: '${DFE_CERT_PASSPHRASE}' DFE_TLS_REJECT_UNAUTHORIZED: 'false' TERADATA_ENABLED: '${TERADATA_ENABLED:-false}' TERADATA_HOSTS: '10.14.203.21,10.14.203.22,10.14.203.23' TERADATA_USER: '${TERADATA_USER}' TERADATA_PASSWORD: '${TERADATA_PASSWORD}' TERADATA_DATABASE: P_ACCDB DFE_HOT_DAYS: '7' DFE_OPEN_MAX_DAYS: '60' TD_CHAIN_MIN_AGE_HOURS: '36' # wait-for-db osrm: sem OSRM roteando, a rota seria gravada como RETA (fallback). command: 'sh -c "wait-for-db schema osrm && pnpm --filter @rastro/processor start:worker"' analytics: image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-analytics:v4' restart: unless-stopped depends_on: - db environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar INTEL_TOP_COMPANIES: '200' INTEL_TOP_COMMODITIES: '50' INTEL_HORIZON_WEEKS: '8' INTEL_CYCLE_HOURS: '24' TERADATA_ENABLED: '${TERADATA_ENABLED:-false}' TERADATA_HOSTS: '10.14.203.21,10.14.203.22,10.14.203.23' TERADATA_USER: '${TERADATA_USER}' TERADATA_PASSWORD: '${TERADATA_PASSWORD}' backend: image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.8' restart: unless-stopped depends_on: - db environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar NODE_ENV: production DB_CONNECTION_LIMIT: '12' BACKEND_PORT: '39010' JWT_SECRET: '${JWT_SECRET}' SESSION_COOKIE_NAME: rastro_session CORS_ORIGINS: 'https://${WEB_DOMAIN},https://${ADMIN_DOMAIN},https://${CAMPO_DOMAIN}' RATE_LIMIT_MAX: '1000' GOTENBERG_URL: 'http://gotenberg:3000' MINIO_ENDPOINT: minio MINIO_PORT: '9000' MINIO_ROOT_USER: '${MINIO_ROOT_USER}' MINIO_ROOT_PASSWORD: '${MINIO_ROOT_PASSWORD}' MINIO_BUCKET: rastro-photos TERADATA_ENABLED: '${TERADATA_ENABLED:-false}' TERADATA_HOSTS: '10.14.203.21,10.14.203.22,10.14.203.23' TERADATA_USER: '${TERADATA_USER}' TERADATA_PASSWORD: '${TERADATA_PASSWORD}' TERADATA_DATABASE: P_ACCDB TD_DOC_CACHE_HOURS: '24' EVIDENCE_KEYS: '${EVIDENCE_KEYS}' EVIDENCE_BUCKET: rastro-evidence # Certificado A1 para o XML sob demanda: o .pfx em base64 numa variável secreta # (base64 -i certificado.pfx | tr -d '\n'). Vazio = XML sob demanda desligado. DFE_CERT_PFX_BASE64: '${DFE_CERT_PFX_BASE64}' DFE_CERT_PASSPHRASE: '${DFE_CERT_PASSPHRASE}' DFE_TLS_REJECT_UNAUTHORIZED: 'false' # Rastro Vista (PWA volante). O registro de passagem (610500) nasce DESLIGADO e # em HOMOLOGAÇÃO (tpAmb 2): ligar CAMPO_PASSAGEM_ENABLED só depois de ver cStat # 135/136 em homologação; produção = SEFAZ_EVENTO_TP_AMB=1. CAMPO_PUBLIC_URL: 'https://${CAMPO_DOMAIN}' ALPR_URL: 'http://alpr:8000' CAMPO_PASSAGEM_ENABLED: '${CAMPO_PASSAGEM_ENABLED:-false}' SEFAZ_EVENTO_TP_AMB: '${SEFAZ_EVENTO_TP_AMB:-2}' SEFAZ_EVENTO_CORGAO: '${SEFAZ_EVENTO_CORGAO:-91}' SEFAZ_EVENTO_CORGAO_AUTOR: '41' SEFAZ_EVENTO_CNPJ: '${SEFAZ_EVENTO_CNPJ:-}' SEFAZ_EVENTO_ENDPOINTS: '${SEFAZ_EVENTO_ENDPOINTS:-}' TD_DOC_CACHE_HOURS_CAMPO: '1' command: 'sh -c "wait-for-db schema && pnpm --filter @rastro/backend start"' expose: - '39010' networks: - default - coolify labels: - traefik.enable=true # O Traefik do Coolify vive na rede `coolify`. Como o container fica em DUAS # redes (a do recurso + a do proxy), sem esta dica ele não sabe por qual IP # falar, ignora o container e o roteador fica sem servidor — é o "503 no # available server" com os containers todos saudáveis. - traefik.docker.network=coolify # Hosts vêm de WEB_DOMAIN/ADMIN_DOMAIN (mesmas variáveis do PREVIEW_ALLOWED_HOSTS e # do CORS): domínio hardcoded aqui deixou o /api fora do ar na troca de endereço — # o Traefik mandava /api para o `web`, que respondia 403 do vite preview. - 'traefik.http.routers.rastroapi.rule=(Host(`${WEB_DOMAIN}`) || Host(`${ADMIN_DOMAIN}`)) && PathPrefix(`/api`)' - traefik.http.routers.rastroapi.entryPoints=https - traefik.http.routers.rastroapi.tls=true - traefik.http.routers.rastroapi.tls.certresolver=letsencrypt - traefik.http.routers.rastroapi.priority=1000 - traefik.http.routers.rastroapi.service=rastroapi - traefik.http.services.rastroapi.loadbalancer.server.port=39010 # /api no domínio do Rastro Vista: roteador SEPARADO — se CAMPO_DOMAIN estiver # vazio, só ele fica inválido; o /api do web/admin continua de pé. - 'traefik.http.routers.rastroapicampo.rule=Host(`${CAMPO_DOMAIN}`) && PathPrefix(`/api`)' - traefik.http.routers.rastroapicampo.entryPoints=https - traefik.http.routers.rastroapicampo.tls=true - traefik.http.routers.rastroapicampo.tls.certresolver=letsencrypt - traefik.http.routers.rastroapicampo.priority=1000 - traefik.http.routers.rastroapicampo.service=rastroapi web: image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.8' restart: unless-stopped environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar VITE_DEV_HELPERS: 'false' PREVIEW_ALLOWED_HOSTS: '${WEB_DOMAIN}' command: 'sh -c "pnpm --filter web preview --host 0.0.0.0 --port 39000"' expose: - '39000' networks: - default - coolify labels: # O endereço sai de WEB_DOMAIN, a MESMA variável que o site usa para aceitar o # host e que o /api usa na regra do Traefik. Enquanto o domínio era atribuído na # tela do Coolify, os dois podiam divergir — e divergiram: a tela tinha # `rastro.cfi…`, a variável `rastrobr.cfi…`, resultado 403 num nome e 503 no outro. - traefik.enable=true # O Traefik do Coolify vive na rede `coolify`. Como o container fica em DUAS # redes (a do recurso + a do proxy), sem esta dica ele não sabe por qual IP # falar, ignora o container e o roteador fica sem servidor — é o "503 no # available server" com os containers todos saudáveis. - traefik.docker.network=coolify - 'traefik.http.routers.rastroweb.rule=Host(`${WEB_DOMAIN}`)' - traefik.http.routers.rastroweb.entryPoints=https - traefik.http.routers.rastroweb.tls=true - traefik.http.routers.rastroweb.tls.certresolver=letsencrypt # Prioridade baixa: o /api do backend (1000) tem de ganhar no mesmo domínio. - traefik.http.routers.rastroweb.priority=10 - traefik.http.routers.rastroweb.service=rastroweb - traefik.http.services.rastroweb.loadbalancer.server.port=39000 admin: image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.8' restart: unless-stopped environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' RABBITMQ_URL: 'amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672' RASTRO_EXCHANGE: rastro.passagens RASTRO_QUEUE: rastro.passagens.processar VITE_DEV_HELPERS: 'false' PREVIEW_ALLOWED_HOSTS: '${ADMIN_DOMAIN}' command: 'sh -c "pnpm --filter admin preview --host 0.0.0.0 --port 39020"' expose: - '39020' networks: - default - coolify labels: - traefik.enable=true # O Traefik do Coolify vive na rede `coolify`. Como o container fica em DUAS # redes (a do recurso + a do proxy), sem esta dica ele não sabe por qual IP # falar, ignora o container e o roteador fica sem servidor — é o "503 no # available server" com os containers todos saudáveis. - traefik.docker.network=coolify - 'traefik.http.routers.rastroadmin.rule=Host(`${ADMIN_DOMAIN}`)' - traefik.http.routers.rastroadmin.entryPoints=https - traefik.http.routers.rastroadmin.tls=true - traefik.http.routers.rastroadmin.tls.certresolver=letsencrypt - traefik.http.routers.rastroadmin.priority=10 - traefik.http.routers.rastroadmin.service=rastroadmin - traefik.http.services.rastroadmin.loadbalancer.server.port=39020 # Rastro Vista — PWA da fiscalização volante (endereço: rastrovista.). # O identificador interno segue `campo` (serviço, rotas /api/campo, permissões # campo.*): renomear por dentro exigiria migrar permissões já gravadas no banco, # e ninguém além de nós vê esses nomes. HTTPS é obrigatório — sem certificado # válido o navegador não libera a câmera. campo: image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.8' restart: unless-stopped environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' PREVIEW_ALLOWED_HOSTS: '${CAMPO_DOMAIN}' command: 'sh -c "pnpm --filter campo preview --host 0.0.0.0 --port 39030"' expose: - '39030' networks: - default - coolify labels: - traefik.enable=true # O Traefik do Coolify vive na rede `coolify`. Como o container fica em DUAS # redes (a do recurso + a do proxy), sem esta dica ele não sabe por qual IP # falar, ignora o container e o roteador fica sem servidor — é o "503 no # available server" com os containers todos saudáveis. - traefik.docker.network=coolify # Sem PathPrefix e com prioridade baixa: este roteador pega tudo o que sobra no # domínio do Campo. O /api do mesmo domínio tem prioridade 1000 no backend e # ganha dele — a ordem importa, não a ordem de escrita. - 'traefik.http.routers.rastrocampo.rule=Host(`${CAMPO_DOMAIN}`)' - traefik.http.routers.rastrocampo.entryPoints=https - traefik.http.routers.rastrocampo.tls=true # SEM este resolvedor o Traefik serve o certificado interno dele ("TRAEFIK # DEFAULT CERT") e o navegador recusa — comprovado: o roteador /api do Campo # existia desde 18/09 só com `tls=true` e o domínio nunca ganhou certificado. # `letsencrypt` é o nome do resolvedor que o Coolify cria. - traefik.http.routers.rastrocampo.tls.certresolver=letsencrypt - traefik.http.routers.rastrocampo.priority=10 - traefik.http.routers.rastrocampo.service=rastrocampo - traefik.http.services.rastrocampo.loadbalancer.server.port=39030 # OCR de placas do Rastro Vista (CPU, modelos embutidos na imagem). Só rede interna. alpr: image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-alpr:v1' restart: unless-stopped expose: - '8000' # A rede do proxy do Coolify, onde o Traefik vive (ele roda SÓ nela, como mostra o # /data/coolify/proxy/docker-compose.yml). Os serviços com endereço público precisam # estar nas DUAS redes: a própria (para falar com banco, fila e entre si) e esta (para # o Traefik alcançá-los). Faltando esta, o roteador existe e responde "503 no # available server" com todos os containers saudáveis — foi o que aconteceu. networks: default: {} coolify: external: true volumes: osrm_data: null dbdata2: null rabbitmq_data: null minio_data: null