The 12-Factor App in 2026 — Cloud-Native Best Practices for Modern Backend Systems
Advertisement
Introduction
Why This Matters
In 2011, Heroku published the 12-Factor App methodology as a guide for building reliable SaaS systems. Fifteen years later, these principles are more relevant than ever — especially as systems move to Kubernetes, serverless, and multi-cloud. Teams that ignore them produce systems that break under load, leak secrets, or fail silently during deployments.
Every backend team should treat the 12 factors as a baseline checklist, not an optional guide.
Factor I — Codebase: One Codebase, Many Deployments
One git repository, deployed to multiple environments. In monorepos, each service remains a distinct codebase that happens to share a repo:
// Correct monorepo structure — each service has one codebase
repo/
├── packages/
│ ├── api-server/ // Deployed separately
│ │ ├── Dockerfile
│ │ └── package.json
│ ├── worker/ // Deployed separately
│ │ ├── Dockerfile
│ │ └── package.json
│ └── shared-lib/ // Published as npm package, never deployed directly
├── infrastructure/ // IaC — not deployed, describes deployments
└── .github/workflows/
// WRONG: Two separate git repos for services that share code
// repo-api/ (git repo 1)
// repo-worker/ (git repo 2)
// When shared-lib changes, you must update both repos separatelyThe rule: one codebase can be deployed to many environments (dev, staging, prod) but the code must be identical across all of them. Environment differences come from config, not code branches.
Factor II — Dependencies: Explicit Declaration
Never rely on system-level packages or ambient tools. Every dependency must be declared in a manifest and locked:
// package.json — all production dependencies listed
{
"dependencies": {
"express": "^4.18.2",
"pino": "^8.0.0",
"prisma": "^5.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"jest": "^29.0.0"
}
}# Dockerfile — install from manifest, never assume system packages
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "dist/server.js"]The npm ci command (not npm install) ensures reproducible installs from the lockfile. Never use npm install in production Docker images.
Factor III — Config: Strict Separation of Config and Code
Anything that changes between environments (credentials, URLs, feature flags) must come from environment variables — never from the codebase:
// config.ts — all config from environment
const config = {
port: parseInt(process.env.PORT || '3000', 10),
database: {
url: process.env.DATABASE_URL,
poolSize: parseInt(process.env.DB_POOL_SIZE || '20', 10),
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
auth: {
jwtSecret: process.env.JWT_SECRET,
jwtExpiry: process.env.JWT_EXPIRY || '24h',
},
}
// Validate at startup — fail fast if required config is missing
export function validateConfig() {
if (!config.database.url) throw new Error('DATABASE_URL is required')
if (!config.auth.jwtSecret) throw new Error('JWT_SECRET is required')
if (process.env.NODE_ENV === 'production' && !process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is required in production')
}
}
// WRONG — hardcoded config in code
const WRONG_CONFIG = {
database: 'postgresql://user:password@localhost:5432/myapp', // secret in source
stripeKey: 'sk_live_abc123', // committed to git
}The test: could you open-source this codebase without exposing credentials? If no, config is leaking into code.
Factor IV — Backing Services: Treat as Attached Resources
Every external resource (database, cache, queue, email service) should be swappable by changing a URL — no code changes required:
// Good: All backing services accessed via URL from config
const db = new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } }
})
const cache = redis.createClient({ url: process.env.REDIS_URL })
const queue = new SQSClient({ endpoint: process.env.SQS_QUEUE_URL })
const mailer = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
// Swap backing services by changing env vars, not code:
// Dev: DATABASE_URL=postgresql://localhost:5432/dev
// Prod: DATABASE_URL=postgresql://rds.amazonaws.com:5432/prod
// Test: DATABASE_URL=postgresql://localhost:5432/testFactor V — Build, Release, Run: Three Immutable Stages
The pipeline must be strictly one-way: build produces an artifact, release combines it with config, run executes it:
# STAGE 1: BUILD — compile source into an immutable image
docker build -t myapp:git-abc123 .
# STAGE 2: RELEASE — combine image + environment config
docker tag myapp:git-abc123 myapp:v1.4.2
docker push myapp:v1.4.2
# This release is immutable — never modify a released image
# STAGE 3: RUN — execute the release
docker run myapp:v1.4.2 \
--env-file production.env# GitHub Actions implementing all three stages
name: Build Release Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build -t myapp:${{ github.sha }} .
docker push myapp:${{ github.sha }}
release:
needs: build
steps:
- name: Tag release
run: |
docker tag myapp:${{ github.sha }} myapp:v${{ github.run_number }}
docker push myapp:v${{ github.run_number }}
deploy:
needs: release
steps:
- name: Rolling deploy
run: |
kubectl set image deployment/myapp app=myapp:v${{ github.run_number }}Factor VI — Processes: Stateless and Share Nothing
Running processes must not store user session state in memory. Any data that must persist goes to a backing service:
// WRONG — state in process memory breaks horizontal scaling
const sessionStore = new Map<string, Session>()
app.post('/login', (req, res) => {
sessionStore.set(req.body.userId, { loggedIn: true, cart: [] })
res.json({ success: true })
})
// If the load balancer routes the next request to a different instance,
// the session is gone.
// CORRECT — state in Redis, shared across all instances
import session from 'express-session'
import RedisStore from 'connect-redis'
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
}))
// Or use stateless JWT — no server-side session at all
app.post('/login', async (req, res) => {
const user = await authenticate(req.body)
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET!)
res.json({ token }) // Client sends token on every request
})Factor VII — Port Binding: Self-Contained Services
The app must export an HTTP service by binding to a port directly — no external web server required:
// app.ts — self-contained HTTP server
import express from 'express'
const app = express()
app.get('/health', (req, res) => res.json({ status: 'ok' }))
app.use('/api', apiRouter)
const port = process.env.PORT || 3000
app.listen(port, () => {
console.log(`Server listening on port ${port}`)
})# Dockerfile runs the app directly — no nginx, no apache
CMD ["node", "dist/app.js"]The process itself speaks HTTP. A load balancer or Kubernetes ingress sits in front, but the app doesn't depend on it.
Factor VIII — Concurrency: Scale Out Via Process Model
Scale by running more processes, not by making individual processes consume more resources:
# Kubernetes: separate deployments for each process type
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-web
spec:
replicas: 8 # Scale up web tier independently
template:
spec:
containers:
- name: web
image: myapp:v1.4.2
env:
- name: PROCESS_TYPE
value: "web"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-worker
spec:
replicas: 3 # Workers scale with queue depth
template:
spec:
containers:
- name: worker
image: myapp:v1.4.2 # Same image, different CMD
env:
- name: PROCESS_TYPE
value: "worker"// entrypoint.ts — same image, different process type
const processType = process.env.PROCESS_TYPE || 'web'
if (processType === 'web') {
import('./web/server')
} else if (processType === 'worker') {
import('./worker/processor')
} else if (processType === 'scheduler') {
import('./scheduler/cron')
}Factor IX — Disposability: Fast Startup and Graceful Shutdown
Processes can be started or stopped at any time. They must start quickly and shut down cleanly when they receive SIGTERM:
import express from 'express'
import { Server } from 'http'
const app = express()
const server: Server = app.listen(process.env.PORT || 3000)
// Handle graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received — shutting down gracefully')
// Step 1: Stop accepting new connections
server.close()
// Step 2: Wait for in-flight requests (with timeout)
const shutdownTimeout = setTimeout(() => {
console.error('Shutdown timeout — forcing exit')
process.exit(1)
}, 30_000)
// Step 3: Close backing service connections
await Promise.all([
prisma.$disconnect(),
redisClient.quit(),
])
clearTimeout(shutdownTimeout)
console.log('Graceful shutdown complete')
process.exit(0)
})
// Kubernetes sends SIGTERM before killing the pod
// 30-second graceful period allows in-flight requests to completeFactor X — Dev/Prod Parity: Keep Environments Nearly Identical
Minimize the gaps between development, staging, and production environments:
# docker-compose.yml — mirrors production stack locally
version: '3.9'
services:
api:
build: .
environment:
- DATABASE_URL=postgresql://postgres:password@postgres:5432/dev
- REDIS_URL=redis://redis:6379
depends_on:
- postgres
- redis
postgres:
image: postgres:16 # Same major version as production RDS
environment:
POSTGRES_PASSWORD: password
redis:
image: redis:7 # Same major version as production ElastiCacheThe anti-pattern: using SQLite locally but PostgreSQL in production. Different SQL dialects, different constraint enforcement, different query plans. Bugs that only appear in production.
Factor XI — Logs: Treat as Event Streams
Write structured logs to stdout. Never write to files inside the container:
import pino from 'pino'
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { service: 'api-server', env: process.env.NODE_ENV },
})
app.get('/orders', async (req, res) => {
logger.info({ userId: req.user.id, action: 'list_orders' }, 'Orders requested')
const orders = await db.orders.findMany({ where: { userId: req.user.id } })
logger.info({ userId: req.user.id, count: orders.length }, 'Orders returned')
res.json({ orders })
})
// Output — structured JSON on stdout, collected by Kubernetes log driver:
// {"level":30,"service":"api-server","userId":"u-123","action":"list_orders","msg":"Orders requested"}
// Infrastructure routes stdout to Datadog, CloudWatch, ELK — app doesn't careNever: fs.appendFileSync('/var/log/app.log', line) — logs are lost when the container restarts.
Factor XII — Admin Processes: Run as One-Off Tasks
Database migrations, cleanup jobs, backups — run as separate one-off processes, not inside the running app:
# Kubernetes Job for database migration
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate-v1-4-2
spec:
template:
spec:
containers:
- name: migrate
image: myapp:v1.4.2 # Same image as the app
command: ["npm", "run", "db:migrate"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
restartPolicy: Never
backoffLimit: 3// WRONG — running migrations inside the web server startup
app.listen(3000, async () => {
await prisma.$executeRaw`ALTER TABLE users ADD COLUMN last_login TIMESTAMP`
// Blocks startup, runs on every instance simultaneously,
// causes race conditions with multiple pods
})
// CORRECT — migration as a separate process
// package.json scripts:
// "db:migrate": "prisma migrate deploy"
// Run before deploying new app versionCommon Mistakes
- Storing secrets in
config/production.jsinstead of environment variables - Using
npm installinstead ofnpm ciin Docker builds — non-reproducible installs - Running database migrations in the web process startup code
- Storing user sessions in the Node.js process memory instead of Redis
- Using SQLite locally and PostgreSQL in production
- Writing logs to files inside containers that get lost on restart
- Not handling SIGTERM — Kubernetes kills the pod with in-flight requests dropped
Best Practices
- Use
docker-composeto mirror your entire production stack locally — same versions, same config - Validate all required environment variables at startup and fail fast with a clear error message
- Use
pinoorwinstonconfigured to output structured JSON to stdout - Add a health check endpoint that verifies database connectivity, not just HTTP availability
- Version your Docker images with git SHA — never use
latestin production - Run database migrations as a Kubernetes Job that must succeed before rolling out new pods
Key Takeaways
- The 12-Factor App principles apply to Kubernetes, serverless, and every cloud-native deployment model in 2026
- Config must come entirely from environment variables — code should be deployable to any environment without modification
- Processes must be stateless — any state that must survive a process restart lives in a backing service like Redis or PostgreSQL
- Logs belong on stdout as structured JSON — infrastructure routes them to aggregators, not the application
- Database migrations are one-off admin processes, not startup code inside the web server
- Build, release, and run are strictly separated stages — a release is immutable once tagged
- Dev and prod must use the same backing service versions — SQLite vs PostgreSQL parity gaps hide real bugs
- Graceful shutdown handling is non-negotiable — SIGTERM must be caught and in-flight requests must complete before exit
Advertisement