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.
