In 2011, Heroku published a methodology for building cloud-native applications that are portable, scalable, and maintainable. Twelve principles. Container technology didn't exist yet — but containers turned out to be the best vehicle for enforcing them.
1. What Is the Twelve-Factor App?
The Twelve-Factor App methodology was authored by Adam Wiggins and the Heroku team after observing hundreds of production applications. Its goal: a set of principles that, if followed, produce apps that can be deployed to any cloud, scaled horizontally without architectural changes, and maintained by any developer on the team.
The twelve factors span three concerns:
- Codebase hygiene — how you store and version your app
- Runtime behaviour — how the app starts, configures itself, and exits
- Operational posture — how it fits into the surrounding infrastructure
A container image is an artifact produced from a codebase, containing explicit dependencies, that runs as a stateless, ephemeral process and exports services via port binding. That sentence describes Factors I through VIII almost exactly. Containers don't guarantee twelve-factor compliance — but they make several factors almost unavoidable if you follow best practices.
2. The 12 Factors, Container-Mapped
| # | Factor | Principle | Container Implementation |
|---|---|---|---|
| I | Codebase | One repo, many deploys | One Git repo → one image; different tags per environment (myapp:staging, myapp:prod) |
| II | Dependencies | Explicitly declared and isolated | Dockerfile installs all dependencies into the image — nothing assumed from the host |
| III | Config | Stored in the environment, not the code | Pass config as env vars (-e, env: in Compose) — never bake secrets into the image |
| IV | Backing services | Treat as attached resources via URLs | DB, cache, queue connected via env-var URLs; swappable without code changes |
| V | Build, Release, Run | Strict separation of build, release, and run stages | docker build → push tagged image → docker run; release = image + config, never modified at runtime |
| VI | Processes | Execute as stateless, share-nothing processes | Containers are ephemeral by design; local filesystem is scratch space only |
| VII | Port binding | Export services via port binding | EXPOSE in Dockerfile; -p / ports: at runtime — the app is self-contained, no app server needed |
| VIII | Concurrency | Scale out via the process model | Run multiple container replicas; use docker compose --scale or an orchestrator |
| IX | Disposability | Fast startup, graceful shutdown | Containers start in seconds; handle SIGTERM to drain in-flight requests before exiting |
| X | Dev/Prod parity | Keep dev, staging, and production as similar as possible | Same image runs in every environment — only env vars change |
| XI | Logs | Treat logs as event streams | Write to stdout/stderr; Docker log drivers forward to your logging platform |
| XII | Admin processes | Run admin/management tasks as one-off processes | docker run --rm myapp migrate — run migrations, scripts, or REPL sessions as ephemeral containers |
3. Build → Release → Run Pipeline (Factor V)
Factor V is the most structurally important for container workflows. The key rule: once an image is built, it must never be modified. Config is layered on at release time; the container runs exactly what was released.
# BUILD — produces immutable image
docker build -t myapp:v1.4.2 .
# RELEASE — image + runtime config = a versioned release
# (the image is never changed; env vars supply the config)
docker tag myapp:v1.4.2 registry.example.com/myapp:v1.4.2
docker push registry.example.com/myapp:v1.4.2
# RUN — instantiate the release as one or more containers
docker run -d \
-e DATABASE_URL=postgres://prod-db:5432/app \
-e REDIS_URL=redis://prod-cache:6379 \
registry.example.com/myapp:v1.4.2
SSHing into a container to patch a bug, copying files in, or running apt install at runtime violates Factor V. The fix lives nowhere in version control and disappears on the next deploy. Fix the Dockerfile, build a new image, release and run it.
4. Factors Containers Enforce Naturally
These factors are almost automatic consequences of building and running containers correctly:
Factor III — Config via Environment Variables
Passing -e KEY=VALUE or using env: in Compose is the default way to configure containers. There's no temptation to edit a config file inside the container — the container filesystem is ephemeral. Environment variables are the canonical interface.
services:
api:
image: myapp:v1.4.2
environment:
DATABASE_URL: postgres://db:5432/app
LOG_LEVEL: info
SECRET_KEY: ${SECRET_KEY} # from host env or .env file
Factor V — Build/Release/Run Separation
Docker's image model enforces this architecturally. You cannot modify a built image without rebuilding it. The tag is your release version. docker run is the run stage.
Factor VI — Stateless Processes
Containers have no persistent local storage by default. Anything written to the container filesystem vanishes on removal. This forces you to externalise state to backing services — databases, object stores — exactly as Factor VI requires.
Factor IX — Disposability
Containers are designed to be stopped, removed, and replaced within seconds. The container lifecycle maps directly: docker stop sends SIGTERM → app drains → exit 0. Fast startup and graceful shutdown are requirements, not niceties.
Factor X — Dev/Prod Parity
The same image runs on a laptop, in CI, in staging, and in production. The only difference is environment variables. This eliminates the "works on my machine" class of bugs entirely.
5. Factors You Must Consciously Apply
These factors don't fall out automatically — they require deliberate design choices.
Factor IV — Backing Services as Attached Resources
The principle: your database should be swappable between a local container and a managed RDS instance without any code change. Only the connection URL changes (via env var). This requires discipline:
# Correct: URL from env — swap prod/dev without code change
DATABASE_URL=postgres://localhost:5432/dev # local
DATABASE_URL=postgres://rds.example.com/prod # production
# Wrong: hardcoded hostname or IP inside the application code
db = connect("localhost", 5432, "mydb") # ❌ breaks outside dev
Factor VIII — Concurrency via the Process Model
Scaling out means running more container replicas, not making one container do more. This requires an orchestrator (Compose, Swarm, Kubernetes) and a stateless app (see Factor VI). Common mistakes: in-process job queues that only one replica can drain, session state held in memory, or websocket state not shared via Redis.
# Scale web and worker independently
docker compose up --scale web=4 --scale worker=2
Factor XI — Logs as Event Streams
The app must write to stdout/stderr — not to files, not to syslog, not to a database. Docker captures these streams and routes them to the configured log driver. Common mistake: a legacy app that writes to /var/log/app.log inside the container. The log driver never sees it, log rotation never runs inside the container, and the container's writable layer bloats over time.
# Correct: write to stdout — Docker log drivers handle the rest
console.log(JSON.stringify({ level: "info", msg: "Request received" }));
# Check what your container actually writes
docker logs mycontainer
# Route to a log aggregator via log driver
docker run --log-driver=fluentd --log-opt fluentd-address=localhost:24224 myapp
6. Anti-Patterns
User uploads written to /app/uploads, SQLite databases at /data/app.db, or session files in /tmp — all vanish on container replacement. Use volumes for persistence or externalise to object storage / a proper database.
Running RUN echo "DATABASE_HOST=prod-db" >> /etc/environment in a Dockerfile or COPY .env.production /app/.env embeds secrets and environment-specific config into the image layer — visible in the registry, unversioned, and impossible to vary per environment.
Logging to files inside a container means logs are invisible to docker logs, inaccessible to log drivers, and silently lost on container removal. Always configure your application's logger to use stdout/stderr, even if that requires an adapter.
SSHing into a container to install packages, patch files, or "just fix this one thing" is the most damaging anti-pattern. It produces unreproducible state, defeats immutability, and means the next deploy silently regresses. The container should be replaced, not repaired.
7. Industry: Heroku, Cloud Run, and Fargate
Heroku (the methodology's birthplace) enforces twelve-factor at the platform level. It injects config as env vars, routes logs as streams, runs apps as ephemeral dynos, and refuses persistent local filesystem writes. Twelve-factor wasn't a prescription for Heroku — it was a description of what Heroku's architecture required.
Google Cloud Run takes the same model to containers: your image is deployed with env vars, runs as a stateless, port-binding process, scales from zero to N replicas instantly, and shuts down after a request window. Any container that isn't twelve-factor compliant will break under Cloud Run's scaling model.
AWS Fargate adds Factor X explicitly to its value proposition: the same ECS task definition (image + env config) runs identically in every AWS region and account. No servers to manage, no AMIs to patch — just the image and config.
The common thread: all three platforms treat containers as the primitive, enforce statelessness, and supply config via environment. Teams that adopted twelve-factor early found these platforms required zero architectural changes to adopt.
Hands-On Task: Audit a Dockerfile & Compose Setup
🔬 Task: Score an Application Against the 12 Factors
Given the following (intentionally flawed) setup, identify every twelve-factor violation and propose a fix for each.
# Dockerfile (to audit)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
# Hardcode the environment
ENV NODE_ENV=production
ENV DATABASE_HOST=prod-db.internal
ENV DATABASE_PASSWORD=supersecret123
ENV LOG_FILE=/var/log/app.log
RUN mkdir -p /var/log
CMD ["node", "server.js"]
# compose.yaml (to audit)
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- ./uploads:/app/uploads
# No restart policy
# No health check
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: supersecret123
# Data stored only in container filesystem (no volume)
Work through each factor. Violations to find include:
- Factor III — Config (DATABASE_HOST, DATABASE_PASSWORD, NODE_ENV) baked into the image via
ENVinstructions. Fix: remove from Dockerfile; supply at runtime via env vars. - Factor III — Password hardcoded in
compose.yaml. Fix: use${POSTGRES_PASSWORD}from a.envfile not committed to git, or a secrets manager. - Factor VI —
./uploadsvolume mounts host filesystem, implying the app stores state locally. Fix: use object storage (S3, GCS) or a dedicated volume managed by the orchestrator. - Factor VI & XI — Logs written to
/var/log/app.loginside the container. Fix: configure the logger to write to stdout; remove the LOG_FILE env var and themkdir. - Factor IX — No restart policy; container won't recover from crashes. Fix: add
restart: on-failureand ahealthcheck. - Factor IV — Postgres has no volume, so data is lost on container removal. Fix: add a named volume (
pgdata:/var/lib/postgresql/data).
Corrected compose.yaml:
services:
app:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL} # from .env or shell
restart: on-failure:5
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
retries: 3
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 5
volumes:
pgdata:
Corrected Dockerfile (key changes):
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production # install before copying app code (cache)
COPY . .
# No ENV for config — supplied at runtime
# No log directory — app writes to stdout
EXPOSE 3000
CMD ["node", "server.js"]
Interactive Quizzes
Your app writes session data to /tmp/sessions/ inside its container. Which twelve-factor principle does this violate?
A team bakes their production database password into the Docker image using an ENV instruction. Which factors does this violate?
Which of the following is a twelve-factor-compliant way to run a one-off database migration?
Key Takeaways
- Twelve-factor predates containers but describes exactly how container-native apps should behave
- Containers enforce naturally: config via env (III), build/release/run (V), statelessness (VI), disposability (IX), dev/prod parity (X)
- Conscious effort required for: backing services as URLs (IV), horizontal concurrency (VIII), logging to stdout (XI)
- Factor V is the foundation: build once, release with config, run anywhere — never mutate a running container
- Logs to stdout, not files — Docker log drivers and external aggregators need the stream to be there
- Admin tasks belong in one-off ephemeral containers (
docker run --rm), not in the running service - Heroku, Cloud Run, Fargate are built on twelve-factor assumptions — compliance is the price of admission to modern PaaS
- Auditing an existing app against the 12 factors is the fastest way to identify portability and scalability bottlenecks