Este documento define o contrato mínimo que qualquer projeto em /opt/server/projetos/<nome>/ deve cumprir pra rodar limpo dentro do server-stack. É opinado por design — segue os padrões aqui e o projeto encaixa sem fricção; foge deles e cria atrito que vai cobrar caro depois.

Escopo

Aplica a: projetos que rodam em containers Docker dentro de /opt/server/projetos/<nome>/, expostos pelo NPM, consumindo a infra compartilhada (Postgres, Redis). Não aplica a: binários standalone, scripts cron, jobs disparados pelo n8n, ou qualquer coisa que não rode permanentemente como serviço web. O que este documento NÃO regula:
  • Linguagem, framework ou arquitetura interna do projeto
  • Padrões de código (tipo style, naming de classes, organização de pastas dentro do app)
  • Decisões de domínio
Se está aqui, é porque interfere com a plataforma (rede, recursos compartilhados, deploy).

Arquivos obrigatórios no root do projeto

ArquivoFunção
DockerfileComo buildar a imagem
docker-compose.ymlComo subir os containers do projeto
.dockerignoreExcluir lixo do build context (.git, vendor/, node_modules/, .env, etc.)
.env.exampleTemplate versionado das variáveis de ambiente, sem segredos
CLAUDE.md(Recomendado) Apontar Claude do projeto pra estes standards e descrever o domínio
O que NÃO deve existir:
  • docker-compose.staging.yml / docker-compose.prod.yml (este é servidor mono-ambiente)
  • Containers de banco/cache/proxy próprios (use a infra compartilhada)
  • entrypoint.sh complexo orquestrando wait-for-DB + migrate + cache + supervisord (use mecanismos do compose e do framework)
  • .env versionado (sempre .gitignore)

Contrato do docker-compose.yml

Template mínimo:
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: <projeto>-app:latest
    container_name: <projeto>-app
    restart: unless-stopped
    volumes:
      - ./.env:/app/.env
      - ./storage:/app/storage      # ou equivalente da stack
    networks:
      - server_net

networks:
  server_net:
    external: true
Regras:
  • Service app é o container web principal. Variantes (worker, queue, scheduler) ficam como app-queue, app-worker, etc.
  • container_name explícito com prefixo do projeto. Sem isso, Docker prefixa pelo nome da pasta e o DNS interno fica imprevisível.
  • restart: unless-stopped sempre — sobrevive a reboots do host.
  • Sem ports: no host — acesso externo é via NPM Proxy Host. Exceção em Portas administrativas abaixo.
  • networks: [server_net] external — a rede é criada pelo bootstrap.sh da plataforma; o compose só consome.
  • Bind mounts pros arquivos que precisam persistir: .env (lido pelo app) e storage/ (uploads/logs/cache).

Convenções de naming

Consistência aqui faz a diferença entre “intuitivo” e “decorebá”.
ItemPadrãoExemplo
Repo no GitHublivre (PascalCase ou kebab-case)SistemaShalom
Pasta no servidorigual ao repo/opt/server/projetos/SistemaShalom/
Deploy name (slug curto)lowercase, sem hífens se possívelshalom
container_name<deploy>-<role>shalom-app, shalom-queue
image<deploy>-<role>:latestshalom-app:latest
Subdomínio público<deploy>.<dominio>shalom.example.com
DB no Postgres compartilhadoigual ao deploy ou bem próximosistemashalom
Role do DBigual ao DB namesistemashalom
Regra mental: o deploy name é o “endereço” do projeto. Container, subdomínio, DB e secrets file (infra/postgres/secrets/<deploy>.env) usam ele como prefixo.

Banco de dados

