The 12-Factor App in 2026 — Cloud-Native Best Practices for Modern Backend Systems

Sanjeev SharmaSanjeev Sharma
10 min read

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 separately

The 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/test

Factor 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 complete

Factor 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 ElastiCache

The 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 care

Never: 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 version

Common Mistakes

  • Storing secrets in config/production.js instead of environment variables
  • Using npm install instead of npm ci in 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-compose to 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 pino or winston configured 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 latest in 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading