Como Rodar — Local e Produção


1. Rodando localmente (desenvolvimento)

Pré-requisitos

  • Docker Desktop (Mac/Windows) ou Docker Engine + Docker Compose (Linux)
  • Portas livres: 3000, 3001, 4242, 5432, 6379, 8000, 8042, 8043
  • Git

Passo a passo

1. Clone o repositório
git clone git@github.com:ViniciusAguiarBenvinda/PacsTechnik.git
cd PacsTechnik
git checkout develop
2. Configure as variáveis de ambiente O projeto usa três .env separados:
ArquivoPara quê serve
.env (raiz)Infra Docker: credenciais do Postgres, Redis, Orthanc, ORTHANC_WEBHOOK_SECRET
backend/.envLaravel: APP_KEY, JWT_SECRET, DB_*, REDIS_*, FRONTEND_URL, ORTHANC_WEBHOOK_SECRET
frontend/.env.localNext.js: NEXT_PUBLIC_API_BASE_URL e NEXT_PUBLIC_OHIF_VIEWER_URLopcional em dev com Docker (o docker-compose.yml já define os valores para localhost:8000 / localhost:3001). Necessário apenas se for rodar o frontend fora do Docker (npm run dev direto) ou sobrescrever os padrões
# .env raiz — infraestrutura Docker (banco, redis, orthanc)
cp .env.example .env

# .env do backend — configuração Laravel
cp backend/.env.example backend/.env

# .env do frontend — só se for rodar fora do Docker
cp frontend/.env.example frontend/.env.local
Edite o backend/.env e preencha obrigatoriamente:
APP_KEY=                    # gere com: docker run --rm php:8.4-cli-alpine php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;"
JWT_SECRET=                 # gere com: (após subir o container) docker exec pacs_backend php artisan jwt:secret
FRONTEND_URL=http://localhost:3000
ORTHANC_WEBHOOK_SECRET=dev-webhook-secret-change-in-prod  # pode manter esse valor em dev
Os valores de DB_* e REDIS_* no backend/.env já apontam para os containers Docker — não mude em dev.
ALLOWED_ORIGIN no .env raiz não precisa ser preenchida em dev — o compose usa o fallback http://localhost:3001 automaticamente.
3. Suba a stack
docker compose up -d --build
Aguarde todos os containers ficarem healthy:
docker compose ps
4. Gere o JWT secret (primeira vez)
docker exec pacs_backend php artisan jwt:secret
Cole o valor gerado no backend/.env em JWT_SECRET= e recarregue:
docker exec pacs_backend php artisan octane:reload
5. Rode migrations e seed
docker exec -it pacs_backend php artisan migrate --seed
O seed cria:
  • Super admin padrão (verifique database/seeders/ para o email/senha)
  • Tenant de exemplo
  • Unidade de exemplo
6. Acesse

Comandos do dia a dia

# Subir a stack
docker compose up -d

# Parar tudo
docker compose down

# Recarregar o backend após editar PHP (obrigatório — Octane não tem hot reload)
docker exec pacs_backend php artisan octane:reload

# Ver logs em tempo real
docker logs pacs_backend -f
docker logs pacs_queue -f
docker logs pacs_worker -f
docker logs pacs_orthanc -f

# Acessar o shell do backend
docker exec -it pacs_backend bash

# Rodar uma migration nova
docker exec pacs_backend php artisan migrate

# Rodar os testes do backend
docker exec pacs_backend php artisan test

# Reprocessar estudos sem modality indexada
docker exec pacs_backend php artisan pacs:requeue --only-missing-modalities

# Inspecionar o Redis
docker exec -it pacs_redis redis-cli

# Acessar o banco direto
docker exec -it pacs_postgres psql -U pacs_admin -d pacs_database

Troubleshooting comum

