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
Why Containers & Twelve-Factor Fit Together

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 docker build → image artifact RELEASE image + config → tagged release RUN docker run → container(s) push & tag env vars immutable artifact myapp:v1.4.2 1..N replicas Factor V — Build / Release / Run Separation 🔒
# 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
Never Mutate a Running Container

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

Storing State Inside Containers (Factor VI violation)

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.

Baking Config into Images (Factor III violation)

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.

Writing Logs to Files (Factor XI violation)

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.

Treating Containers Like VMs (Factors V, VI, IX violations)

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

🏭 Platforms Built on Twelve-Factor Assumptions

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:

  1. Factor III — Config (DATABASE_HOST, DATABASE_PASSWORD, NODE_ENV) baked into the image via ENV instructions. Fix: remove from Dockerfile; supply at runtime via env vars.
  2. Factor III — Password hardcoded in compose.yaml. Fix: use ${POSTGRES_PASSWORD} from a .env file not committed to git, or a secrets manager.
  3. Factor VI./uploads volume mounts host filesystem, implying the app stores state locally. Fix: use object storage (S3, GCS) or a dedicated volume managed by the orchestrator.
  4. Factor VI & XI — Logs written to /var/log/app.log inside the container. Fix: configure the logger to write to stdout; remove the LOG_FILE env var and the mkdir.
  5. Factor IX — No restart policy; container won't recover from crashes. Fix: add restart: on-failure and a healthcheck.
  6. 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?

  • Factor II — Dependencies (should be in the image)
  • Factor VII — Port binding (not exporting via port)
  • Factor VI — Processes (containers must be stateless; local filesystem is not persistent)
  • Factor XI — Logs (should write to stdout)

A team bakes their production database password into the Docker image using an ENV instruction. Which factors does this violate?

  • Factor V only — the build/release/run separation
  • Factor XI only — logs should be streams
  • Factor III — config must come from the environment, not be baked into the image
  • Factor IX — disposability; the container can't start quickly

Which of the following is a twelve-factor-compliant way to run a one-off database migration?

  • docker exec app-container npm run migrate — run it inside the running app container
  • Add migration logic to the app startup and run it every time the container starts
  • docker run --rm -e DATABASE_URL=$DB myapp:v1.4 npm run migrate — a one-off ephemeral container (Factor XII)
  • SSH into the database server and run SQL manually

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