The Anatomy of a Low-Maintenance Django Monolith

Why a 2019 Django monolith needed zero commits in all of 2024 — pinned deps, env-driven config, and one Docker image for web, worker, and beat.

The Anatomy of a Low-Maintenance Django Monolith
Contents

Here’s the metric I’m proudest of on a codebase I wrote early in my career: for the whole of 2024, I made zero commits to it. Not a hotfix, not a dependency bump, not a config tweak. It sat in production — a veterinary clinic platform serving real clinics, OTP logins, billing, reminder SMS going out on a schedule — and it asked nothing of me. When the next change finally landed, in October 2025, it wasn’t a bug fix. It was a vendor migration I chose to do, and it shipped as a config change.

I want to be clear that this is not luck. A system doesn’t drift into needing zero maintenance. Low maintenance is a set of decisions you make up front, most of them boring, several of them slightly against the grain of what everyone tells you to do. This is the anatomy of those decisions, from a Django monolith I built solo in 2019 and still deploy today.

Low maintenance is a design goal, not a happy accident

The instinct early in your career — I had it too — is to reach for the newest thing. Newest framework, freshest dependency, the pattern that was trending on Hacker News that week. I went the other way, and it’s the single decision the rest of this post hangs off: I optimized for how little the system would need from one person, years later, rather than for how modern it looked at launch.

The first commit was actually a Flask bootstrap. I threw it out within weeks and rewrote on Django and DRF. For a solo builder shipping a multi-role product — five distinct staff roles, each with its own permissions and workflows — Django’s batteries meant every hour went into clinic logic instead of plumbing I’d have to own forever. Fewer things I hand-wrote is fewer things that could rot.

Pin everything, then actually leave it alone

The pinned stack in production today is the pinned stack from years ago:

Django==3.1.2
djangorestframework==3.12.1
celery==4.4.2

Running on python:3.8-slim-bullseye. Every dependency pinned to an exact version, no floating ranges, no >=.

The conventional advice is the opposite: stay current, patch continuously, keep your dependencies fresh so the eventual upgrade is never a cliff. That advice is correct for a team with a maintenance rotation. It’s actively wrong for a solo-operated product where every upgrade is an unpaid, unscheduled interruption. So I froze on a known-good stack and chose not to chase upgrades.

What that bought me is boring and enormous: a build I did in 2019 produces the same image in 2025. Nothing silently changes underfoot. When I hadn’t touched the repo in a year and needed to rebuild, there were no surprise breakages from a transitive dependency that moved while I wasn’t looking, because nothing was allowed to move.

The honest trade-off: you defer, you don’t escape. Freezing on Django 3.1 means the day I must move off it — for a security fix I can’t backport, or a Python version that ages out — I pay that upgrade as one deliberate project, on my calendar, not as a paper cut every sprint. For a system a human isn’t babysitting, batching the pain is the right shape. Pin hard, and hold a pre-commit line to keep the code itself tidy — black, flake8, pyupgrade, import ordering — so the codebase you come back to after a year of silence still reads like you left it yesterday.

Config is environment, code is a constant

The rule I held to religiously: the deployed artifact never knows where it’s running. Everything that differs between staging, production, or a local box lives in environment variables, and the code just reads them.

# settings.py (representative)
import os

PROD_MODE = os.environ.get("PROD_MODE", "false").lower() == "true"

DB_PREFIX = "PROD_DB_" if PROD_MODE else "DEBUG_DB_"
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ[f"{DB_PREFIX}NAME"],
        "USER": os.environ[f"{DB_PREFIX}USER"],
        "HOST": os.environ[f"{DB_PREFIX}HOST"],
        # reuse connections against the managed Postgres instead of
        # opening a fresh one per request
        "CONN_MAX_AGE": 300,
    }
}

USE_SPACES_CDN = os.environ.get("USE_SPACES_CDN", "false").lower() == "true"

One PROD_MODE flag flips the entire database configuration. A USE_SPACES_CDN toggle decides whether media is served from the origin bucket or the CDN. File storage goes through django-storages with a set of custom subclasses — one for static, one for public media, one for private media — so which object store is behind them is just credentials in the environment:

# storage_backends.py
from storages.backends.s3boto3 import S3Boto3Storage

class StaticStorage(S3Boto3Storage):
    location = "static"
    default_acl = "public-read"

class PublicMediaStorage(S3Boto3Storage):
    location = "media"
    default_acl = "public-read"
    file_overwrite = False

class PrivateMediaStorage(S3Boto3Storage):
    location = "private"
    default_acl = "private"
    file_overwrite = False

This felt like over-plumbing in 2019. It paid off spectacularly in October 2025. The migration off one S3-compatible provider onto another — and the switch from one transactional email vendor to another — was not a rewrite. Both speak the same protocols the code already targeted (S3Boto3 for storage, SMTP for mail), so the whole thing was new values in the environment plus an image rebuild. The code diff was effectively nothing. That’s the entire promise of 12-factor config made concrete: you get to swap infrastructure vendors without opening the application.

One image, three ways to run it

Here’s the decision that got the most raised eyebrows. A Django app like this has several distinct processes: the web server, the Celery workers, and the Celery beat scheduler. The conventional move is a purpose-built image per role — a lean web image, a separate worker image, and so on.

I built one image that serves all of them. The web container, every worker container, and beat all run the exact same artifact. What differs is only the command:

# same image, different command per role
services:
  web:
    image: vetclinic-api:d1.234
    command: gunicorn petapp_api.wsgi -k gevent -w 2 --worker-connections 1000

  worker-sms:
    image: vetclinic-celery:d1.234
    command: celery -A petapp_api worker -Q send_text_messages -n sms@%h

  beat:
    image: vetclinic-celery:d1.234
    command: celery -A petapp_api beat

Why fight the convention? Because a solo maintainer with N Dockerfiles has N build pipelines to keep in sync, N places for a dependency to drift, N things to rebuild when Python moves. One artifact means the thing I tested as the web server is byte-for-byte the thing running my workers. There’s no “works in the API image but the worker image has an older library” class of bug, because there’s one image. The d1.234 tag tells the story — that’s on the order of 234 incremental builds over the project’s life, and every one of them was a single build, promoted everywhere.

And “everywhere” is broader than it sounds. That same image runs three ways depending on what the deployment needs: docker-compose on a plain droplet, raw Kubernetes manifests, or a managed App Platform spec. One artifact, several hosting escape hatches — so I was never locked to a provider’s control plane either. When the cheap option is enough, run compose on a small box; when I need replicas and ingress, the same image drops into k8s unchanged.

Fewer batteries means fewer batteries to replace

The through-line under all of this is dependency count. Every external service you add is another thing that can page you at 3am. So I leaned on what Django ships and resisted bolting on more.

Django’s ORM, migrations, admin, and auth framework did work I’d otherwise own by hand. Per-process caching used LocMemCache rather than standing up a separate cache tier — Redis was already in the stack as the Celery broker, but I didn’t reach for it as a cache just because it was there. The web tier runs Gunicorn with two gevent workers at a thousand connections each; a queryset pass and an APM tool went in only once observability told me they’d earn their place, not preemptively. Every component I didn’t add is a component I never had to patch, monitor, or migrate.

The maintenance you do still pay

Low maintenance is not no maintenance, and I’d be lying to you if I framed it that way. You pay, you just pay in a shape you chose.

You pay up front: the env plumbing, the storage abstraction, the discipline to pin and the pre-commit hooks to keep the code clean are all work you do before the payoff exists. And you pay eventually — the 2025 vendor migration was real work, and there is a Django 3.1 upgrade with my name on it somewhere in the future. The win isn’t escaping maintenance. It’s converting a stream of unpredictable interruptions into a small number of deliberate, schedulable projects. A system that needs me on a random Tuesday is expensive. A system that needs a planned afternoon once a year is cheap.

What “low maintenance” actually requires up front

If I compress six years of this into advice for the version of me who started it: pick boring, proven tools and then commit to them — pin hard and don’t chase the new hotness. Push every environmental difference out of the code and into the environment, so swapping a vendor is a config change and not a rewrite. Build one artifact and run it many ways, so there’s one thing to reason about. And add dependencies only when something you can measure says you need them.

None of that is clever. That’s the point. The clever code is the code that needs you. The boring code is the code that lets you disappear for a year — and come back to find it did its job the whole time.