“Carregando sessão…” infinito no login Causas mais comuns:
  1. Acessando via 127.0.0.1:3000 em vez de localhost:3000 O CORS do backend permite apenas FRONTEND_URL (default http://localhost:3000). O browser bloqueia silenciosamente requests de origens diferentes — a sessão nunca resolve. Fix: sempre acesse via http://localhost:3000.
  2. Backend retornando 500 em vez de 401 em rotas protegidas O middleware Authenticate do Laravel tenta redirecionar para a rota nomeada login que não existe em apps API-only. Fix: já resolvido em bootstrap/app.phpAuthenticationException retorna JSON 401.
  3. Workers RoadRunner crashando após restart Após docker compose restart backend, aguarde alguns segundos antes de acessar o app.
“Route [login] not defined” (500 no backend) Já resolvido via handler em bootstrap/app.php. Se voltar a aparecer, verifique se o arquivo foi alterado:
docker exec pacs_backend php artisan config:clear
docker exec pacs_backend php artisan octane:reload
Após docker compose down -v, sessão trava O banco foi zerado mas cookies antigos persistem no browser. Limpe os cookies de localhost no DevTools e rode as migrations:
docker exec -it pacs_backend php artisan migrate --seed

Testando o webhook do Orthanc manualmente

curl -X POST http://localhost:8000/api/orthanc/webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Secret: dev-webhook-secret-change-in-prod" \
  -d '{"ID": "orthanc-study-uuid"}'

Recriando um container específico

docker compose up -d --force-recreate backend
docker compose up -d --force-recreate worker
docker compose up -d --force-recreate orthanc

Resetando tudo do zero

# Para e remove containers, networks e volumes (⚠️ apaga todos os dados)
docker compose down -v

# Sobe novamente do zero
docker compose up -d --build
docker exec -it pacs_backend php artisan migrate --seed

2. Git Workflow e Releases

Estrutura de branches

BranchFunçãoQuem commita diretamente
mainCódigo estável em produção — o servidor sempre roda daquiNinguém — só via PR
developIntegração do dia a dia — branch base para o trabalhoCorreções pequenas e commits de infra
feat/*, fix/*, infra/*Branches temporárias para features e correções maioresO desenvolvedor da tarefa

Fluxo de trabalho diário

develop
  └── git checkout -b feat/nome-da-feature
        # trabalha, commita...
        git push origin feat/nome-da-feature
        # abre PR no GitHub: feat/nome → develop
        # após merge: deleta a branch temporária
Quando usar branch temporária vs commitar direto no develop:
  • Commitar direto no develop: correções pequenas de bug, ajustes de config, documentação, infra sem risco de quebrar nada
  • Criar branch feat/*: features novas, refatorações, qualquer coisa que possa afetar outras partes do sistema ou precise de revisão antes de integrar

Como fazer um deploy (promote develop → main)

O servidor sempre roda main. Para publicar o que está em develop: 1. Abrir um Pull Request no GitHub
develop → main
Revise os commits, confirme que está tudo ok e faça o merge. O título do PR deve descrever o conjunto de mudanças (ex: Release: hardening de segurança + infra de produção). 2. No servidor, atualizar e reconstruir
git pull origin main

# Reconstruir imagens e reiniciar containers
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build

# Rodar migrations novas (se houver)
docker exec pacs_backend php artisan migrate --force

Situação atual — primeiro deploy

Todo o trabalho realizado até agora está na branch develop e ainda não foi mesclado na main. Antes do primeiro deploy no servidor, é necessário fazer o merge:
# Localmente (ou via PR no GitHub — recomendado)
git checkout main
git merge develop
git push origin main
A forma recomendada é abrir um PR no GitHub (develop → main) para ter um registro histórico do que foi para produção e quando.

3. Rodando em produção

Pré-requisitos do servidor

  • Ubuntu 22.04 LTS ou Debian 12 (recomendado)
  • Docker Engine + Docker Compose Plugin
  • Mínimo: 2 vCPU, 4 GB RAM, 40 GB disco
  • Domínio apontando para o IP do servidor (ex: app.suaempresa.com.br)
  • Portas abertas no firewall: 80, 443 (e opcionalmente 4242 para receber DICOM)
# Instalar Docker no Ubuntu
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker

Passo a passo

0. Certifique-se de que develop foi mesclado em main Antes de clonar o servidor, o código precisa estar na main. Abra um PR no GitHub (develop → main) e faça o merge. Veja a seção Git Workflow e Releases para o passo a passo. 1. Configure o acesso ao repositório privado no servidor O repositório é privado — o servidor precisa de permissão para clonar. A forma recomendada é uma Deploy Key (chave SSH sem passphrase, somente leitura):
# No servidor
ssh-keygen -t ed25519 -C "servidor-prod" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub
Copie a chave pública exibida e adicione em: GitHub → repositório → Settings → Deploy keys → Add deploy key (marque “Allow read access” — não precisa de write) Teste a conexão antes de continuar:
ssh -T git@github.com
# Deve retornar: Hi ViniciusAguiarBenvinda/PacsTechnik! You've successfully authenticated...
2. Clone o repositório no servidor
git clone git@github.com:ViniciusAguiarBenvinda/PacsTechnik.git
cd PacsTechnik
# main já é o default — confirme com:
git branch
3. Configure as variáveis de ambiente
cp .env.example .env
cp backend/.env.example backend/.env
Em produção não é necessário criar frontend/.env. O Dockerfile.prod embute as NEXT_PUBLIC_* no bundle em compile time via ARG, lendo os valores direto do .env raiz. O frontend/.env.local serve só para dev fora do Docker.
Gere os secrets antes de preencher:
# DB_PASSWORD e REDIS_PASSWORD (um para cada)
openssl rand -hex 16

# ORTHANC_WEBHOOK_SECRET e APP_KEY (um para cada)
openssl rand -hex 32

# APP_KEY no formato do Laravel
docker run --rm php:8.4-cli-alpine php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;"
Edite o .env raiz com os valores gerados e os domínios reais:
DB_PASSWORD=<gerado acima>
REDIS_PASSWORD=<gerado acima>
ORTHANC_WEBHOOK_SECRET=<gerado acima>
NEXT_PUBLIC_API_BASE_URL=https://api.suaempresa.com.br/api
NEXT_PUBLIC_OHIF_VIEWER_URL=https://viewer.suaempresa.com.br
ALLOWED_ORIGIN=https://viewer.suaempresa.com.br
ACME_EMAIL=seuemail@suaempresa.com.br

# Resource limits — ajuste conforme os CPUs do servidor
# Nunca exceda o total de CPUs disponíveis (verifique com: nproc)
BACKEND_CPU_LIMIT=1
BACKEND_MEM_LIMIT=1G
FRONTEND_CPU_LIMIT=0.5
FRONTEND_MEM_LIMIT=256M
WORKER_CPU_LIMIT=0.5
WORKER_MEM_LIMIT=512M
QUEUE_CPU_LIMIT=0.5
QUEUE_MEM_LIMIT=256M
ACME_EMAIL é usado pelo Caddy para emitir certificados via Let’s Encrypt. Os limites de CPU acima são para um servidor de 1 CPU — ajuste proporcionalmente para servidores maiores (ex: 4 CPUs → BACKEND_CPU_LIMIT=2).
Edite o backend/.env:
APP_ENV=production
APP_DEBUG=false
APP_KEY=<gerado acima com base64:...>
APP_URL=https://api.suaempresa.com.br

LOG_LEVEL=error

DB_PASSWORD=<mesmo valor do .env raiz>
REDIS_PASSWORD=<mesmo valor do .env raiz>
ORTHANC_WEBHOOK_SECRET=<mesmo valor do .env raiz>

FRONTEND_URL=https://app.suaempresa.com.br
SESSION_SECURE_COOKIE=true   # obrigatório com HTTPS — cookie JWT só trafega em conexões seguras

JWT_TTL=480                  # duração do token em minutos (8 horas)
ORTHANC_USERNAME e ORTHANC_PASSWORD — deixe em branco. O Orthanc está com AuthenticationEnabled: false no orthanc.json; quem protege o acesso é o nginx proxy via token temporário.
JWT_SECRET — deixe em branco por enquanto. Será gerado após o primeiro build (passo 6).
JWT_TTL=480 — o token expira em 8 horas. O frontend renova automaticamente via POST /api/refresh quando recebe 401, sem exigir novo login. O refresh é válido por 2 semanas (padrão tymon) — após esse período, o usuário precisa fazer login novamente.
3.5. Edite o Caddyfile com os seus domínios O docker/caddy/Caddyfile tem os subdomínios hardcoded no repositório (usados no ambiente de homolog). Antes de subir a stack, troque pelos domínios reais do seu servidor:
${EDITOR:-nano} docker/caddy/Caddyfile
Os três blocos precisam casar exatamente com os domínios que você vai cadastrar no DNS (passo 8) e com o que colocar no .env e backend/.env:
app.suaempresa.com.br {
    reverse_proxy frontend:3000
}

api.suaempresa.com.br {
    reverse_proxy backend:8000
}

viewer.suaempresa.com.br {
    handle_path /dicom-proxy/* {
        reverse_proxy orthanc-cors-proxy:80
    }
    handle {
        reverse_proxy ohif:80
    }
}
Três armadilhas comuns aqui:
  1. Underscore em domínio é inválidotechnik_api.seudominio.com não resolve em DNS. Use sempre hífen: technik-api.seudominio.com.
  2. Caddyfile, .env e DNS têm que bater exatamente. Se o Caddyfile lista app.seudominio.com mas o .env diz NEXT_PUBLIC_API_BASE_URL=https://api.outro-dominio.com/api, o frontend quebra em runtime.
  3. NEXT_PUBLIC_* são compiladas no build do Next.js. Se você trocar os domínios no .env raiz depois do primeiro build, é obrigatório docker compose ... up -d --build frontend.
4. Suba a stack de produção
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
O build pode levar alguns minutos (compila o Next.js, instala dependências PHP, baixa o binário do RoadRunner). 5. Verifique se todos os containers subiram
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
# Todos devem aparecer como "running" ou "healthy"
Se algum container falhar, veja os logs antes de continuar:
docker logs pacs_backend --tail 50
6. Gere o JWT secret
docker exec pacs_backend php artisan jwt:secret
# Cole o valor gerado em JWT_SECRET= no backend/.env
docker compose -f docker-compose.yml -f docker-compose.prod.yml restart backend
7. Rode as migrations
docker exec -it pacs_backend php artisan migrate --force
O --force é necessário em APP_ENV=production para confirmar que você quer rodar migrations.
Não rode --seed em produção — o seed é para dados de desenvolvimento. 8. Configure o DNS O sistema usa três subdomínios. O proxy DicomWeb (Orthanc) é servido como path sob o viewer (/dicom-proxy/), sem precisar de subdomínio próprio.
SubdomínioServiço
app.suaempresa.com.brFrontend (Next.js)
api.suaempresa.com.brBackend (Laravel)
viewer.suaempresa.com.brOHIF Viewer + Proxy DicomWeb (/dicom-proxy/)
Todos devem ter um registro A apontando para o IP público do servidor:
Tipo   Nome                          Valor (IP do servidor)   TTL
A      app.suaempresa.com.br         203.0.113.10             300
A      api.suaempresa.com.br         203.0.113.10             300
A      viewer.suaempresa.com.br      203.0.113.10             300
Substitua 203.0.113.10 pelo IP real do servidor (curl -4 ifconfig.me). Se usar Cloudflare, desative o proxy (nuvem laranja) para evitar conflitos com o TLS do Caddy.
Verificando a propagação DNS (aguarde de 1 a 30 minutos após criar os registros):
dig +short app.suaempresa.com.br
dig +short api.suaempresa.com.br
dig +short viewer.suaempresa.com.br
Porta DICOM (C-STORE): A porta 4242 é usada por modalities (tomógrafos, RX) para enviar imagens. Não precisa de subdomínio — as modalities são configuradas com o IP direto do servidor:
sudo ufw allow 4242/tcp

9. Suba o Caddy (reverse proxy + TLS automático) O Caddy já está incluído no docker-compose.prod.yml como container. A config está em docker/caddy/Caddyfile e é montada automaticamente.
Só execute este passo (e o build da stack) após o DNS ter propagado — o Caddy usa os domínios para emitir os certificados via Let’s Encrypt. Se os domínios não apontarem para o servidor, a emissão falhará.
Certifique-se de que ACME_EMAIL está preenchido no .env raiz e que as portas 80 e 443 estão abertas no firewall:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
O Caddy sobe junto com a stack no passo 4. Se precisar reiniciar só o Caddy:
docker compose -f docker-compose.yml -f docker-compose.prod.yml restart caddy
docker logs pacs_caddy --tail 30   # verificar emissão do certificado
O Caddy obtém e renova certificados TLS via Let’s Encrypt automaticamente. Os certificados são persistidos no volume caddy_data. Troubleshooting — NXDOMAIN no log do Caddy: Se docker logs pacs_caddy mostrar algo como:
DNS problem: NXDOMAIN looking up A for <dominio> - check that a DNS record exists for this domain
Significa que o Let’s Encrypt não achou os registros A dos seus domínios. Checklist:
  1. Os 3 registros A realmente existem no provedor de DNS? → dig +short app.seudominio.com.br
  2. Os domínios no Caddyfile batem exatamente com o DNS e com o .env?
  3. Se usar Cloudflare, o proxy (nuvem laranja) está desativado? Com proxy ativo o challenge TLS-ALPN do Caddy não fecha — desative até o primeiro cert ser emitido.
  4. As portas 80 e 443 estão abertas no firewall do servidor e do provedor cloud (security group / ufw)?
Depois de corrigir, force nova tentativa:
docker compose -f docker-compose.yml -f docker-compose.prod.yml restart caddy
docker logs pacs_caddy -f
pacs_orthanc_cors_proxy — container nginx que autentica as requisições DicomWeb do OHIF antes de repassar ao Orthanc. Em produção é acessado via path /dicom-proxy/ sob o subdomínio viewer.*. Sobe junto com a stack — sem ele o OHIF não consegue carregar imagens.
10. Crie o primeiro usuário super admin O seed não é rodado em produção. Para criar o primeiro acesso é preciso criar um tenant da plataforma + uma unit principal + o super admin (o campo tenant_id em users é NOT NULL — mesmo o super admin precisa pertencer a um tenant; o comportamento “acesso global” vem da flag is_super_admin, não da ausência de tenant).
docker exec -it pacs_backend php artisan tinker
Dentro do tinker (executar em ordem):
$platformTenant = \App\Models\Tenant::create([
    'name'      => 'PACS Cloud',
    'slug'      => 'pacs-cloud',
    'is_active' => true,
]);

$platformUnit = \App\Models\Unit::create([
    'tenant_id' => $platformTenant->id,
    'name'      => 'Unidade Principal - PACS Cloud',
    'slug'      => 'unidade-principal-pacs-cloud',
    'is_active' => true,
]);

\App\Models\User::create([
    'tenant_id'      => $platformTenant->id,
    'unit_id'        => $platformUnit->id,
    'name'           => 'Super Admin',
    'email'          => 'admin@suaempresa.com.br',
    'password'       => bcrypt('troque-esta-senha'),
    'is_super_admin' => true,
]);
Troque o email e a senha imediatamente após o primeiro login. Este tenant/unit da plataforma é interno — os tenants reais das clínicas você cria depois pelo painel super admin (/admin/tenants).
11. Verifique o status
# Todos os containers saudáveis?
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps

# Backend respondendo?
curl https://api.suaempresa.com.br/up

# Logs
docker logs pacs_backend --tail 50
docker logs pacs_worker --tail 50

Comandos de produção

# Alias útil para não repetir os -f toda vez
alias dc-prod="docker compose -f docker-compose.yml -f docker-compose.prod.yml"

# Subir
dc-prod up -d

# Parar
dc-prod down

# Rebuild após atualização de código
dc-prod up -d --build

# Rodar uma migration nova (após deploy)
docker exec pacs_backend php artisan migrate --force

# Ver logs
docker logs pacs_backend -f
docker logs pacs_worker -f

# Recarregar backend (sem rebuild)
docker exec pacs_backend php artisan octane:reload

Fazendo um novo deploy (fluxo completo)

Passo 1 — no repositório (local ou GitHub): mesclar develop em main A forma recomendada é abrir um Pull Request no GitHub:
develop → main
Revise os commits, aprove e faça o merge. Alternativamente, via linha de comando:
git checkout main
git merge develop
git push origin main
Passo 2 — no servidor: atualizar e reconstruir
cd PacsTechnik
git pull origin main

# Reconstruir imagens e reiniciar containers
dc-prod up -d --build

# Rodar migrations novas (se houver — sempre seguro rodar)
docker exec pacs_backend php artisan migrate --force
O Dockerfile.prod já roda config:cache, route:cache e view:cache durante o build. O artisan config:clear só é necessário se você alterar variáveis de ambiente sem fazer rebuild — nesse caso, recarregue apenas o backend:
docker exec pacs_backend php artisan octane:reload

Backup

O projeto inclui scripts/backup.sh — faz dump comprimido do PostgreSQL com retenção de 7 dias. Configurar cron (uma vez, no servidor):
chmod +x /opt/projetos/PacsTechnik/scripts/backup.sh

# Editar crontab
crontab -e

# Adicionar (roda todo dia às 03:00):
0 3 * * * /opt/projetos/PacsTechnik/scripts/backup.sh >> /var/log/pacs_backup.log 2>&1
Backup manual:
/opt/projetos/PacsTechnik/scripts/backup.sh
# Arquivo gerado em /opt/backups/pacs/postgres_YYYYMMDD_HHMMSS.sql.gz
Restaurar:
gunzip -c /opt/backups/pacs/postgres_<data>.sql.gz | docker exec -i pacs_postgres psql -U pacs_admin pacs_database
Imagens DICOM ficam no volume pacs_orthanc_data. O Docker Compose prefixa automaticamente o nome com ${COMPOSE_PROJECT_NAME}_ — com o padrão do projeto resulta em pacs_cloud_pacs_orthanc_data. Para localizar no host:
docker volume inspect pacs_cloud_pacs_orthanc_data | grep Mountpoint
Para backup do volume de imagens (snapshot manual):
docker run --rm -v pacs_cloud_pacs_orthanc_data:/data -v /opt/backups/pacs:/backup \
  alpine tar czf /backup/orthanc_$(date +%Y%m%d).tar.gz -C /data .

Checklist pré-deploy

Antes de cada deploy em produção, verifique: Variáveis de ambiente — backend/.env:
  • APP_ENV=production
  • APP_DEBUG=false
  • APP_KEY gerada (php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;")
  • APP_URL=https://api.suaempresa.com.br
  • LOG_LEVEL=error
  • JWT_SECRET gerada com php artisan jwt:secret
  • JWT_TTL=480 (8 horas)
  • FRONTEND_URL=https://app.suaempresa.com.br
  • SESSION_SECURE_COOKIE=true
  • DB_PASSWORD / REDIS_PASSWORD / ORTHANC_WEBHOOK_SECRET (mesmos do .env raiz)
Variáveis de ambiente — .env raiz:
  • REDIS_PASSWORD (forte, gerada com openssl rand -hex 16)
  • ORTHANC_WEBHOOK_SECRET (gerada com openssl rand -hex 32)
  • NEXT_PUBLIC_API_BASE_URL=https://api.suaempresa.com.br/api
  • NEXT_PUBLIC_OHIF_VIEWER_URL=https://viewer.suaempresa.com.br
  • ALLOWED_ORIGIN=https://viewer.suaempresa.com.br
  • ACME_EMAIL=seuemail@suaempresa.com.br
  • BACKEND_CPU_LIMIT / FRONTEND_CPU_LIMIT / WORKER_CPU_LIMIT / QUEUE_CPU_LIMIT (nunca exceder nproc)
DNS e TLS:
  • Registros A criados para app, api e viewer apontando para o IP do servidor
  • DNS propagado — dig +short api.suaempresa.com.br retorna o IP correto
  • Portas 80 e 443 abertas no firewall (necessárias para o Caddy e o Let’s Encrypt)
  • Porta 4242 aberta no firewall (para receber DICOM das modalities)
  • Container pacs_caddy rodando — docker logs pacs_caddy --tail 20 mostra certificado emitido
  • Certificado TLS válido — curl https://api.suaempresa.com.br/up retorna 200
Stack:
  • docker compose ps — todos os containers healthy ou running
  • curl https://api.suaempresa.com.br/up retorna 200
Para o checklist completo de segurança e infraestrutura, veja DEV-TO-PROD.md.