Skip to main content

Command Palette

Search for a command to run...

Understanding the 12-Factor App Through Real Experience (Not Just Theory)

Updated
6 min read
Understanding the 12-Factor App Through Real Experience (Not Just Theory)
M
DevOps enthusiast | Docker | Frappe | Learning in public Exploring scalable systems and real-world engineering practices

When I started learning the 12-Factor App, I thought it would be just another theoretical concept.

But as I went deeper, something interesting happened…

I realized I had already been working with many of these principles in a real-world project — I just didn’t know they had a name.

This blog is not a textbook explanation.
It’s how I understood the 12-factor app by connecting it with my actual experience working on a Frappe-based system.


💡 The Moment It Clicked

While learning, I kept thinking:

“Wait… I’ve done this before.”

And that’s when things started making sense—not as rules, but as practical patterns.


🔹 1. Codebase — Keep It Clean

One app → one codebase.

What this looked like in practice:

A single repository for the main Frappe app (UI, models, doctypes, etc.)
A separate repo/app for business-specific logic or customizations

Why it matters:

Clear source-of-truth for each deploy
Easier CI/CD and rollbacks
Simpler contributor workflows

🔹 2. Dependencies — Don’t Trust the System

Everything your app needs should be explicitly declared.

What I did:

Docker images that pin exact OS + Python + libs
Requirements files (requirements.txt or Pipfile.lock) committed to repo
Dev and prod images built from the same Dockerfile with different envs

Example:

build and tag

docker build -t my-frappe:1.0.0 .

run locally

docker run -p 8000:8000 --env-file .env my-frappe:1.0.0

Why it matters:

Avoid "works on my machine" problems
Reproducible builds across CI, dev, and prod

🔹 3. Config — Keep It Outside Code

Anything that changes between environments should be configurable, not hardcoded.

What I used:

.env for development
.env.prod or secret manager in production

Example .env snippet:

FRAPPE_SITE=site1.local DB_HOST=mariadb DB_USER=frappe DB_PASS=${DB_PASS} # injected by CI or secret manager

Best practice:

Use environment variables for credentials and URLs
Keep defaults minimal and non-sensitive in code

🔹 4. Backing Services — Keep Them Replaceable

Treat backing services (DB, cache, message brokers, object store) as attached resources.

Real setup:

MariaDB as a separate container/service
Redis/Memcached separately for caching & queueing

Benefits:

Swap implementations without code changes
Easier upgrades and maintenance

Tip: Use environment variables to provide service connection strings so replacement is simple:

DATABASE_URL=mysql://frappe:secret@mariadb:3306/frappe

🔹 5. Build, Release, Run — Understand the Flow

Containerization made this explicit for me:

Build → produce immutable images/artifacts
Release → combine build with config (env) and a versioned tag
Run → execute the artifact in the target environment

CI/CD idea:

CI builds image → pushes to registry → CD pulls and deploys the tagged release

Why separate?

Clear rollback points
Auditable deployments

🔹 6. Processes — Keep Them Stateless

Processes should be ephemeral and stateless. Persist important state in backing services.

How we applied it:

App processes only handle requests
Database stores persistent state
File uploads stored in external object store (or a mounted persistent volume), not local ephemeral disk

Pattern:

Session storage in DB or Redis
Avoid writing critical files to container filesystem unless using persistent volumes

🔹 7. Port Binding — Expose via Ports

Apps should be self-contained web processes that bind to a port and serve requests.

What we did:

Local dev: Frappe served on 0.0.0.0:8000
Production: route traffic through load balancer/traefik to containers; secure with HTTPS (port 443)

Note:

Containers should log to stdout/stderr and expose the port expected by the orchestration layer.

🔹 8. Concurrency — Scale with Processes

Design for scaling by adding processes of different types (web, worker, scheduler).

In Frappe:

Gunicorn for web workers
Background workers for jobs/queues
Scheduler for periodic tasks

Scaling tips:

Horizontally scale worker counts based on queue depth
Use autoscaling rules where possible (CPU, queue size)

🔹 9. Disposability — Fast Start, Fast Shutdown

Processes should start quickly and shut down gracefully.

My learning point:

New users reported issues after container restarts — traced to improper graceful shutdowns in custom code

Practical fixes:

Respect SIGTERM/SIGINT in long-running Python tasks:

import signal, sys

def handle(sig, frame): # cleanup logic sys.exit(0)

signal.signal(signal.SIGTERM, handle) signal.signal(signal.SIGINT, handle)

Configure timeouts in Gunicorn (graceful-timeout, timeout) and support connection draining in load balancers

🔹 10. Dev/Prod Parity — Keep It Similar

The smaller the gap between dev and prod, the fewer surprises.

How we stayed close:

Same base Docker images in dev and prod
Similar service topology (DB, cache, worker) in dev via docker-compose
Shared schema migrations and test data flow

Don't do:

Developing against different DB engines or drastically different versions of dependencies

🔹 11. Logs — Let the System Handle Them

Treat logs as event streams; don’t attempt to manage log files inside the app.

What we did:

Write logs to stdout/stderr (Frappe + Python logging)
Docker/Orchestrator collects the logs
Forward logs to centralized system (ELK/EFK, Loki, or cloud logging) in prod

Why:

Centralized searching, alerting, retention policies
Easier troubleshooting across distributed services

🔹 12. Admin Processes — One-Off Tasks

One-off administrative or maintenance tasks should run in an identical environment to regular processes.

Examples:

DB restores
Data migrations
Management scripts executed via the same image that runs production processes

Pattern:

Use containerized admin commands: docker run --rm my-frappe:1.0.0 bench --site site.local migrate
Keep admin scripts in repo and document them

🔥 Final Thoughts (Expanded)

What I liked most about seeing these principles in real life:

They’re practical: small, opinionated choices that compound into more maintainable systems
They naturally emerge when you move from single-server hacks to containerized, team-run systems
They give you a language to explain architecture choices to teammates and to negotiate ops vs dev tradeoffs

Common pitfalls I saw:

Hardcoded secrets in code or config files in repo
Relying on local filesystem for state
Not handling signals in background jobs
Dev environments drifting far from production

Quick checklist before a release:

All secrets moved to env/secret manager
Image built from pinned dependencies
DB migrations included in release plan
Proper graceful shutdown behavior for services
Logs centralized and monitored
Health/readiness probes defined in orchestrator

🙌 Closing

This was how the 12-Factor principles stopped being abstract and started shaping real decisions in our Frappe-based system. If you’re using containers, running background workers, or building any backend system, you’re probably already following parts of this model — and you can get big wins by making the rest explicit.