Use o Postgres compartilhado do server-stack. Provisionamento por projeto:
cd /opt/server/infra/postgres
./create-db.sh <deploy-name>
O script cria DB + role + senha aleatória, e salva tudo em infra/postgres/secrets/<deploy>.env. Detalhes em Criar bancos. Depois disso, no .env do projeto:
DB_CONNECTION=pgsql
DB_HOST=postgres        # nome do container na server_net
DB_PORT=5432
DB_DATABASE=<deploy>
DB_USERNAME=<deploy>
DB_PASSWORD=<copiado de secrets/<deploy>.env>
Para Redis (se o projeto usar): reusa o infra/redis/.env. Pra evitar coleção de keys entre projetos:
  • Use prefixos no app (<deploy>:cache:foo)
  • OU use DBs diferentes (redis://default:PASS@redis:6379/3)
Não suba Postgres ou Redis próprio no projeto. Quebra o padrão “uma infra pra todos”, aumenta footprint e duplica backup.

Reverse proxy e portas

Apenas o NPM publica portas no host (80/443). Projetos não publicam portas externas. Acesso externo = configurar Proxy Host na UI do NPM apontando pro container do projeto.

App principal

# no compose do projeto: SEM ports:
services:
  app:
    # ... sem `ports:`
No NPM, criar Proxy Host:
  • Domain: <deploy>.<seu-dominio>
  • Forward Hostname: <container_name> (ex.: shalom-app)
  • Forward Port: 80 (interno do app)
  • SSL: Request new + Force SSL + HTTP/2
Detalhes em Nginx Proxy Manager.

Portas administrativas

Se o projeto tem uma UI de admin separada (ex.: porta 8025 do Mailhog), bind em loopback:
ports:
  - "127.0.0.1:8025:8025"
Por quê: o Docker grava regras de iptables que passam por cima do UFW quando publica portas. ufw deny 8025 não bloqueia portas Docker. Bindar em 127.0.0.1 é a única defesa confiável que não depende de firewall do host. Acesso externo dessas portas vira SSH tunnel ou Proxy Host adicional no NPM.

Volumes e permissões

Bind mounts em ./<pasta>/ são preferidos a named volumes — backup é só copiar a pasta, fica visível no filesystem do host.
volumes:
  - ./storage:/app/storage

Gotcha de UID em bind mounts

A maioria das imagens oficiais (Postgres, Redis, etc.) faz self-chown do volume no startup, então bind mount funciona out-of-the-box. Outras não. Caso real registrado: n8n. O container roda como user node (UID 1000) e tenta escrever em /home/node/.n8n/config. Se a pasta ./data foi criada pelo Docker como root (acontece no primeiro up se a pasta não existia), o n8n falha em loop com EACCES: permission denied. Padrão defensivo: se a imagem que você vai usar não faz self-chown e roda como user específico, pré-crie a pasta com o dono correto. A forma mais clean (sem sudo) é via container alpine descartável:
docker run --rm -v $(pwd)/data:/data alpine chown -R <UID>:<GID> /data
O bootstrap.sh da plataforma faz isso automaticamente pro n8n. Se você adicionar um serviço novo com o mesmo perfil (user não-root + sem self-chown), adicione um bloco equivalente lá.

Variáveis de ambiente

  • .env.example versionado, com placeholders vazios. Documenta o que precisa ser configurado.
  • .env real gitignored sempre. Nunca commit.
  • Single env model: um único .env, sem .env.staging ou .env.prod. Esse servidor é dev-only; produções vivem em outro lugar.
  • APP_KEY (e similares) geradas uma vez via openssl rand -base64 32 (ou php artisan key:generate) e copiadas pro .env. Não viram no .env.example.
Quem cria o .env na primeira deployar:
cp .env.example .env
sed -i "s|^APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|" .env
# popular DB_PASSWORD com o valor de infra/postgres/secrets/<deploy>.env

Dockerfile patterns

Camadas cacheáveis

Ordem das camadas afeta diretamente o tempo de build em iterações. Padrão:
FROM <base>

# 1. Deps de sistema (raramente mudam)
RUN apt-get update && apt-get install -y ...

# 2. Deps da linguagem (mudam quando lockfile muda)
COPY package.json package-lock.json ./
RUN npm ci

# 3. Código (muda em todo commit)
COPY . .

# 4. Build de assets / autoloader (rápido)
RUN npm run build

Comandos stateful: build time, não runtime

Comandos que produzem efeitos no filesystem do container (storage:link, route:cache, view:cache, criação de symlinks) devem rodar no Dockerfile durante build, não no entrypoint. Por quê: se rodar no entrypoint, o efeito vive só no filesystem desse container. Quando você faz docker compose up -d --force-recreate ou um restart, perde o estado e precisa rodar de novo. Bake na imagem garante que o estado está lá toda vez que o container sobe.
# Bom — symlink dentro da imagem, sobrevive a recreates
RUN mkdir -p storage/app/public && php artisan storage:link
# Ruim — entrypoint complicado pra fazer o que o build deveria
ENTRYPOINT ["/entrypoint.sh"]   # com storage:link, cache, etc.
Exceção: migrate deve ser manual após deploy, não no entrypoint nem no Dockerfile. Migrations em runtime criam race conditions quando você sobe múltiplas réplicas; em build, a imagem fica “amarrada” a um schema específico. Padrão é:
docker compose up -d --build
docker compose exec app php artisan migrate --force

Recomendação por stack

  • Laravel / PHP: FrankenPHP base (dunglas/frankenphp:latest). Veja Laravel-specific pra detalhes.
  • Node: node:lts-alpine (multi-stage com build separado em produção)
  • Python: python:slim

Anti-padrões

Lista negra de coisas que parecem boa ideia até cobrarem caro.

❌ Postgres/Redis próprio dentro do compose do projeto

Por quê: Quebra o “uma infra pra todos”, duplica backup, gasta RAM, e a versão da DB fica desalinhada entre projetos. Alternativa: Use o compartilhado, ver Banco de dados.

❌ Publicar portas no host (exceto admin loopback)

Por quê: Expõe diretamente, conflita com outros projetos, força você a tracking manual de portas livres, bypassa o UFW. Alternativa: NPM Proxy Host pro app principal; 127.0.0.1:porta:porta pra UIs admin.

❌ Múltiplos compose files (compose.staging.yml, compose.prod.yml)

Por quê: Esse servidor é dev-only. Ambientes separados viram outros servidores ou outras redes Docker, não overrides de compose dentro do projeto. Alternativa: Um docker-compose.yml, um .env. Quando precisar de homolog/prod, é setup separado. Por quê: Race conditions, perda de estado em recreates, deploy não-determinístico. Alternativa: Stateful no Dockerfile (build time), migrate manual após deploy.

❌ Confiar em UFW pra portas publicadas pelo Docker

Por quê: Docker escreve regras de iptables que rodam antes das do UFW. ufw deny 81/tcp não bloqueia uma porta 81:81 no compose. Alternativa: 127.0.0.1:81:81 no compose (só loopback) ou regra na chain DOCKER-USER do iptables.

.env, data/, secrets/ versionados

Por quê: Vazamento permanente em git history. Mesmo private repo, mesmo deletado depois, o blob persiste. Alternativa: .gitignore rigoroso. Veja exemplo no server-stack.

DB::statement com SQL específico de um driver (em projetos Laravel)

Por quê: Quebra portabilidade. MySQL aceita MODIFY COLUMN ENUM(...), Postgres não. Mesmo se hoje você só usa MySQL, amanhã pode mudar. Alternativa: Use Schema::Blueprint (portável). Se realmente precisa SQL bruto, ramifique por driver via DB::connection()->getDriverName().

Onboarding de projeto novo

Se for Laravel, use o template em /opt/server/templates/project/laravel/ — inclui Dockerfile, compose, .env.example e CLAUDE.md pré-configurados conforme este standards. Ver README do template.
Passo a passo pra colocar um projeto novo no servidor:
1

Clone na pasta certa

cd /opt/server/projetos
git clone <repo> <pasta>
cd <pasta>
2

Provisionar DB

/opt/server/infra/postgres/create-db.sh <deploy>
Salva credenciais em infra/postgres/secrets/<deploy>.env.
3

Criar DNS

No registrador, criar CNAME <deploy> apontando pro apex (ou A direto). Ver Configurar DNS.
4

Configurar `.env`

cp .env.example .env
# Gerar APP_KEY + popular DB_PASSWORD do passo 2
5

Subir

docker compose up -d --build
6

Migrar (se aplicável)

docker compose exec app <comando-de-migrate-da-stack>
7

Configurar Proxy Host no NPM

Domain <deploy>.<dominio><container>:80 com Let’s Encrypt. Ver Nginx Proxy Manager.
8

Validar

Abre https://<deploy>.<dominio> no browser. Login / smoke test.

Onboarding de projeto existente

Pra projetos Laravel, o template em /opt/server/templates/project/laravel/ tem o Fluxo B documentado no README — copia os arquivos de infra, mergeia .env.example e CLAUDE.md mantendo o que era do projeto.
Pra trazer um projeto que já roda em outro servidor (ou em outro padrão de Docker) pro server-stack, segue um playbook adaptado do caso real do SistemaShalom (Laravel + MySQL → Postgres).
1

Auditoria

Antes de mexer em qualquer coisa, mapeie:
  • Já tem Dockerfile? Se sim, qual padrão (php-fpm+nginx, FrankenPHP, etc.)?
  • Já tem docker-compose? Quais serviços ele sobe?
  • Migrations são portáveis ou usam SQL específico de driver?
  • O DB de prod (se existir) tem dados a preservar?
  • Existem múltiplas branches divergentes (cuidado com isso — cheque git branch -r)
2

Substituir setup Docker

  • Dropar docker/ (entrypoint, nginx.conf, supervisord.conf) se existirem
  • Substituir Dockerfile pelo padrão da stack (FrankenPHP pra Laravel, etc.)
  • Substituir docker-compose.yml pelo padrão server-stack (server_net, sem postgres próprio, sem porta exposta)
  • Dropar compose.staging/prod.yml e .env.staging/prod.example
3

Adaptar `.env.example`

Apontar pro Postgres compartilhado: DB_HOST=postgres, DB_DATABASE=<deploy>, etc.
4

(Stack-specific) Resolver pegadinhas

Pra Laravel veja Laravel-specific — TrustProxies, migrations portáveis, etc.
5

(Opcional) Squash de migrations

Se o projeto tem 20+ migrations com refactors históricos, vale consolidar em N migrations representando o estado final. Procedimento documentado por projeto se for o caso.
6

Restore do DB de prod (se aplicável)

  1. pg_dump (ou conversão MySQL→PG via pgloader) no servidor antigo
  2. Transfer pro servidor novo (NÃO via git — ver anti-padrões)
  3. create-db.sh <deploy> pra preparar role + DB destino
  4. psql ... -d <deploy> < dump.sql pra restaurar
  5. Se houve squash: aplicar database/squash-transition.sql (ou equivalente) pra alinhar a tabela migrations
7

Validar

  • migrate:status (se Laravel) — tudo Yes
  • Smoke test no browser
  • Verificar dados via tinker / psql
8

Cutover (quando for hora)

DNS apontando pro novo servidor. Manter o velho como fallback até estabilizar.

Páginas relacionadas