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

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.
