12 Factor App Principles Visualized

Table of contents

The 12-Factor App methodology was introduced by engineers (Adam Wiggings) at Heroku to define best practices for building modern, scalable, maintainable, cloud-native applications.
This is the definition taken from Wikipedia:
The Twelve-Factor App methodology is a methodology for building software-as-a-service applications. These best practices are designed to enable applications to be built with portability and resilience when deployed to the web.
It came from real-world experience deploying and scaling hundreds of apps on Heroku’s platform - and seeing the same problems happen over and over again.
The twelve-factor methodology can be applied to apps written in any programming language, and which use any combination of backing services (database, queue, memory cache, etc).
What problems does it aim to solve?
Here are the key problems it tackles:
1. Difficult deployments
Problem: Apps often break when moved between environments (e.g., dev → staging → production).
Solution: Standardize config management, environment parity, and external dependencies.
2. Tight coupling & poor modularity
Problem: Monolithic codebases that are hard to scale, test, and maintain.
Solution: Encourage loose coupling, stateless processes, and clear boundaries between components.
3. Hard-to-scale apps
Problem: Traditional apps can’t scale easily—whether horizontally (across multiple servers) or independently (per feature).
Solution: Promote process-based scaling, disposability, and portability.
4. Environment-specific behavior
Problem: Apps behave differently depending on the machine or team member.
Solution: Use environment variables and strict separation of config from code.
5. Fragile deployments & rollbacks
Problem: Manual deploys and rollbacks are error-prone and slow.
Solution: Encourage one-command deploys, strict build/release/run stages, and clear versioning.
6. Stateful processes
Problem: Apps with in-memory state or sticky sessions don’t play well in distributed systems.
Solution: Treat processes as stateless and share nothing - persist state externally (e.g., DB, S3).
🌩️ In a cloud-native world...
The 12-Factor App method helps you build apps that thrive in environments like Kubernetes, AWS, Heroku, or Docker, where:
Infrastructure is ephemeral
Services need to scale on-demand
Reliability and portability are key
In summary:
The 12-Factor App is a blueprint for building apps that are easy to deploy, scale, maintain, and evolve - especially in the cloud.
The Twelve Factors
I. Codebase
One codebase tracked in revision control, many deploys
One codebase: A single, unified repository (e.g., in Git) contains all the source code for the application. This ensures there's a single source of truth, avoiding confusion from multiple versions or forks of the code.
Tracked in revision control: The codebase is managed using a version control system (like Git), which tracks changes, enables collaboration, and allows rollbacks or branching for features and fixes.
Many deploys: The same codebase can be deployed to multiple environments or instances, such as development, staging, production, or different regions for a distributed system. Each deployment uses the same codebase but may have different configurations (e.g., database connections, API keys) tailored to the environment.
Why it matters:
Consistency: Using one codebase ensures all deployments share the same logic and behavior, reducing bugs from divergent code.
Scalability: Multiple deployments (e.g., across servers or regions) can run from the same code, supporting load balancing or geographic distribution.
Simplicity: A single codebase simplifies development, testing, and maintenance compared to managing multiple codebases.
Example: A web app's code is stored in a Git repository. Developers push changes to this repo. The code is deployed to:
A local development server for testing.
A staging server for QA.
Multiple production servers in different regions for live users. All deployments use the same codebase, with environment-specific settings (e.g., database URLs) handled separately via configuration files or environment variables.
This approach contrasts with maintaining separate codebases for different environments, which can lead to inconsistencies and maintenance headaches.
II. Dependencies
Explicitly declare and isolate dependencies
Explicitly declare dependencies: Clearly specify all external libraries, frameworks, or tools your application relies on in a manifest file (e.g.,
requirements.txt
for Python,package.json
for Node.js, orGemfile
for Ruby). This ensures that anyone running the app can see exactly what’s needed and install the correct versions, avoiding ambiguity or reliance on implicit system-level dependencies.Isolate dependencies: Ensure the app’s dependencies are isolated from the host system and other apps. This means the app should bundle its dependencies and not rely on pre-installed software on the system (e.g., a specific version of Python or a library already on the server). Isolation is often achieved using tools like virtual environments (e.g., Python’s
venv
), containers (e.g., Docker), or dependency managers that install dependencies locally for the app.
Why it matters:
Reproducibility: Explicit declaration ensures the app behaves the same way across different environments (development, staging, production) by using the same dependency versions.
Avoid conflicts: Isolation prevents clashes between apps or system-level software that might use different versions of the same dependency.
Portability: The app can run anywhere without assuming the host system has certain tools or libraries installed.
Debugging: If something breaks, you know exactly which dependencies are in use, making troubleshooting easier.
Example:
A Python app declares its dependencies in
requirements.txt
(e.g.,flask==2.0.1
,requests==2.28.1
).It uses a virtual environment (
venv
) to isolate these dependencies, so even if the system has a different version of Flask installed globally, the app uses its specified version.When deploying, the same
requirements.txt
is used to install dependencies in the production environment, ensuring consistency.
If an app assumes the system has a specific version of a library (e.g., "I’ll just use whatever Python is on the server"), it might break if the server’s version differs from what the app was developed with. This violates the principle and leads to unpredictable behavior.
III. Config
Store config in the environment
The principle "Store config in the environment" ensures that an application's configuration is separated from its code, making the app more portable, secure, and adaptable across different environments.
What is config?: Configuration refers to anything that varies between environments (e.g., development, staging, production), such as database URLs, API keys, port numbers, or feature flags. These should not be hard-coded in the codebase.
Store in the environment: Instead of embedding config values in the code, store them in environment variables (e.g.,
os.environ
in Python). Environment variables are managed by the operating system or deployment platform and can be easily changed without modifying the code.
Why it matters:
Separation of concerns: Keeping config separate from code ensures the same codebase can be deployed to multiple environments without changes.
Security: Sensitive data like API keys or passwords aren’t hardcoded or checked into version control (e.g., Git), reducing the risk of accidental leaks.
Flexibility: Environment variables can be updated dynamically by the deployment system (e.g., Docker, Kubernetes, or CI/CD pipelines) without redeploying the app.
Portability: The app can run anywhere (e.g., a developer’s laptop, a staging server, or production) by simply adjusting the environment variables.
Example:
A web app needs a database URL, API key, and port number.
Instead of hardcoding them like this:
DATABASE_URL = "postgres://user:pass@localhost:5432/dev" API_KEY = "abc123" PORT = 5000
The app reads them from environment variables:
import os DATABASE_URL = os.getenv("DATABASE_URL") API_KEY = os.getenv("API_KEY") PORT = int(os.getenv("PORT", "5000")) # Default to 5000 if not set
In development, you might set these variables in a
.env
file (loaded withpython-dotenv
):DATABASE_URL=postgres://user:pass@localhost:5432/dev API_KEY=abc123 PORT=5000
In production, the hosting platform (e.g., Heroku, AWS) sets these variables:
DATABASE_URL=postgres://prod_user:prod_pass@prod_host:5432/prod API_KEY=xyz789 PORT=8080
Key takeaway: The same codebase runs everywhere; only the environment variables change. This avoids the need for environment-specific code branches and ensures consistency while keeping sensitive config secure.
IV. Backing services
Treat backing services as attached resources
The principle "Treat backing services as attached resources" ensures that an application interacts with external services (like databases, APIs, or message queues) in a flexible and interchangeable way.
What are backing services?: These are external services an app relies on to function, such as databases (e.g., PostgreSQL, MongoDB), messaging systems (e.g., RabbitMQ), caching systems (e.g., Redis), email services (e.g., SendGrid), or third-party APIs. They are not part of the app’s core codebase but are essential for its operation.
Treat as attached resources: The app should treat these services as resources that can be "attached" or "detached" without changing the codebase. This means:
The app connects to these services using configuration (e.g., environment variables for URLs, credentials) rather than hardcoding connection details.
The app should not care whether the service is local (e.g., a database on the same server) or remote (e.g., a cloud-hosted database). It interacts with them the same way—via a network address (URL) and credentials.
Services should be interchangeable. For example, swapping a local MySQL database for a cloud-hosted Amazon RDS instance should only require updating the config (e.g., the database URL), not the code.
Why it matters:
Flexibility: The app can switch backing services (e.g., from SQLite in development to PostgreSQL in production) without code changes, as long as the new service supports the same interface.
Scalability: Treating services as attached resources makes it easier to use managed services (e.g., AWS S3, Google Cloud SQL) that scale independently of the app.
Portability: The app can run in different environments (local, staging, production) by simply changing the service endpoint in the config.
Loose coupling: The app isn’t tightly bound to a specific service instance, making it easier to upgrade, replace, or failover services.
Example:
A web app needs a database and an email service.
Instead of hardcoding the database connection:
db = connect("localhost:5432/myappdb") # Bad: hardcoded
The app reads the connection details from an environment variable:
import os DB_URL = os.getenv("DATABASE_URL") # e.g., "postgres://user:pass@host:port/db" db = connect(DB_URL)
Similarly, for an email service:
EMAIL_SERVICE_URL = os.getenv("EMAIL_SERVICE_URL") # e.g., "smtp://sendgrid.com" email_client = EmailClient(EMAIL_SERVICE_URL)
In development,
DATABASE_URL
might point to a local PostgreSQL instance (postgres://
localhost:5432/devdb
), while in production, it points to a cloud-hosted RDS instance (postgres://
rds.amazonaws.com:5432/proddb
). The code remains unchanged.If the email service switches from SendGrid to Postmark, only the
EMAIL_SERVICE_URL
needs to be updated in the environment - no code changes required.
Key takeaway: Backing services are treated as pluggable resources, accessed via configuration (e.g., URLs and credentials). This makes the app resilient to changes in service providers or locations, aligning with the goal of portability and scalability in modern app development.
V. Build, release, run
Strictly separate build and run stages
The principle "Strictly separate build and run stages" emphasizes dividing the process of preparing and executing an application into distinct stages - build, release, and run - to ensure consistency, reliability, and scalability.
Three distinct stages:
Build stage: This is where the application code is transformed into an executable artifact. It involves compiling the code (if needed), fetching dependencies (e.g., from
requirements.txt
), and packaging everything into a self-contained bundle (e.g., a Docker image, a JAR file, or a compiled binary). The output is a build artifact that’s immutable and ready for deployment.Release stage: The build artifact is combined with environment-specific configuration (e.g., environment variables like
DATABASE_URL
) to create a release. A release is a specific, deployable version of the app tailored for a particular environment (e.g., production). It’s uniquely identified (e.g., with a version number or timestamp) and ready to run.Run stage: The release is executed in the target environment as one or more processes (e.g., web servers or worker processes). This stage involves simply running the app without modifying the code or configuration.
Strict separation: These stages must be clearly separated:
The build stage produces a single artifact that doesn’t change afterward.
The release stage only adds configuration, not code changes.
The run stage executes the release without altering the artifact or config. This ensures that the same build can be reused across environments, and the running app is predictable and reproducible.
Why it matters:
Consistency: Using the same build artifact across environments (development, staging, production) guarantees that the app behaves identically everywhere, reducing "it works on my machine" issues.
Reproducibility: A release can be rolled back or redeployed exactly as it was, since the build artifact and config are versioned and immutable.
Security and auditing: Separating stages makes it clear what code and dependencies are running in production, as the build artifact is fixed and can be inspected before release.
Efficiency: The build stage, which can be resource-intensive, happens once, and the resulting artifact is reused for multiple releases, saving time and compute resources.
Clear responsibilities: Developers focus on the build stage, ops teams handle the release stage (configuring environments), and the run stage is automated by the platform, reducing overlap and errors.
Example:
Build stage: A Python web app’s code is pushed to a CI/CD pipeline (e.g., GitHub Actions). The pipeline:
Installs dependencies from
requirements.txt
.Runs tests to ensure quality.
Packages the app into a Docker image tagged as
myapp:build-123
.Stores the image in a registry (e.g., Docker Hub).
Release stage: The Docker image (
myapp:build-123
) is combined with production config (e.g.,DATABASE_URL=postgres://prod/db
,PORT=80
) to create a release, tagged asmyapp:release-2025-04-18
. This release is ready for production.Run stage: The release is deployed to a Kubernetes cluster, where it runs as multiple stateless containers. The containers execute the app without modifying the image or config.
If you need to deploy to staging, the same build (myapp:build-123
) is combined with staging config (e.g., DATABASE_URL=postgres://staging/db
) to create a different release, but the code remains unchanged.
Counter Example:
A bad practice would be building the app differently for each environment (e.g., installing different dependencies in production vs. development) or modifying code during deployment (e.g., editing a config file inside the app at runtime). This leads to inconsistencies and unpredictable behavior.
Another violation is running untested code in production by building it on the fly during deployment, risking errors.
Key takeaway: By strictly separating the build (create artifact), release (combine with config), and run (execute) stages, the app becomes more consistent, reproducible, and easier to manage across environments. This separation aligns with modern deployment practices like CI/CD and containerization, ensuring robust and scalable applications.
VI. Processes
Execute the app as one or more stateless processes
The principle "Execute the app as one or more stateless processes" emphasizes designing applications to run as stateless processes to ensure scalability, reliability, and simplicity in deployment.
Stateless processes: A process is stateless if it doesn’t store data or state internally between requests. Any data needed to process a request must come from the request itself or an external backing service (e.g., a database, cache, or file storage). The app should behave the same regardless of which process handles a request, and restarting a process shouldn’t lose critical information.
One or more processes: The app can run as a single process or multiple identical processes (e.g., for load balancing or scaling). Each process is independent, stateless, and interchangeable, allowing the app to distribute work across processes without relying on a specific process retaining state.
Why it matters:
Scalability: Stateless processes can be added or removed dynamically to handle increased traffic. For example, spinning up more processes to handle more users is straightforward if no state is tied to a specific process.
Reliability: If a process crashes or restarts, it doesn’t lose critical data, as state is stored externally (e.g., in a database). Another process can take over seamlessly.
Simplicity: Stateless apps are easier to deploy and manage because they don’t require complex state synchronization between processes or servers.
Cloud-friendliness: Modern platforms (e.g., Kubernetes, Heroku) expect apps to be stateless for horizontal scaling and fault tolerance.
How it works:
Store state externally: Persistent data (e.g., user sessions, transaction records) is stored in a backing service like a database (PostgreSQL), cache (Redis), or object storage (S3). For example, instead of keeping a user’s session in memory on a specific server, store it in Redis with a session ID.
Each request is self-contained: A request carries all necessary information (e.g., via headers, query parameters, or body), or the process fetches state from a backing service. For instance, an HTTP request with a user ID retrieves user data from a database rather than relying on in-memory state.
Multiple processes: The app might run several processes (e.g., web servers behind a load balancer). Since they’re stateless, any process can handle any request, and adding more processes increases capacity without coordination.
Example:
A web app handles user logins:
Stateless approach: When a user logs in, the app generates a session token and stores session data in Redis (e.g.,
session:123 = {user_id: 456}
). The token is sent to the client, and subsequent requests include the token. Any process can retrieve the session from Redis using the token, so it doesn’t matter which process handles the request.Stateful (bad) approach: The session is stored in the process’s memory. If the user’s next request goes to a different process (e.g., due to load balancing), the session is lost, and the user is logged out.
Scaling example: If traffic spikes, you deploy more stateless processes (e.g., additional containers in Docker). A load balancer distributes requests across them, and each process fetches state from the same database or cache, ensuring consistent behavior.
Counter Example: An app that stores user data in a local file or in-memory cache on a single server is stateful. If that server crashes or a request goes to another server, the data is unavailable, breaking the app or causing inconsistencies.
Key takeaway: By running as stateless processes and storing all state in external backing services, the app becomes easier to scale, more resilient to failures, and simpler to deploy across distributed environments like the cloud.
VII. Port binding
Export services via port binding
The principle "Export services via port binding" dictates that an application should make its services (e.g., web server, API) available to the outside world by binding to a specific network port and listening for incoming requests, rather than relying on external configuration or injection.
Export services: The application itself is responsible for providing its functionality (e.g., serving HTTP requests, responding to API calls) as a self-contained service. It does this by running a server process that listens for requests.
Via port binding: The app binds to a specific port (e.g., 5000 for a web server) and exposes its services over that port. The port is typically configured via an environment variable (e.g.,
PORT=5000
), allowing flexibility across environments. The app listens for incoming network requests (e.g., HTTP, TCP) on this port and handles them independently, without relying on an external server (like a pre-installed web server) to manage the binding.
Key aspects:
Self-contained: The app includes its own server software (e.g., a built-in HTTP server like Python’s Flask, Node.js’s Express, or Go’s
net/http
) rather than depending on an external server (e.g., Apache or Nginx) to host it.Dynamic port assignment: The app shouldn’t hardcode the port number; it should read it from the environment (e.g.,
os.getenv("PORT")
). This allows the hosting platform to assign any available port, avoiding conflicts in environments like cloud platforms or containers.Network accessibility: By binding to a port, the app becomes accessible to other services, clients, or load balancers over the network, making it a standalone service that can integrate into a larger system.
Why it matters:
Portability: The app can run in any environment (local, cloud, container) as long as the assigned port is open. It doesn’t rely on specific server software being pre-installed.
Simplicity: Embedding the server logic in the app reduces external dependencies and configuration complexity. The app is a self-contained unit that “just works” when deployed.
Scalability: Multiple instances of the app can run on different ports (or servers), and a load balancer can route traffic to them, enabling horizontal scaling.
Consistency: Developers and production environments use the same setup (e.g., the app’s built-in server), avoiding discrepancies like “it works locally but not in production.”
Cloud-native compatibility: Modern platforms (e.g., Heroku, Kubernetes) expect apps to bind to a port and handle requests directly, aligning with containerized and microservices architectures.
Example:
A Python Flask app:
from flask import Flask import os app = Flask(__name__) @app.route("/") def hello(): return "Hello, World!" if __name__ == "__main__": port = int(os.getenv("PORT", 5000)) # Default to 5000 if PORT not set app.run(host="0.0.0.0", port=port) # Bind to port and listen
In development, you set
PORT=5000
in a.env
file, and the app runs onhttp://localhost:5000
.In production (e.g., on Heroku), the platform assigns a dynamic port (e.g.,
PORT=8080
), and the app binds to it automatically. A load balancer might route external traffic to this port.The app includes Flask’s built-in server, so it doesn’t rely on an external server like Apache to expose the service.
Counter Example:
A bad practice is deploying an app to a specific web server (e.g., configuring Nginx to proxy requests to the app) with hardcoded port assumptions, requiring manual setup in each environment.
Another violation is assuming the app will always run on a fixed port (e.g., 80) without checking the environment, which causes conflicts in shared or dynamic environments like Docker.
Key takeaway: By exporting services through port binding, the app becomes a self-contained, portable unit that exposes its functionality over a configurable port. This approach simplifies deployment, enhances scalability, and aligns with modern cloud-native and microservices practices, ensuring the app can run consistently anywhere.
VIII. Concurrency
Scale out via the process model
The principle "Scale out via the process model" advocates for scaling an application horizontally by adding more stateless processes rather than increasing the resources of a single process (vertical scaling). This approach leverages the process model to handle increased load efficiently and reliably.
Scale out: Scaling out (or horizontal scaling) means increasing an application's capacity by running multiple identical instances of the app (processes) across different servers or containers. This is in contrast to scaling up (vertical scaling), which involves adding more resources (e.g., CPU, RAM) to a single server.
Process model: The app is designed to run as one or more stateless processes, each capable of handling requests independently (as per the "Execute the app as one or more stateless processes" principle). These processes are lightweight, share-nothing, and rely on external backing services (e.g., databases, caches) for state. The process model allows you to scale by simply adding more processes to distribute the workload.
How it works:
Each process runs the same codebase and is stateless, so any process can handle any request.
A load balancer distributes incoming requests (e.g., HTTP traffic) across the processes.
To handle more traffic, you increase the number of processes (e.g., from 2 to 10), potentially across multiple servers or containers.
The app’s architecture ensures that adding processes doesn’t require complex coordination, as state is managed externally (e.g., in a database or Redis).
Why it matters:
Scalability: Horizontal scaling allows the app to handle increased load by distributing work across many processes, which is often more cost-effective and flexible than upgrading a single server.
Resilience: If one process fails, others can continue handling requests, improving fault tolerance. New processes can be started quickly to replace failed ones.
Cloud-native compatibility: Modern platforms (e.g., Kubernetes, Heroku, AWS ECS) are designed for horizontal scaling, dynamically spinning up or down processes based on demand.
Simplicity: The process model avoids the complexity of managing in-memory state or inter-process communication, making scaling straightforward.
Elasticity: Processes can be added or removed in response to real-time traffic changes, enabling efficient resource use.
Example:
A web app built with Node.js runs as a single process on port 3000, handling 100 requests per second.
As traffic grows to 1000 requests per second, instead of upgrading the server’s CPU or RAM (scaling up), you:
Deploy 10 identical processes (e.g., as Docker containers) across multiple servers, each running the same app.
Configure a load balancer (e.g., Nginx, AWS ELB) to distribute requests across the processes.
Store session data in Redis and user data in PostgreSQL, so all processes access the same state without conflicts.
If traffic spikes, you add more processes (e.g., scale to 20). If traffic drops, you reduce to 5, saving resources.
The app’s stateless design ensures each process behaves identically, and the external state storage (Redis, PostgreSQL) keeps data consistent.
Counter Example:
A bad practice is scaling up by running a single, beefy process that stores state in memory (e.g., user sessions). This limits scalability (one server can only handle so much), risks data loss if the process crashes, and complicates deployment.
Another violation is using processes that share state locally (e.g., via shared memory or files), making it hard to distribute them across servers without synchronization issues.
Key takeaway: By scaling out via the process model, the app achieves scalability and resilience by running multiple stateless processes that share the workload, with state managed externally. This aligns with cloud-native architectures, enabling dynamic, cost-effective scaling to meet demand without modifying the codebase. Supporting concurrency means that different parts of an application can be scaled up to meet the need at hand.
IX. Disposability
Maximize robustness with fast startup and graceful shutdown
The principle "Maximize robustness with fast startup and graceful shutdown" focuses on designing applications to start quickly and shut down cleanly to ensure reliability, scalability, and resilience in dynamic environments like cloud platforms.
Fast startup: The application should initialize and be ready to handle requests as quickly as possible, ideally within seconds. This means minimizing heavy initialization tasks (e.g., preloading large datasets into memory) and deferring non-critical setup until after the app is running. Fast startups enable rapid deployment and scaling, especially in environments where processes are frequently started or stopped.
Graceful shutdown: When the app receives a termination signal (e.g., SIGTERM from a container orchestrator like Kubernetes), it should shut down cleanly by:
Stopping new incoming requests (e.g., closing the HTTP server).
Completing or safely terminating any in-progress tasks (e.g., finishing database writes or HTTP responses).
Releasing resources (e.g., closing database connections, flushing logs).
Exiting with a proper status code. This ensures no data is lost, clients receive proper responses, and the system remains stable during shutdowns.
Maximize robustness: By starting fast and shutting down gracefully, the app becomes more reliable and resilient to failures, restarts, or scaling events. It can handle frequent process churn (e.g., in containerized environments) without disrupting users or leaving the system in an inconsistent state.
Why it matters:
Scalability: Fast startups allow new processes to be spun up quickly to handle traffic spikes, enabling elastic scaling in cloud platforms.
Reliability: Graceful shutdowns prevent data corruption, dropped requests, or incomplete transactions when processes are terminated (e.g., during deployments or crashes).
Portability: Cloud-native systems (e.g., Kubernetes, Heroku) frequently start and stop processes. Fast startups and graceful shutdowns ensure the app integrates seamlessly with these platforms.
User experience: Quick startups reduce downtime during deployments, and graceful shutdowns ensure clients don’t see abrupt errors when processes stop.
Resource efficiency: Fast startup/shutdown cycles minimize resource waste, as processes can be started or stopped without long delays.
Example:
Fast startup:
A Python Flask app:
from flask import Flask import os app = Flask(__name__) @app.route("/") def hello(): return "Hello, World!" if __name__ == "__main__": port = int(os.getenv("PORT", 5000)) app.run(host="0.0.0.0", port=port)
The app starts in seconds because it only initializes the Flask server and binds to a port. Heavy tasks (e.g., database migrations) are deferred or handled by separate processes.
In contrast, an app that preloads a massive dataset into memory during startup would violate this principle, delaying readiness.
Graceful shutdown:
The same Flask app is enhanced to handle shutdown signals:
from flask import Flask import os import signal import sys import time app = Flask(__name__) @app.route("/") def hello(): return "Hello, World!" def handle_shutdown(signum, frame): print("Received shutdown signal, closing gracefully...") # Stop accepting new requests (Flask handles this internally) # Finish in-progress requests (Flask's WSGI server manages this) # Clean up resources (e.g., close DB connections, flush logs) print("Resources released, shutting down.") sys.exit(0) if __name__ == "__main__": # Register shutdown handlers for SIGTERM and SIGINT signal.signal(signal.SIGTERM, handle_shutdown) signal.signal(signal.SIGINT, handle_shutdown) port = int(os.getenv("PORT", 5000)) app.run(host="0.0.0.0", port=port)
When Kubernetes sends a
SIGTERM
during a deployment, the app stops accepting new requests, completes ongoing ones, releases resources, and exits cleanly.
Counter Example:
Slow startup: An app that takes minutes to start because it runs database migrations or precomputes data violates this principle, slowing down deployments and scaling.
Ungraceful shutdown: An app that abruptly terminates without finishing in-progress requests or closing connections risks dropping client requests or corrupting data (e.g., leaving a database transaction open).
Key takeaway: By ensuring fast startups and graceful shutdowns, the app becomes robust, able to start quickly to handle load or deployments and shut down cleanly to avoid errors or data loss. This principle is critical for cloud-native environments where processes are frequently started, stopped, or scaled, ensuring reliability and a seamless user experience.
X. Dev/Prod parity
Keep development, staging, and production as similar as possible
The principle "Keep development, staging, and production as similar as possible" emphasizes minimizing differences between the environments where an application runs - development (where developers write and test code), staging (where the app is tested in a production-like setting), and production (where the app serves real users). By keeping these environments as alike as possible, you reduce bugs and ensure consistent behavior across the application lifecycle.
Why similarity matters: Differences between environments often cause issues where the app works fine in development but fails in production (e.g., "it works on my machine"). Aligning environments ensures that code behaves predictably and that tests in development or staging accurately reflect production conditions.
Areas to keep similar:
Codebase: Use the same codebase across all environments, with the same dependencies and versions (as per the "One codebase" principle). Avoid environment-specific code branches.
Dependencies: Declare and isolate dependencies (e.g., via
requirements.txt
) so all environments install the same versions of libraries and tools.Backing services: Use production-like services in development and staging. For example, if production uses PostgreSQL, avoid using SQLite locally; use a local or cloud-hosted PostgreSQL instance instead.
Configuration: Store config in environment variables (e.g.,
DATABASE_URL
,API_KEY
) to ensure the app adapts to each environment without code changes, but keep the structure and behavior consistent.Infrastructure: Run the app in a similar way (e.g., as stateless processes with a load balancer) across environments. For instance, use containers (e.g., Docker) locally to mirror production’s containerized setup.
Data: While production data shouldn’t be used in development for security reasons, staging should have a representative dataset (e.g., anonymized production data) to mimic real-world conditions.
Gaps to minimize:
Time gap: Deploy code to production soon after it’s tested in development/staging to avoid drift (e.g., outdated code lingering in development).
Personnel gap: Developers should be involved in staging and production deployments, not just ops teams, to catch environment-specific issues early.
Tooling gap: Use the same tools (e.g., CI/CD pipelines, monitoring) across environments to ensure consistent behavior and debugging.
Why it matters:
Reliability: Similar environments reduce surprises, ensuring that bugs caught in development or staging are relevant to production.
Faster debugging: If environments are aligned, issues in production are easier to reproduce and fix locally, as the setup is nearly identical.
Confidence in deployments: Staging closely mimicking production means tests in staging are a reliable indicator of production success, reducing deployment risks.
Developer productivity: Developers can focus on writing code rather than troubleshooting environment-specific quirks.
Cloud-native alignment: Modern platforms expect consistency across environments, and tools like Docker or Kubernetes make it easier to replicate production setups locally.
Example:
Good practice:
A web app uses Docker to run a Python Flask app with PostgreSQL in all environments.
In development, developers run a local Docker Compose setup with a PostgreSQL container (
DATABASE_URL=postgres://
localhost:5432/devdb
) and the samerequirements.txt
as production.In staging, the app runs on a Kubernetes cluster with a cloud-hosted PostgreSQL instance (
DATABASE_URL=postgres://staging/db
) and the same Docker image built from the codebase.In production, the same Docker image runs on a larger Kubernetes cluster with a production-grade PostgreSQL instance (
DATABASE_URL=postgres://prod/db
).Dependencies, runtime (Python version), and process model (stateless Flask processes) are identical across all three, with only environment variables differing.
Result: Bugs in development are likely to reflect production issues, and staging tests closely predict production outcomes.
Bad practice:
In development, the app uses SQLite and a local Python install with unpinned dependencies.
In staging, it uses MySQL with a different web server (e.g., Apache vs. Flask’s built-in server).
In production, it uses PostgreSQL on a cloud platform with pinned dependencies.
Result: Features work locally but break in staging or production due to database differences, dependency mismatches, or runtime variations.
Counter Example:
Using different databases (e.g., SQLite locally, PostgreSQL in production) can cause SQL queries to fail in production due to dialect differences.
Running a single-threaded server locally but a multi-process setup in production might hide concurrency bugs until deployment.
Developers testing with fake data locally while production uses real, complex data can miss performance issues.
Key takeaway: Keeping development, staging, and production as similar as possible ensures that the app behaves consistently across its lifecycle, reducing bugs, simplifying debugging, and increasing deployment confidence. By aligning code, dependencies, services, and infrastructure—while managing differences via environment variables—the app becomes more robust and easier to maintain in modern, cloud-native workflows.
XI. Logs
Treat logs as event streams
The principle "Treat logs as event streams" emphasizes that an application should output logs as a continuous, unbuffered stream of events to stdout (standard output) rather than managing log files itself. This approach simplifies logging, enhances scalability, and integrates well with modern deployment environments.
Logs as event streams: Logs are treated as a time-ordered sequence of events generated by the app, such as user actions, errors, or system metrics (e.g., "
User ID 123 logged in at 2025-04-18 10:00:00
"). Instead of writing these events to a file or database, the app emits them directly to stdout in real-time, producing a continuous stream of log entries.No log management by the app: The application shouldn’t handle log file creation, rotation, or storage. It simply outputs logs to stdout, and the runtime environment (e.g., container orchestrator, cloud platform, or operating system) is responsible for capturing, routing, aggregating, or storing them. This could involve redirecting logs to a file, a logging service (e.g., ELK Stack, CloudWatch), or a monitoring tool.
Key characteristics:
Unbuffered output: Logs are written immediately to stdout without buffering, ensuring real-time visibility.
Simple format: Logs should be plain text, ideally with a timestamp and structured data (e.g., JSON), to make them easy to parse and process.
Environment-agnostic: The app doesn’t care where logs go after being emitted—it’s the job of the environment to handle them, whether locally or in production.
Why it matters:
Simplicity: By outputting logs to stdout, the app avoids complex log file management (e.g., rotation, compression), reducing code complexity and maintenance.
Flexibility: The environment can redirect logs to different destinations (e.g., a local terminal for development, a centralized logging system for production) without changing the app’s code.
Scalability: In distributed systems with many processes (e.g., containers), collecting logs from stdout allows aggregation into a single stream for analysis, regardless of how many instances are running.
Debugging and monitoring: Real-time event streams enable immediate access to logs for troubleshooting or monitoring, critical for cloud-native apps where processes may be short-lived.
Separation of concerns: The app focuses on generating logs, while the environment handles storage, analysis, and retention, aligning with the Twelve-Factor principle of loose coupling.
Example:
A Python Flask app:
from flask import Flask import logging import sys app = Flask(__name__) # Configure logging to stdout logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') @app.route("/login") def login(): logging.info("User attempted login") return "Logged in" if __name__ == "__main__": app.run()
When a user hits
/login
, the app outputs to stdout:2025-04-18 10:00:00,123 - INFO - User attempted login
In development, the developer sees this in the terminal.
In production, a platform like Kubernetes captures stdout and forwards it to a logging service (e.g., Elasticsearch), where logs from multiple processes are aggregated for analysis.
The app doesn’t write to a file like
app.log
or manage log rotation—it just emits the stream.
Counter Example:
A bad practice is an app that writes logs to a specific file (e.g.,
/var/log/app.log
) and handles rotation itself. This complicates deployment, as the app needs file system permissions, and logs are harder to aggregate in distributed systems.Another violation is buffering logs in memory before writing, which delays visibility and risks losing logs if the process crashes.
Key takeaway: Treating logs as event streams means outputting them as a real-time, unbuffered sequence to stdout, letting the environment handle collection, storage, and analysis. This simplifies the app, supports scalability, and integrates seamlessly with modern logging systems, ensuring logs are accessible and useful for debugging and monitoring.
XII. Admin Processes
Run admin/management tasks as one-off processes
The principle "Run admin/management tasks as one-off processes" emphasizes that administrative or management tasks (e.g., database migrations, data imports, or maintenance scripts) should be executed as separate, short-lived processes using the same codebase and environment as the main application, rather than being embedded in the app or run manually. In simpel terms, when you need to run a database migration, data fix, or a cron job - run it as a separate, short-lived process, not inside your app or web server.
Admin/management tasks: These are tasks outside the app’s regular workload (e.g., serving HTTP requests). Examples include:
Running database migrations (e.g., creating tables or updating schemas).
Importing or exporting data (e.g., seeding a database or generating reports).
Running one-time scripts (e.g., fixing data inconsistencies).
Executing maintenance tasks (e.g., clearing caches, pruning old records).
Interacting with the app’s data via a console (e.g., a REPL like Python’s
manage.py shell
in Django).
One-off processes: These tasks should run as standalone, ephemeral processes that:
Use the same codebase as the main app (e.g., web processes).
Operate in the same environment (e.g., same dependencies, configuration, and backing services).
Start, perform their task, and exit cleanly once complete.
Are not part of the app’s long-running processes (like web servers or workers).
Key aspects:
Same environment: The one-off process should access the same configuration (via environment variables) and backing services (e.g., database, cache) as the main app to ensure consistency.
Packaged with the app: Admin tasks should be defined in the codebase (e.g., as scripts or commands in a framework like Django’s
manage.py
or Rails’rake
) and shipped with the release.Run via the platform: Use the deployment platform’s tools (e.g.,
kubectl run
,heroku run
, ordocker exec
) to execute these processes, ensuring they’re launched in the correct environment.
Why it matters:
Consistency: Running admin tasks in the same environment as the app (same code, dependencies, and config) prevents discrepancies, ensuring tasks behave as expected in production.
Reproducibility: Packaging tasks in the codebase and running them as processes makes them repeatable and trackable, avoiding manual interventions that are error-prone.
Scalability: One-off processes can be run on demand without affecting long-running processes, fitting well in distributed systems where resources are dynamically allocated.
Security: Using the app’s environment (e.g., environment variables for credentials) avoids hardcoding sensitive data or relying on external scripts with different access patterns.
Cloud-native compatibility: Modern platforms (e.g., Kubernetes, Heroku) support running one-off tasks as processes, integrating seamlessly with deployment workflows.
Example:
A Python Django app has a web process running in production and needs to run a database migration and a data import.
In the codebase:
Database migrations are defined as Django migration files (e.g.,
0001_
initial.py
).A custom management command for data import is added, e.g.,
import_
data.py
:from django.core.management.base import BaseCommand class Command(BaseCommand): help = "Imports data into the database" def handle(self, *args, **options): self.stdout.write("Starting data import...") # Logic to import data self.stdout.write("Data import complete.")
Running one-off processes:
For migrations, the developer runs:
$ heroku run python manage.py migrate
or in Kubernetes:
$ kubectl run migrate --image=myapp:latest -- python manage.py migrate
This spins up a temporary process, applies migrations to the database, and exits.
For data import:
$ heroku run python manage.py import_data
A new process starts, imports the data, and terminates.
Both processes use the same Docker image, dependencies (
requirements.txt
), and environment variables (e.g.,DATABASE_URL
) as the web process, ensuring consistency.
Counter Example:
A bad practice is running admin tasks manually via SSH on a production server, using a different environment or dependencies, which risks inconsistencies (e.g., missing libraries) and lacks traceability.
Another violation is embedding admin tasks in the web process (e.g., running migrations on every app startup), which slows startups and couples unrelated logic.
# Bad practice: running migrations from inside the app on every start app.on("start", () => { run_migrations() # dangerous, unexpected });
Writing external scripts not included in the codebase (e.g., a separate
fix_
data.py
on a developer’s machine) breaks reproducibility and version control.
Key takeaway: Admin and management tasks should be run as one-off processes using the same codebase, dependencies, and environment as the main app. This ensures consistency, reproducibility, and integration with modern deployment platforms, making tasks reliable and manageable in development and production alike.
Subscribe to my newsletter
Read articles from Maxat Akbanov directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Maxat Akbanov
Maxat Akbanov
Hey, I'm a postgraduate in Cyber Security with practical experience in Software Engineering and DevOps Operations. The top player on TryHackMe platform, multilingual speaker (Kazakh, Russian, English, Spanish, and Turkish), curios person, bookworm, geek, sports lover, and just a good guy to speak with!