Imagem do ConectaSUS V2 — API + frontend
Imagem de produção com o código encapsulado: o vendor/, os caches de boot
(packages.php, services.php, routes-v7.php) e o dist/ do frontend já vão
prontos. Substitui o modelo de bind mount de /var/www + publish.sh dentro do
container.
API e frontend ficam no mesmo container, como em produção: Apache serve a API
na :80 e o SPA na :8080. Uma tag, um artefato, um rollback — as duas partes não
têm como ficar dessincronizadas.
O que entra e o que não entra
| Na imagem | código da API, vendor/ (--no-dev), autoload otimizado, route cache, dist/ do frontend, vhosts do Apache, configs do supervisor |
| Fora da imagem | .env, connections.json, storage/ (dados e logs) |
config/database.php:36 faz file_get_contents(base_path('connections.json'))
no boot — sem esse arquivo a aplicação não sobe. O entrypoint valida os dois
arquivos e falha com mensagem clara em vez de deixar o Apache subir quebrado.
O npm existe só no stage de build. Para o runtime atravessa apenas o dist/:
nem node_modules, nem fonte .vue, nem webpack. Produção deixa de compilar
qualquer coisa — nem PHP, nem JS.
Estrutura
O contexto do build é a raiz do application/, porque o build precisa enxergar
api/ e frontend/ ao mesmo tempo. Por isso Dockerfile e docker/ vivem na
raiz, e não dentro de api/.
application/
├── Dockerfile # 3 stages: frontend-build | build (composer) | runtime
├── .dockerignore
├── api/
├── frontend/
└── docker/
├── entrypoint.sh
├── apache/
│ ├── ports.conf # Listen 80 + Listen 8080
│ ├── 000-default.conf # :80 -> /var/www/api/public
│ └── frontend.conf # :8080 -> /var/www/frontend/dist
└── supervisor/conf.d/
├── apache.conf
└── websockets.conf # :6001
O frontend.conf traz FallbackResource /index.html: o vue-router está em
mode: 'history' (frontend/src/router/index.js) e, sem esse fallback, F5 em
rota interna devolve 404.
Build local
A partir da raiz do application/ (o contexto é .):
docker build -f Dockerfile \
--build-arg APP_VERSION=2.59.3 \
-t conectasus:2.59.3 \
.
O stage do frontend leva ~7 min (webpack) e roda com
NODE_OPTIONS=--max-old-space-size=4096. O Node 10 da imagem base limita o heap a
~1.5GB por padrão, e este projeto estoura esse teto com
FATAL ERROR: JavaScript heap out of memory.
O COPY do dist/ é o último do stage de runtime, de propósito: assim uma
alteração só de frontend não invalida as camadas pesadas da API (vendor/, caches
de boot), e o push sobe só o delta.
Executando
docker run -d --name conectasus \
-p 80:80 -p 8080:8080 -p 6001:6001 \
-v /dados/producao/conectasus/api/.env:/var/www/api/.env:ro \
-v /dados/producao/conectasus/api/connections.json:/var/www/api/connections.json:ro \
-v /dados/producao/conectasus/config.js:/var/www/frontend/config.js:ro \
-v /dados/producao/conectasus/storage:/var/www/api/storage \
192.168.0.41:3001/rkm/conectasus-api:2.59.3
São três arquivos de configuração, todos obrigatórios — o entrypoint recusa
subir sem qualquer um deles. O config.js é a config de runtime do frontend
(modelo em config.js.example): é ele que faz a mesma imagem
servir teste, homologação e produção sem rebuild.
O bind mount de arquivo único exige que o arquivo já exista no host — se o caminho não existir, o Docker cria um diretório com aquele nome e o entrypoint falha como se o arquivo estivesse faltando.
Variáveis de runtime opcionais:
| Variável | Padrão | Efeito |
|---|---|---|
CACHE_CONFIG |
false |
true gera bootstrap/cache/config.php no boot. Ganha performance, mas exige restart do container a cada mudança de .env/connections.json. |
Migrations
O
WORKDIRda imagem e/var/www, nao/var/www/api. Comandos artisan precisam de-w /var/www/api(ou--workdir), senao falham comCould not open input file: artisan.
Não são executadas nem no build nem no start do container. A aplicação de migrations por município é uma esteira separada.
O entrypoint só roda no start, então docker exec passa por fora dele. Tudo o
que o comando precisa já está na imagem: database/migrations,
database/seeders, database/utils e o doctrine/dbal (usado nos ->change()).
O nome passado em --database vem do connections.json montado em runtime.
Em um container já rodando:
docker exec -w /var/www/api -it conectasus php artisan migrate --database=municipio --force
Em um container descartável, sem depender de nenhum já no ar — o formato que a esteira de migration deve usar, já que só precisa da tag da imagem e dos mounts de config:
docker run --rm \
-v /dados/producao/conectasus/api/.env:/var/www/api/.env:ro \
-v /dados/producao/conectasus/api/connections.json:/var/www/api/connections.json:ro \
-v /dados/producao/conectasus/storage:/var/www/api/storage \
--workdir /var/www/api \
--entrypoint php \
192.168.0.41:3001/rkm/conectasus-api:2.59.3 \
artisan migrate --database=municipio --force
Pontos de atenção:
--force |
Obrigatório. Com APP_ENV=production o migrate abre prompt de confirmação e aborta em execução não interativa. |
--database=<nome> |
Precisa bater com uma chave do connections.json. Nome inexistente falha com Database connection [<nome>] not configured. |
| Código executado | É o da tag da imagem, não o do git checkout do host — a esteira de migration deve usar a mesma tag que está em produção. |
| Persistência | Migration escreve no banco, não no filesystem do container. Nada se perde em restart ou troca de tag. |
Se em algum momento for usado o php artisan publish (que percorre todos os
clientes de uma vez), aí o connections.json precisa ter também a entrada
schema — PublishCommand::databaseExists()
usa DB::connection('schema'). Com migrate --database= direto isso não se aplica.
Workflow manual de migrations
O workflow .gitea/workflows/migrate.yml
aplica migrations em uma imagem já publicada, sem executar deploy nem
construir uma imagem. Ele exige quatro dados no botão Run workflow:
| Campo | Exemplo | Regra |
|---|---|---|
ambiente |
teste |
Escolha explícita entre teste, homolog e prod. |
versao |
2.59.3 |
Deve ser a tag da imagem que contém a migration. |
municipios |
anhembi,aparecida |
Lista explícita de chaves do connections.json; não existe opção todos. |
confirmacao |
MIGRAR |
Sem o texto literal, o workflow recusa executar. |
Para cada município, o workflow cria um container descartável com a imagem
escolhida, os mounts .env, connections.json e storage/ do ambiente, e roda
php artisan migrate --database=<municipio> --force. O container temporário
não é a aplicação em produção e é removido ao fim; a alteração permanece no
banco de dados.
Antes de executar a primeira migration, o script verifica que todos os
nomes informados existem no connections.json, sem imprimir o conteúdo do
arquivo. Depois disso os municípios são processados em sequência. Uma falha
interrompe a lista; os bancos anteriores podem já ter sido migrados e devem ser
verificados antes de uma nova tentativa.
Rollback
Voltar o código é trocar a tag da imagem — segundos, sem rebuild:
docker stop conectasus-api && docker rm conectasus-api
docker run -d --name conectasus-api ... 192.168.0.41:3001/rkm/conectasus-api:2.59.2
O que não volta é o schema do banco, já migrado para a versão nova. Se isso quebra ou não depende do tipo de migration aplicada:
Tipo de migration no up() |
Rollback da imagem |
|---|---|
| Aditiva (coluna nova, tabela nova, índice) | Seguro. O código antigo ignora o que não conhece. |
Destrutiva (dropColumn, dropIfExists, renameColumn) |
Quebra. O código antigo referencia o que deixou de existir. |
Historicamente o segundo caso é raro: 3 de 255 migrations desde out/2025. Por isso a esteira roda o passo Avaliar reversibilidade das migrations, que compara o release com a tag anterior e marca no job summary se o rollback é seguro. O passo é informativo e não bloqueia o build.
Quando o release for marcado como destrutivo, o rollback deixa de ser opção barata. As saídas são, em ordem de preferência:
- Backup do banco antes de migrar — permite voltar imagem e schema juntos.
- Hotfix para frente — corrigir na versão nova em vez de voltar.
migrate:rollback --database=<municipio> --step=N— último recurso. 97,5% das migrations têmdown()implementado, mas rollback de schema com dados em produção tem risco próprio.
Uma alternativa que elimina o problema na origem é adiar a parte destrutiva: o release N para de usar a coluna, o release N+1 a remove. Aí toda migration é aditiva dentro da janela em que o rollback ainda pode ser necessário.
Esteira (Gitea Actions)
Workflow: .gitea/workflows/build-api-image.yml
(precisa ficar na raiz do repositório — é onde o Gitea procura).
Gatilhos:
- push em
developoufeat_CI_CD_TEST— constrói, publica e faz deploy no ambiente de teste; a tag é o nome da branch em minúsculas elatestnão muda; - push em
release-*— constrói, publica e faz deploy em homologação; - push em
master— não constrói nem implanta; o merge só prepara o commit que será marcado como release; - push de tag
2.*(ex.:2.59.3) — sempre constrói a imagem a partir do commit marcado, publica:2.59.3(elatest, sePUSH_LATEST=true) e para. Deploy e migrations de produção são ações manuais separadas.
Sem prefixo v: o filtro é 2.*, então 2.59.3 dispara e v2.59.3 não — e a
ausência de execução é silenciosa, sem erro em lugar nenhum.
O Gitea publica a tag da imagem em minúsculas. Um build da branch
feat_CI_CD_TEST vira :feat_ci_cd_test no registry.
Release de produção
O responsável cria a tag no commit já aceito em master:
git tag 2.60.0
git push origin 2.60.0
A tag constrói e publica a imagem, mas não toca em nenhum ambiente. Antes da liberação, valide o artefato oficial no ambiente de teste pelo workflow manual:
deploy → ambiente=teste, versao=2.60.0
migrate → ambiente=teste, versao=2.60.0, municipios=<lista>, confirmacao=MIGRAR
No horário de liberação, o operador repete a operação de deploy com
ambiente=prod e a mesma versão 2.60.0; caso haja migrations, aciona
depois o workflow migrate com a mesma versão e os municípios de produção
escolhidos.
Enquanto produção estiver em outro servidor, o caminho recomendado é o job manual conectar por SSH ao host de produção e executar o script de deploy lá. Isso ainda precisa dos dados reais do servidor (host, usuário de deploy, chave, fingerprint SSH, caminho do script e do diretório de configuração); eles não devem ser inventados ou gravados no repositório.
Variables — Settings › Actions › Variables
Nenhum host está fixo no YAML; tudo sai daqui (com fallback embutido).
| Variable | Exemplo | Default |
|---|---|---|
REGISTRY_HOST |
192.168.0.41:3001 |
192.168.0.41:3001 |
REGISTRY_USER |
ci-bot |
quem disparou a tag |
API_IMAGE_NAME |
rkm/conectasus-api |
rkm/conectasus-api |
API_BASE_IMAGE |
tirkm/v2-application:6 |
tirkm/v2-application:6 |
CONTEXT_DIR |
. |
. |
DOCKERFILE |
Dockerfile |
Dockerfile |
API_DIR |
api |
api |
PUSH_LATEST |
true / false |
true |
AUTO_MIGRATE_TEST |
true / false |
false — executa migrations automaticamente só quando o ambiente da branch é teste. |
MIGRATION_CONNECTIONS_TEST |
anhembi,aparecida |
sem padrão — lista explícita de conexões municipais para a migration automática de teste. |
TEST_SSH_HOST |
192.168.0.44 |
opcional; esse é o padrão quando o transporte é SSH. |
TEST_SSH_PORT |
22 |
22 |
SSH_USER_TEST |
ci-deploy |
obrigatório quando o transporte é SSH. |
CONTEXT_DIR é o contexto do build (a raiz, que enxerga api/ e frontend/).
API_DIR é o subdiretório da API, usado só nas checagens que leem arquivos do
repositório — composer.json e database/migrations. Não confundir os dois: foi
por herdarem o mesmo valor que essas checagens quebraram quando o contexto subiu.
Secrets — Settings › Actions › Secrets
| Secret | Obrigatório | Para quê |
|---|---|---|
REGISTRY_TOKEN |
sim | PAT do Gitea com escopo write:package (e read:package) |
DOCKERHUB_USER / DOCKERHUB_TOKEN |
só se tirkm/ for privado |
pull da imagem base |
SSH_KEY_TEST |
sim, para deploy de teste | chave privada do usuário SSH de deploy no host de teste. |
SSH_KNOWN_HOSTS_TEST ou SSH_KNOWN_HOST_TEST |
sim, para deploy de teste | fingerprint SSH do host de teste, no formato de known_hosts. |
O REGISTRY_TOKEN é obrigatório apesar do fallback no script: neste Gitea o
secrets.GITEA_TOKEN do job resolve para string vazia, e o set -u não pega
isso (a variável está definida, só que vazia). O resultado é um docker login com
senha vazia e um unauthorized que parece problema de rede ou de TLS.
Cadastre o secret no repositório ou na organização. O cofre pessoal (avatar → Settings → Actions → Secrets) não é lido por repos da organização — o campo chega vazio e o sintoma é idêntico ao de secret inexistente.
Deploy de teste por SSH
Todo deploy de teste usa SSH. Configure:
TEST_SSH_HOST=192.168.0.44
TEST_SSH_PORT=22
SSH_USER_TEST=ci-deploy
O job abre SSH para o host .44, não para o container act_runner. Ele
envia temporariamente deploy.sh e teste.env, faz login do Docker remoto no
registry, executa o deploy com health check e remove os arquivos temporários.
.env, connections.json, config.js e storage/ não atravessam SSH: eles
continuam no CONFIG_DIR do host de teste.
Registre o host em SSH_KNOWN_HOSTS_TEST (ou SSH_KNOWN_HOST_TEST) com
fingerprint conferido fora da esteira. Não use StrictHostKeyChecking=no. O
usuário SSH deve ser dedicado ao deploy; como ele opera Docker, trate-o como
acesso administrativo ao host.
Pré-requisitos
-
act_runner registrado com acesso ao daemon Docker do host — registre em modo
host, ou monte/var/run/docker.sockno runner. O registro é feito uma única vez e persiste em/data/.runner; o token de registro do compose só é lido no primeiro boot. -
Registry em HTTP:
192.168.0.41:3001não tem TLS, então o Docker do runner precisa confiar nele. Em/etc/docker/daemon.json:{ "insecure-registries": ["192.168.0.41:3001"] }Seguido de
systemctl reload docker—insecure-registriesé recarregável via SIGHUP, então não é preciso reiniciar o daemon nem derrubar containers. Vale para todo host que façapushoupulldessa imagem, incluindo os servidores de deploy. -
Container registry habilitado no Gitea (
[packages] ENABLED = truenoapp.ini— já responde em/v2/). -
Packages: Writeno time da organização. Publicar emrkm/exige essa unidade no time ao qual a conta do CI pertence — é permissão separada da de repositórios, e sem ela odocker loginpassa e só opushfalha, comunauthorized: authentication required. Configura-se em organização rkm → Settings → Teams → <time> → Units.Para diagnosticar: o endpoint
/v2/tokennão serve — ele devolveScope: "all"mesmo sem permissão, porque a checagem real só acontece no upload do blob. O teste que decide é tentar o push num namespace pessoal (<usuario>/teste): se lá funcionar e emrkm/não, é permissão de org, não escopo de PAT.
Etapas do job
- Checkout
- Metadados (versão, tags, data)
- Confere
composer.json.versioncontra a tag (aviso, não bloqueia) - Avalia reversibilidade das migrations contra a tag anterior (informativo)
- Login no Docker Hub (pulado se o secret não existir) e no registry do Gitea
docker build— frontend (npm ci+npm run build) e API (composer install) em stages paralelos, com só odist/e o código atravessando para o runtime- Smoke test: a app boota com config;
.env/connections.jsonnão estão na imagem; o entrypoint recusa subir sem config; odist/está publicado; o Apache passa noconfigteste expõe:80e:8080 docker pushde:<versão>e, sePUSH_LATEST=truee for build de tag, de:latest- Resumo no job summary + limpeza (
docker rmi,docker logout)