---
id: twelve-factor-app
domain: devops
agents: [devops]
when: "ao avaliar se uma aplicação está pronta para cloud / deploy contínuo"
---

# Twelve-Factor App — o checklist enforçável de cloud-readiness

## O problema

"Pronto pra produção" costuma ser uma opinião. O dono do serviço jura que está, o deploy quebra
às 3h da manhã, e ninguém consegue dizer *qual* propriedade faltou. O Twelve-Factor (Adam Wiggins,
Heroku) existe para transformar essa opinião em **12 checagens binárias** — cada fator é uma
pergunta com resposta sim/não, e cada "não" é uma classe específica de incidente futuro.

Os tells de uma app que **não** está pronta:

- Credencial de banco hardcoded num `config/database.yml` commitado → vaza no primeiro repo público.
- "Funciona na minha máquina" porque dev usa SQLite e prod usa Postgres.
- App precisa de Apache/Tomcat instalado e configurado *fora* dela pra subir.
- `kill -9` na app perde jobs porque ninguém trata SIGTERM.
- Escalar = comprar máquina maior, porque o processo guarda sessão em memória local.
- Migração de banco roda por SSH manual com um script que vive fora do repo e desincroniza.

Este pack é a régua. Para cada fator: o **anti-padrão concreto** + a **correção**, com snippet real —
não prosa. A regra-mãe do Twelve-Factor sobre config (Fator III) serve de litmus test pro espírito
todo: *"whether the codebase could be made open source at any moment, without compromising any
credentials."* Se você não consegue abrir o repo agora, falhou.

## O conhecimento

### I — Codebase: um codebase, muitos deploys

*"If there are multiple codebases, it's not an app – it's a distributed system."*
*"Multiple apps sharing the same code is a violation of twelve-factor."*

Correlação **1:1** entre codebase e app. Um codebase (em git), N deploys (prod, staging, local). Código
compartilhado entre dois apps vira **biblioteca** puxada via dependency manager — não um diretório
copiado nem um submódulo que dois serviços editam.

| Ruim | Bom |
|---|---|
| `api/` e `worker/` no mesmo repo, mas são dois apps com lifecycles diferentes → ainda 1 repo, OK; o problema é o inverso | 1 codebase = 1 app; dois apps que precisam do mesmo helper → extrai `lib-shared` versionada |
| `cp -r ../common ./vendor/common` em dois serviços | `common` publicado num registry privado; cada app declara `common@1.4.2` no manifest |
| Branch `production` com hotfixes que nunca voltam pra `main` | mesma codebase, deploys diferem só por *qual commit* + config |

> Deploy = instância rodando da app. Prod, staging e o laptop de cada dev são deploys do **mesmo**
> codebase, possivelmente em commits diferentes.

### II — Dependencies: declare e isole, explicitamente

*"A twelve-factor app never relies on implicit existence of system-wide packages. It declares all
dependencies, completely and exactly, via a dependency declaration manifest."*
*"Dependency declaration and isolation must always be used together – only one or the other is not
sufficient."*

E o item esquecido: *"Twelve-factor apps also do not rely on the implicit existence of any system
tools. Examples include shelling out to ImageMagick or curl."*

```dockerfile
# RUIM — depende de pacote do sistema e de uma tool implícita
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3   # versão imprevisível
COPY . /app
# em algum lugar do código: subprocess.run(["convert", "in.png", "out.jpg"])  # ImageMagick implícito
CMD ["python3", "/app/main.py"]
```

```dockerfile
# BOM — runtime pinado, deps declaradas e isoladas, tool de sistema vendorizada
FROM python:3.12-slim
# ImageMagick declarado, não assumido
RUN apt-get update && apt-get install -y --no-install-recommends imagemagick=8:6.* \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt   # isolamento via imagem + venv implícito
COPY . .
CMD ["python", "main.py"]
```

