12-factor configuration
Why config lives in the environment, what 'one artifact for every environment' means, and which 12-factor principles are about operations.
Why this matters
The same service lives in several environments: on a developer's laptop, on staging, in prod. The code is identical everywhere, but the database address, the keys, and the mode differ. If these differences are baked into the code or the image, every environment needs its own build, and what ships to prod isn't what was tested.
This lesson is about the principles that keep code and environment apart. They also explain why a service can run in ten copies and why its logs don't live in files.
Where 12-factor came from
The Twelve-Factor App is a set of twelve principles for writing services that are easy to operate in the cloud. Adam Wiggins wrote it based on experience at Heroku, where the authors shipped hundreds of applications. The principles became a shared language: when people say "a 12-factor app", they mean a service that's easy to configure, scale, and deploy.
Here we'll cover the four factors closest to DevOps:
| Factor | Name in the original | Gist |
|---|---|---|
| III | Config | config lives in the environment |
| V | Build, release, run | build and run are strictly separated |
| VI | Processes | processes don't hold state |
| XI | Logs | logs are a stream of events |
Full list: 12factor.net.
Config in the environment
The factor that matters most here: config lives in the environment, not in the code. In the 12-factor sense, config is everything that changes between environments: the database address, credentials for external services, the hostname. It's passed through environment variables, not baked into files inside the build. The mechanics of variables were covered in the env-vars lesson; what matters here is the principle.
The sign of a violation is simple: if changing the database address between dev and prod requires rebuilding the artifact, the config isn't where it should be. The right setup is one image that runs in any environment, differing only in variables.
# one image, different environments
docker run -e DATABASE_URL=postgres://dev-db/app myapp:1.4
docker run -e DATABASE_URL=postgres://prod-db/app myapp:1.4
What this looks like on a live container: the same command, the same image, only the variable changes.
docker run --rm -e APP_ENV=staging alpine:3.20.3 sh -c 'echo "env=$APP_ENV"'
env=staging
With -e APP_ENV=prod the same command prints env=prod. In Docker
Compose you set the same thing with the service's environment: block:
services:
api:
image: myapp:1.4
environment:
DATABASE_URL: postgres://prod-db/app
Not every setting counts as config. Application routes or the links between modules are the same in every environment, and belong in the code. Only what differs from environment to environment goes into the environment.
12-factor has an honesty check: could you open the code in a public repository right now without exposing a single password? If not, config is mixed in with the code.
Variables are set one per setting. They aren't bundled into named sets like "development config" and "production config". Such sets multiply with every new environment (staging, qa, a demo for a client) and quickly drift apart.
One artifact for every environment
From this follows the build once, deploy anywhere principle. The artifact (the image) is built once, and that exact artifact is run through staging and prod. There's no separate "prod build": rebuilding means something other than what was tested on staging ships to prod.
In 12-factor this is factor V. A rollout splits into three stages:
- Build: the code is turned into a runnable artifact, for example an image.
- Release: the artifact is combined with a specific environment's config.
- Run: the release is started.
Code changes only at the build stage. Config is attached at the release stage, so the same image becomes different releases in different environments.
The version is pinned by a tag or digest, as covered in the registries lesson, and the differences between environments go into variables. That way staging and prod are guaranteed to run the same code.
Stateless processes
Another key factor: processes don't hold state. Anything that must survive a restart or be shared across multiple copies (sessions, uploaded files, carts) is moved out to external storage: a database, object storage, Redis. A process's memory and disk are fine only as a short-lived cache within a single request.
This is what horizontal scaling, covered in the load balancing lesson, rests on. If the copies are stateless, any request can be served by any of them, and copies can be added or removed freely. If a copy keeps a session in memory, the next request from the same user, landing on a different copy, won't see that session.
12-factor explicitly calls sticky sessions a violation. That is when the load balancer always sends a user to the same copy. They hide the problem: the copy restarted during a deploy, and the session was gone.
Logs as a stream
And one more factor: logs are a stream of events to stdout, not files managed by the application. The process just writes to stdout without buffering, and the environment (Docker, journald, a log collector) gathers, stores, and routes the logs. The application doesn't think about file rotation or where the logs end up.
That's why docker logs shows only what the process printed to stdout and
stderr. If a service writes to a file inside the container, docker logs
is empty, and the file disappears along with the container. Log levels are
covered in a separate lesson.
Common mistakes
- Config in the image. A settings file with the database address gets copied into the image at build time. Changing the address needs a new build, and every environment ends up with its own image.
- A separate build for prod. "Same code, just built with the prod settings" means prod runs an artifact that never went through staging.
- Editing a variable without recreating the container. The environment
is set when the container is created.
docker compose restartrestarts the container with the old environment, whiledocker compose up -drecreates it if the service's configuration changed (docker compose restart). localhostinstead of the service name. Inside a container,localhostis the container itself. A neighboring service in compose is reached by its name.- Secrets in variables as if in a safe. An environment variable keeps a
password out of the code, but it's visible to anyone who can run
docker inspect. How to store secrets is covered in the secrets management lesson. - State in one copy's memory. The service works as long as there's one copy, and loses data as soon as there are two copies or a copy restarts.
What you'll need for the tasks
docker compose -f setup/docker-compose.yml logs <service>: a service's startup line often shows which settings it started with.- The
environment:block of a service indocker-compose.yml, anddocker compose -f setup/docker-compose.yml up -dto apply changes. docker compose -f setup/docker-compose.yml ps: theIMAGEcolumn shows which image each service is running from.- Inside the compose network, services find each other by service name.
curl -s http://127.0.0.1:<port>/to make requests to a service.
In the next lesson we'll cover resilience to failures: retries, timeouts, and the circuit breaker.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson app-patterns-03-config-12factor
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress