The 12-Factor App: A Blueprint for Cloud-Native Applications
A practical, engineer-friendly walkthrough of all twelve factors — from codebase and config to processes, concurrency, and admin tasks — and how each one supports CI/CD.
When I'm in doubt about how to build a cloud-native application the right way, there's one reference I always come back to: the 12-Factor App. It's not a framework or a tool — it's a collection of best practices that help engineers create applications that are scalable, maintainable, and secure in modern cloud environments.
These twelve principles act as a north star. They reduce decision fatigue, eliminate common pitfalls, and promote architecture that can evolve as your product grows. In this post I'll explain what each factor means in practical terms, and how each one supports CI/CD — without overcomplicating your architecture.
1. Codebase
One codebase tracked in version control, many deploys.
Each app should have exactly one codebase tracked in version control (like Git). That single codebase may be deployed to multiple environments (development, staging, production), but it always comes from one source.
In practice: keep all your code in one Git repository per application; collaborate via branches, pull requests, and CI/CD; and deploy the same codebase with different configuration per environment. This gives you consistency across environments, traceability of changes, easy automation, and simpler collaboration.
2. Dependencies
Explicitly declare and isolate dependencies.
A 12-factor app declares all its dependencies in a manifest and isolates them from the system — never relying on system-wide packages or manually installed tools. Each environment installs the same dependencies from the manifest:
- Node.js —
package.json - Python —
requirements.txtorpyproject.toml - Go —
go.mod - Java —
pom.xml(Maven) orbuild.gradle(Gradle)
This gives you environment consistency and reproducibility — clone the repo, run
npm install / pip install / go mod download, and you have a working
environment instantly.
Common mistake: installing tools globally and relying on them implicitly. The CI pipeline then fails because the tool doesn't exist on the build server, and new team members hit "works on my machine" bugs. Instead, declare everything in the manifest, use a lock file for version consistency, and isolate runtimes with virtual environments or containers.
3. Config
Store config in the environment.
Configuration should be externalized from code and managed through environment variables — anything that varies between deployments: database credentials, API keys, storage URLs, feature flags, and secrets. This gives you portability across environments, security (secrets never committed), and simplified deployment.
# .env (local development)
DATABASE_URL=postgres://localhost:5432/mydb
API_KEY=dev-api-key-123
PORT=4000
const dbUrl = process.env.DATABASE_URL
const port = process.env.PORT || 3000
In production, your platform (Vercel, AWS, Heroku, Docker, Kubernetes) injects the environment variables securely without changing your source.
Common mistake — hardcoding config:
const dbUrl = 'postgres://admin:password@localhost:5432/prod-db'
If this is pushed to GitHub, your database is now publicly exposed, changing config requires a code change, and you can't reuse the code across environments.
4. Backing Services
Treat backing services as attached resources.
Databases, caches, message queues, storage, and third-party APIs should be treated as external resources — loosely coupled, accessed via configuration, and swappable without code changes.
DATABASE_URL=postgres://user:pass@localhost:5432/dev-db
REDIS_URL=redis://localhost:6379
S3_BUCKET_URL=https://s3.amazonaws.com/my-app-bucket
const db = new DatabaseClient(process.env.DATABASE_URL)
const redis = new RedisClient(process.env.REDIS_URL)
When you deploy to staging or production, only the environment variable values change — not the code. This improves testability, deployment speed, and reliability. The anti-pattern is hardcoding or tightly coupling services, which makes swapping databases or debugging environments painful.
5. Build, Release, Run
Strictly separate build and run stages.
These are three distinct phases that must not be mixed:
- Build — turn source code into an executable artifact (compile, install dependencies, bundle assets, build a Docker image).
- Release — combine the build artifact with environment-specific config to create a ready-to-run release, versioned with a tag or commit hash.
- Run — launch the release artifact in its runtime context.
This separation gives you consistency, reproducible deployments (recreate any release later from the artifact + stored config), and safer releases. CI/CD pipelines map naturally onto this model: build once, configure per environment, deploy many times.
Common mistake: building the app after deployment (running npm install inside
a production container), or hardcoding environment flags. This causes unpredictable
deployments and inconsistent rollbacks.
6. Processes
Execute the app as one or more stateless processes.
A 12-factor app runs as stateless, disposable processes. Everything it needs comes from the outside — the database, a cache like Redis, object storage like S3, environment variables. Processes should not rely on in-memory variables or local disk to persist data between requests.
app.get('/profile', async (req, res) => {
const user = await db.getUser(req.user.id)
res.json(user)
})
This works because the data is in the database, the process is stateless, and it can run on one server or many. Statelessness gives you horizontal scalability, resilience (a crash loses nothing critical), and clean CI/CD.
Common mistake — storing state in memory or local disk:
// in-memory cache (bad if not externalized)
const activeUsers = {}
app.post('/login', (req, res) => {
activeUsers[req.body.userId] = true
})
// local file storage (lost if the process restarts)
fs.writeFileSync('/tmp/report.txt', reportData)
If the app crashes you lose the session or file, and scaling to multiple instances means some won't have the data.
7. Port Binding
Export services via port binding.
The app should be self-contained and expose its functionality by binding to a port — not relying on an external web server (Apache, Nginx) to be injected.
const express = require('express')
const app = express()
app.get('/', (req, res) => res.send('Hello, World'))
const port = process.env.PORT || 3000
app.listen(port, () => console.log(`App listening on port ${port}`))
This makes the app portable, testable, and easy to deploy. CI environments can start the app, hit the port, run integration tests, and shut it down — all in the pipeline, with no external web server to configure.
8. Concurrency
Scale out via the process model.
Embrace scaling by running multiple processes, not by creating threads or managing a worker pool inside the app. Need to handle more web traffic? Run more web processes. Have a queue of background jobs? Run more workers. Need real-time processing? Run separate stream processors.
A typical app might have a web process (HTTP requests), a worker process (consuming jobs from a queue like BullMQ or RabbitMQ), and a scheduler (cron-like tasks). In Kubernetes these become separate Deployments or CronJobs, each scaled and managed independently.
Anti-pattern — mixing concurrency into one process:
// web server + background jobs + cron all in one process
startWebServer()
startJobQueue()
startScheduledTasks()
This makes the app harder to scale (you scale everything together), creates resource contention, and increases deployment risk (one crash kills all responsibilities).
9. Disposability
Maximize robustness with fast startup and graceful shutdown.
A 12-factor app is disposable: it starts quickly and shuts down gracefully at any time without errors or data loss. Fast startup lets new instances spin up rapidly when scaling or recovering. Graceful shutdown means closing connections, finishing in-flight requests, and cleaning up before exit.
process.on('SIGTERM', () => {
console.log('Received SIGTERM, shutting down gracefully...')
server.close(() => {
console.log('Closed out remaining connections')
process.exit(0)
})
// Force shutdown if it takes too long
setTimeout(() => {
console.error('Could not close connections in time, forcing shutdown')
process.exit(1)
}, 10000)
})
This enables robust scaling, zero-downtime deployments, and safe rollbacks.
10. Dev/Prod Parity
Keep development, staging, and production as similar as possible.
One of the most common causes of bugs is differences between environments. Minimize them to avoid surprises at deploy time. Practical ways to achieve parity:
- Containerize with Docker so the app behaves the same on a laptop, a CI server, and in production.
- Share environment configuration via env vars and config management.
- Use the same backing services — same types and versions of databases and caches everywhere (e.g. PostgreSQL 14 in all environments, not 12 locally and 14 in prod).
- Automate provisioning with Terraform or Kubernetes manifests so environments are identical.
When environments drift you get "works on my machine" bugs, pipelines that pass in staging but fail in production, and a higher risk of downtime.
11. Logs
Treat logs as event streams.
The app shouldn't manage logs itself. It writes logs as a continuous stream to
stdout/stderr, and a separate service captures, stores, and analyzes them.
console.log('User signed up', userData)
console.error('Database connection failed', error)
A logging agent (Fluent Bit, Vector, Fluentd) collects these and ships them to a system like Loki + Grafana, Elasticsearch + Kibana, Datadog, or Google Cloud Logging. This decouples concerns, improves traceability across instances, and simplifies scaling — when logs go to stdout, new containers can come and go without losing local log files.
12. Admin Processes
Run admin and management tasks as one-off processes.
Administrative tasks — database migrations, backfills, data cleanups — should run as one-off processes, separate from the main app runtime. This keeps production predictable and traceable.
# Run a database migration
npx prisma migrate deploy
# Backfill data
node scripts/backfill-users.js
# Manual user management
node scripts/addAdminUser.js --email test@example.com
If you embed migrations in app startup instead, you risk failures from accidental re-runs, and admin logic gets tangled with application logic. Treating them as isolated, versioned processes makes your CI/CD workflow safer and more flexible.
The 12-factor methodology has aged remarkably well because it optimizes for the things that actually matter in the cloud: portability, reproducibility, and the ability to scale and recover cleanly. Treat it as a checklist whenever you start something new — or whenever a system starts feeling harder to operate than it should.
Related reading
The Power of Cloud Native: How Cloud Enables Business Innovation
What cloud-native really means — containers, microservices, orchestration — and how it lets businesses scale, stay resilient, and innovate faster.
Microservices Are Not a Silver Bullet
Microservices promise autonomy and scale but often deliver a distributed monolith. When they genuinely shine, when to avoid them, and why simplicity usually wins.