Manifesto + isolamento juntos: `requirements.txt` + venv/container, `package.json` + `node_modules`
(com lockfile), `Gemfile` + Bundler, `go.mod`. Lockfile commitado é obrigatório — `^1.2.0` sem lock
não é declaração exata. Litmus: *um dev novo sobe o ambiente com só o runtime da linguagem + o
dependency manager*, nada de "ah, instala o ImageMagick na mão primeiro".

### III — Config: no ambiente, nunca no código

*"An app's config is everything that is likely to vary between deploys."*
*"The twelve-factor app stores config in environment variables."*
*"A litmus test... is whether the codebase could be made open source at any moment, without
compromising any credentials."*

Config = o que varia entre deploys (credenciais, handles de backing service, hostnames). **Não** é
config: roteamento interno, wiring de módulos — isso é código e fica no código.

```python
# RUIM — segredo no código, "ambientes nomeados" (combinatorial explosion)
DATABASE = {
    "development": "postgres://localhost/myapp_dev",
    "production":  "postgres://admin:S3cr3t@prod-db.internal/myapp",  # vaza no git
}
db_url = DATABASE[os.environ.get("RAILS_ENV", "development")]
```

```python
# BOM — env var granular, ortogonal, fora do código
db_url = os.environ["DATABASE_URL"]   # falha alto se ausente; nunca tem default de prod
```

Anti-padrão central: **agrupar config em ambientes nomeados** (`development`/`staging`/`production`).
*"This method does not scale cleanly... a combinatorial explosion of config which makes managing
deploys of the app very brittle."* Cada var é um controle **granular e ortogonal**: `DATABASE_URL`,
`REDIS_URL`, `STRIPE_KEY` — não um bloco `production`.

| Sinal | Veredito |
|---|---|
| `grep -rE 'password\|secret\|api_key\|://.*:.*@' src/` retorna algo | FALHA — segredo no código |
| `.env` no `.gitignore`; `.env.example` commitado só com chaves vazias | OK |
| Código tem `if env == "production"` ramificando comportamento | Cheiro de ambiente nomeado |

### IV — Backing services: recursos anexáveis

*"The code for a twelve-factor app makes no distinction between local and third party services."*
*"...swap out a local MySQL database with one managed by a third party (such as Amazon RDS) without
any changes to the app's code."* — *"resources can be attached to and detached from deploys at will."*

Todo serviço consumido pela rede (Postgres, MySQL, RabbitMQ/Beanstalkd, SMTP/Postmark, Memcached/
Redis, S3) é um **recurso anexável**, acessado por um *handle* (URL) que vem da config (Fator III).
Trocar o Postgres local pelo RDS gerenciado = mudar uma env var, **zero deploy de código**.

```yaml
# RUIM — host do broker hardcoded; trocar de provedor exige rebuild
services:
  worker:
    environment:
      AMQP: "amqp://guest:guest@localhost:5672"   # colado, e ainda com credencial
```

```yaml
# BOM — handle injetado; local e gerenciado são indistinguíveis pro código
services:
  worker:
    environment:
      AMQP_URL: ${AMQP_URL}   # amqp://localhost em dev, CloudAMQP em prod — mesma app
```

Litmus: se o banco corromper, o ops troca o `DATABASE_URL` pra um réplica/backup e sobe — **sem
tocar no código**. Se precisa editar e rebuildar, o serviço não está "anexado", está soldado.

### V — Build, release, run: três estágios estritamente separados

*"The twelve-factor app uses strict separation between the build, release, and run stages."*
*"Releases are an append-only ledger and a release cannot be mutated once it is created. Any change
must create a new release."*

- **Build**: código → bundle executável (compila, instala deps, gera assets). Tolera complexidade.
- **Release**: build + config daquele deploy → release com **ID único** (`v100`, `2011-04-06-20:32:17`),
  **imutável**.
- **Run**: executa a release no ambiente. Deve ser mínimo (quebra de madrugada, sem dev por perto).

```bash
# RUIM — mutar código no servidor de run (sem release, sem rollback)
ssh prod 'cd /app && git pull && npm install && pm2 restart all'
```

```bash
# BOM — build artefato imutável, release com ID, run só seleciona a release
docker build -t myapp:$GIT_SHA .                 # BUILD
docker push registry/myapp:$GIT_SHA              # release = imagem + config no orquestrador
kubectl set image deploy/myapp app=registry/myapp:$GIT_SHA   # RUN seleciona a release
# rollback = reapontar pra release anterior; nunca "editar em produção"
```

Teste: dá pra fazer **rollback** pra release anterior por ID, instantâneo, sem rebuildar? Se não, os
estágios estão misturados. Não se altera código em runtime — *"changes cannot propagate back to the
build stage."*

### VI — Processes: stateless, share-nothing

*"Twelve-factor processes are stateless and share-nothing."*
*"Sticky sessions are a violation of twelve-factor and should never be used or relied upon."*

Memória/filesystem do processo = *"a brief, single-transaction cache"* — nunca assuma que algo
cacheado estará lá num request futuro (*"a future request will be served by a different process"*).
Qualquer dado que precisa persistir vai pra **backing service** (Fator IV).

```js
// RUIM — estado na memória do processo + sticky session
const sessions = {};                       // some no restart/scale
app.post("/login", (req, res) => { sessions[req.ip] = user; });
// e no LB: ip_hash;  → gruda usuário no mesmo processo
```

```js
// BOM — estado em datastore com expiração; processo é descartável
app.use(session({
  store: new RedisStore({ client: redis }),  // Redis/Memcached, com TTL
  // qualquer instância serve qualquer request
}));
```

Upload chega num processo, processamento num outro? Não pode depender do arquivo estar no disco local
— manda pro S3 (handle na config). Sticky session é o tell #1 de violação aqui.

### VII — Port binding: self-contained, exporta via porta

*"The web app exports HTTP as a service by binding to a port, and listening to requests coming in on
that port."* A app inclui o **webserver como biblioteca** (Tornado, Jetty, o `http` embutido) —
não roda *dentro* de um container Apache/Tomcat injetado em runtime.

```xml
<!-- RUIM — app é um .war que depende de um Tomcat externo instalado e configurado -->
<!-- deploy = copiar para /opt/tomcat/webapps/ e rezar pra versão bater -->
```

```js
// BOM — a própria app faz bind na porta vinda da config
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`bound on :${port}`));
// em prod, a routing layer mapeia o hostname público -> esta porta
```

*"One app can become the backing service for another app, by providing the URL... as a resource
handle in the config for the consuming app."* — é o Fator IV fechando o círculo. Estende além de
HTTP (Redis protocol, XMPP). Teste: `docker run -p 8080:PORT myapp` responde sem nenhum webserver
externo? Então passa.

### VIII — Concurrency: escale horizontal via process model

*"In the twelve-factor app, processes are a first class citizen."*
*"Twelve-factor app processes should never daemonize or write PID files."*

Tipos de processo (web, worker) com workloads distintos; escala = **adicionar processos** (a *process
formation*), não engordar um. Lifecycle (output, crash recovery, shutdown) é do **process manager**
externo (systemd, Kubernetes, Foreman) — a app não se daemoniza.

```ini
# RUIM — um processo monolítico que se daemoniza e escreve PID
[program]
command=/app/server --daemon --pidfile=/var/run/app.pid   # escala só vertical
```

```procfile
# BOM — Procfile: tipos de processo, foreground, escala horizontal
web:    gunicorn app:server --bind 0.0.0.0:$PORT
worker: celery -A app worker --loglevel=info
# escala: kubectl scale deploy/web --replicas=10  (mais processos, não maior)
```

Tells de violação: `--daemon`, `nohup ... &`, escrever `.pid`, ou "vamos aumentar a RAM da VM" como
única estratégia de escala. O processo roda em foreground; o orquestrador cuida do resto.

### IX — Disposability: startup rápido, shutdown gracioso, robusto à morte súbita

*"Processes shut down gracefully when they receive a SIGTERM signal from the process manager."*
*"Ideally, a process takes a few seconds from the time the launch command is executed until the
process is up and ready to receive requests or jobs."*

- **Startup rápido** (segundos) → scale e deploy ágeis.
- **SIGTERM gracioso**: web para de aceitar novas conexões e finaliza as em curso; worker *"returning
  the current job to the work queue"*.
- **Robusto à morte súbita** (`kill -9`, falha de hardware): jobs **idempotentes/reentrantes** e
  backend de fila que devolve o job no disconnect (RabbitMQ NACK, Beanstalkd auto-return).

```js
// RUIM — ignora SIGTERM; orquestrador manda SIGKILL após o grace period e perde requests/jobs
// (nenhum handler) → conexões em curso morrem no meio
```

```js
// BOM — drena conexões no SIGTERM
process.on("SIGTERM", async () => {
  server.close(() => console.log("http drained"));   // para de aceitar, termina em curso
  await worker.close();                               // devolve job atual à fila
  process.exit(0);
});
```

Teste: `docker stop` (= SIGTERM + grace) não derruba request em andamento nem perde job? E um
`kill -9` no worker faz o job voltar pra fila e ser reprocessado sem efeito colateral? Então passa.

### X — Dev/prod parity: mantenha dev, staging e prod o mais parecidos possível

*"The twelve-factor developer resists the urge to use different backing services between development
and production, even when adapters theoretically abstract away any differences."*

Três gaps a fechar:

| Métrica | App tradicional | Twelve-Factor App |
|---|---|---|
| Tempo entre deploys (time gap) | Semanas | Horas |
| Autores do código vs quem faz deploy (personnel gap) | Pessoas diferentes | Mesmas pessoas |
| Dev vs prod (tools gap) | Divergentes | O mais parecidos possível |

```yaml
# RUIM — SQLite em dev, Postgres em prod (mesmo "com ORM, é igual")
# dev:  DATABASE_URL=sqlite:///dev.db
# prod: DATABASE_URL=postgres://...
# resultado: bug de tipo/locking só aparece em produção, depois do merge
```

```yaml
# BOM — mesmo backing service em todo ambiente, via container
# docker-compose.yml (dev) sobe o MESMO Postgres da prod
services:
  db:
    image: postgres:16        # mesma major version que prod
```

Docker/Compose tornaram "rodar o serviço de verdade localmente" barato — não há mais desculpa de
custo pra SQLite-em-dev. O risco de incompatibilidade *"undermines continuous deployment"*.

### XI — Logs: event stream pra stdout, sem gerenciar arquivo

*"A twelve-factor app never concerns itself with routing or storage of its output stream."*
Cada processo escreve seu event stream **unbuffered pra stdout**. Em dev, o dev vê no terminal; em
prod, *"each process' stream will be captured by the execution environment... and routed to one or
more final destinations."*

```python
# RUIM — app gerencia arquivo, rotação, destino (responsabilidade que não é dela)
logging.basicConfig(
    filename="/var/log/myapp/app.log",     # disco local some no scale (Fator VI)
    handlers=[RotatingFileHandler(...)],   # rotação é trabalho do ambiente
)
```

```python
# BOM — escreve pra stdout, sem buffer; o ambiente coleta e roteia
import sys, logging
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
# (em Python, garanta unbuffered: PYTHONUNBUFFERED=1 no ambiente)
```

Tells: a app abre `FileHandler`, faz rotação, ou tenta mandar pro Datadog/ELK direto. Não — ela só
emite linhas em stdout (JSON estruturado é ótimo); coleta, agregação e roteamento são do execution
environment (fluentd, Loki, CloudWatch).

### XII — Admin processes: tarefas one-off no ambiente idêntico

*"One-off admin processes should be run in an identical environment as the regular long-running
processes of the app."* — contra a mesma release, mesma codebase e config. E o cuidado:
*"Admin code must ship with application code to avoid synchronization issues."*

```bash
# RUIM — migração roda por um script que vive FORA do repo, com conexão própria
ssh dba@prod 'psql $PROD -f /home/dba/manual_migrations/add_column.sql'
# desincroniza do código; usa env/credencial diferente do app
```

```bash
# BOM — migração versionada no repo, mesma release/config, mesmo isolamento de deps
kubectl run migrate --image=registry/myapp:$GIT_SHA --restart=Never \
  --env-from=secretRef:myapp-config -- python manage.py migrate
# REPL idem: bundle exec rails console  /  python manage.py shell
```

A migração e o console usam o **mesmo bundle de deps** dos processos longos (se web é
`bundle exec thin start`, migração é `bundle exec rake db:migrate`). Tell: scripts de manutenção que
não estão no repo, ou que conectam com credenciais/imagem diferentes da app.

## Checklist

Marque cada item. Qualquer "não" é cloud-readiness incompleto.

- [ ] **I Codebase** — 1 repo = 1 app; código compartilhado é biblioteca versionada, não cópia/submódulo editado.
- [ ] **II Deps** — manifesto + lockfile commitados; runtime pinado; nenhuma tool de sistema (curl, ImageMagick) assumida.
- [ ] **III Config** — `grep` por segredo no código volta vazio; tudo em env var granular; repo poderia virar público agora.
- [ ] **IV Backing services** — trocar Postgres/SMTP/Redis = mudar uma env var, zero deploy de código.
- [ ] **V Build/release/run** — artefato imutável, release com ID, rollback por ID instantâneo; nada de `git pull` em prod.
- [ ] **VI Processes** — stateless; sessão/estado em datastore (Redis/Postgres); zero sticky session.
- [ ] **VII Port binding** — app faz bind na `$PORT` por conta própria; não precisa de Apache/Tomcat externo.
- [ ] **VIII Concurrency** — process types num Procfile; escala = mais réplicas; sem daemonizar nem PID file.
- [ ] **IX Disposability** — handler de SIGTERM drena conexões/jobs; startup em segundos; jobs idempotentes; sobrevive a `kill -9`.
- [ ] **X Dev/prod parity** — mesmo backing service (mesma major) em dev e prod; deploys em horas, não semanas.
- [ ] **XI Logs** — app escreve só pra stdout unbuffered; não abre arquivo, não rotaciona, não roteia.
- [ ] **XII Admin processes** — migração/console versionados no repo, contra a mesma release e config.

## Tabela de decisão

| Sintoma observado | Fator violado | Correção imediata |
|---|---|---|
| Credencial encontrada com `grep` no código-fonte | III | Mover pra env var; rotacionar o segredo vazado; `.env` no gitignore |
| `if env == "production"` ramificando lógica | III | Substituir por env vars granulares e ortogonais |
| Trocar de banco/provedor exige editar e rebuildar | IV | Acessar via handle/URL injetado pela config |
| Deploy é `ssh + git pull + restart` | V | Build de imagem imutável com ID; run seleciona release; rollback por ID |
| `kill -9` perde sessões ou jobs em andamento | VI + IX | Estado em Redis/Postgres; handler de SIGTERM; jobs idempotentes |
| LB com `ip_hash` / sticky session | VI | Session store compartilhado com TTL (Redis/Memcached) |
| App precisa de Tomcat/Apache pré-instalado pra subir | VII | Bundlar webserver como lib; bind em `$PORT` |
| Única forma de escalar é VM maior | VIII | Process types + réplicas horizontais; remover daemonização/PID |
| `--daemon`, `nohup &`, `.pid` no comando de start | VIII | Rodar em foreground sob process manager (systemd/k8s) |
| Bug só aparece em prod (SQLite em dev, Postgres em prod) | X | Mesmo backing service em todos os ambientes via container |
| App grava em `/var/log/app.log` e rotaciona | XI | Escrever em stdout unbuffered; deixar coleta/roteamento pro ambiente |
| Migração roda por script fora do repo / credencial própria | XII | Versionar no repo; rodar contra a mesma release/config/imagem |
| Sem lockfile; deps com `^`/`~` sem trava | II | Commitar lockfile; pinar runtime |
| Dois serviços editam o mesmo diretório de código copiado | I | Extrair pra biblioteca versionada no registry |
