Contents
vast.ai had shown me exactly what GPU transcoding should cost — cents per video — and then, in the same fortnight, shown me exactly why I couldn’t yet lean the Happy Thoughts platform on it: instances vanished mid-encode, and “your video is ready” turned into a coin flip. I had a hard number for what the work was worth and a hard no on the way I’d been buying it. What I did next is the least fashionable decision in this entire series, and one of the ones I’m most sure about.
I drove to a computer shop and bought a desktop.
The call: own the silicon, cheaply
Not a server. Not a rack unit with redundant PSUs and a lights-out management card. An ordinary consumer tower with an NVIDIA RTX 4050 in it, the kind of machine a student buys to play games, for about 600 US dollars. I put Ubuntu on it, gave it a static lease on the office network, and it became production infrastructure for a platform that, by the time this setup retired, was serving tens of thousands of people their courses.
The logic was almost embarrassingly simple, and it’s the logic I’d defend in any design review where someone is spending a non-profit’s money. The transcode workload was bursty but not enormous — 150-plus videos on a big upload day, then nothing for a week. vast.ai had told me the marginal cost of an encode was cents. A cloud GPU instance sized for the burst would sit idle most of the month while I paid by the hour anyway; even a modest on-demand GPU box runs a few hundred dollars a month if you leave it up, and adds cold-start pain if you don’t. The RTX 4050 desktop was a one-time 600 dollars. Under encode load it pulls maybe 120 to 150 watts — call it ten, fifteen dollars a month of Indian electricity to run it around the clock. Break-even against any hourly cloud GPU was measured in weeks.
Why a cheap card is the right card: what NVENC actually is
There’s a hardware detail that makes a gaming card the correct tool here, not a compromise. When you encode video “in software,” a codec like x264 runs the compression math on the CPU, frame by frame — flexible, high quality, and slow. NVENC is NVIDIA’s hardware encoder: a dedicated, fixed-function block etched onto the GPU die whose only job is to turn raw frames into H.264 or HEVC. Think of it as a purpose-built appliance welded next to the general-purpose CUDA cores you pay a premium for on datacenter cards. Because it’s fixed-function silicon rather than software, it encodes many times faster than a CPU and barely raises the chip’s temperature.
The kicker: for H.264 and HEVC, the encoder block on a humble RTX 4050 is largely the same generation of block as the one on cards costing ten times more. Transcoding doesn’t lean on the expensive CUDA cores; it leans on the encoder ASIC, and NVIDIA ships roughly the same one across the consumer stack. So “gaming card as server” isn’t a hack that happens to work. For this specific job it’s the right part, bought at the right price.
Why a separate FastAPI service, and why it pulls
The instinct, given everything earlier in this story, would be to make the desktop a Celery worker. We already ran queue-per-workload Celery on named RabbitMQ queues; just point a transcode worker at the box and let RabbitMQ push jobs to it. I deliberately didn’t, for two reasons — and the second one is the whole architecture.
First, decoupling. A decoupled service is one that shares nothing with the rest of the system except a thin, explicit contract — so you can build it, deploy it, and crash it without touching anything else. The desktop runs its own process, a small standalone FastAPI transcoding service with one job: turn a source file into an HLS ladder on the GPU. (HLS is the streaming format that chops a video into a few-seconds-long chunks at several quality levels, so a player streams only what it needs — the “ladder” is those quality rungs, 240p up to 1080p.) It’s not a plugin, not a Celery worker sharing the Django app’s dependency tree, not something a bad deploy of the main backend can take down. It has a /healthz, a status endpoint, and a background loop. Keeping it a separate deployable meant I could reason about it — and reboot it — entirely independently of everything else.
Second, and more important: the box pulls work; it is never a push target. A desktop on an office network sits behind NAT and a residential-grade connection, so the cloud can’t reliably open a connection to it, and I wasn’t going to punch a firewall hole or run a tunnel just so RabbitMQ could shove jobs at a machine that might be asleep. So I inverted the direction. When a video is uploaded, the Django backend does nothing heroic — it writes a pending row in a transcode-jobs table. The desktop polls a claim endpoint, leases a job, does the work, uploads the renditions to Wasabi, and marks it done.
flowchart LR
U[Content team uploads] --> D[Django backend]
D --> Q[Transcode jobs table<br/>pending rows]
Q -->|desktop polls and claims| F[RTX 4050 desktop<br/>FastAPI NVENC service]
F --> W[Wasabi object storage<br/>HLS renditions]
W --> C[Cloudflare CDN]
C --> V[Viewers on mobile]Notice what the diagram makes obvious: the desktop is a dead-end branch off to the side. Uploads flow into the backend, the backend writes rows, and delivery flows out through Wasabi and Cloudflare to viewers — none of that path runs through the desktop. The box reaches up to grab work and pushes results sideways into storage. It is nowhere near the serving path.
Here is the actual loop the desktop runs — a plain async FastAPI service, not a Celery worker, so it owns its own lifecycle. The web app exists only to expose /healthz and a status page; all the real work happens in a background task started on @app.on_event("startup"). claim_next_job is a single POST to the Django backend that atomically leases one pending row (Django does the select_for_update(skip_locked=True) under the hood — more on that lock below), so two workers, or a worker that restarts mid-poll, can never grab the same job:
# desktop/service.py — the FastAPI transcode service
import asyncio
import httpx
from fastapi import FastAPI
app = FastAPI()
API = "https://backend.example.org" # the Django + DRF spine
POLL_INTERVAL = 5.0 # seconds between empty polls
_state = {"current": None, "done": 0} # what /healthz reports
@app.get("/healthz")
async def healthz() -> dict:
return {"ok": True, **_state}
@app.on_event("startup")
async def start_worker() -> None:
asyncio.create_task(worker_loop()) # one background consumer
async def worker_loop() -> None:
async with httpx.AsyncClient(timeout=30, headers=_auth()) as api:
while True:
resp = await api.post(f"{API}/internal/transcode/claim/",
json={"worker": WORKER_ID})
if resp.status_code == 204: # nothing pending
await asyncio.sleep(POLL_INTERVAL)
continue
job = resp.json() # {id, asset_id, source_url, lease_expires}
_state["current"] = job["asset_id"]
try:
await run_ladder(api, job) # NVENC ffmpeg per rung, then upload
await api.post(f"{API}/internal/transcode/{job['id']}/done/")
_state["done"] += 1
except Exception: # never let the loop die
await api.post(f"{API}/internal/transcode/{job['id']}/release/")
await asyncio.sleep(POLL_INTERVAL)
finally:
_state["current"] = None
That except Exception is deliberate and the one place I’ll break my own “catch the specific exception” rule: this is a top-level supervisor loop whose entire job is to survive anything a single encode can throw — a corrupt upload, a SIGKILL from the OOM killer, a dropped Wasabi connection — release the lease so the row heals back to pending, and keep pulling. A worker loop that dies on an unexpected exception is worse than one that logs and continues. In prod it’s except Exception as exc: logger.exception(...) so nothing is swallowed silently.
That inversion is what turned a fragile object — a single gaming PC — into something I could put in the critical path of content publishing without losing sleep. If the box is off, jobs don’t fail. They wait. When it wakes up, it drains the backlog in upload order. The failure mode of the entire transcoding tier is “new videos appear late,” never “the platform is down.”
Claiming a job without two workers colliding
The claim endpoint is the one piece that lives back in Django, and it’s where the concurrency has to be exactly right. Even though there was only ever one desktop, I wrote it as if there were ten, because “there is only one worker” is the kind of assumption that silently becomes false the day someone spins up a second box to drain a backlog:
# backend: DRF view — lease exactly one pending job, safely
from django.db import transaction
@transaction.atomic
def claim_job(worker_id: str) -> TranscodeJob | None:
job = (
TranscodeJob.objects
.select_for_update(skip_locked=True) # skip rows another txn holds
.filter(status="pending")
.order_by("created_at") # upload order, oldest first
.first()
)
if job is None:
return None
job.status = "leased"
job.worker_id = worker_id
job.lease_expires = timezone.now() + timedelta(minutes=30)
job.save(update_fields=["status", "worker_id", "lease_expires"])
return job
select_for_update() only does anything inside transaction.atomic() — outside a transaction there’s no lock to hold, and Django raises TransactionManagementError to stop you shipping the bug. Postgres defaults to READ COMMITTED isolation, so without the row lock two concurrent claims could both read the same pending row before either writes, and both would “win” — the classic lost-update race. skip_locked=True is what makes this scale cleanly: instead of a second worker blocking on the row the first one holds, it steps over locked rows and grabs the next free one, so N workers drain N distinct jobs with no contention. A stale lease (worker died mid-encode) is swept by a periodic task that flips leased rows past lease_expires back to pending.
The encode itself: keep it all on the GPU
The one place the code genuinely teaches is the ffmpeg invocation, because the naive version throws away most of the GPU’s advantage. If you decode on the CPU, copy frames into system RAM, scale them there, then copy back to the GPU to encode, you’ve spent your speedup on a memory bus. The trick is to keep decode, scale, and encode resident on the card, and to encode every rung of the ladder in a single ffmpeg process so the source is decoded exactly once on the GPU and split to all the outputs:
ffmpeg -hwaccel cuda -hwaccel_output_format cuda -i source.mp4 \
-filter_complex "[0:v]split=3[v1][v2][v3]; \
[v1]scale_cuda=-2:1080[v1o]; \
[v2]scale_cuda=-2:720[v2o]; \
[v3]scale_cuda=-2:480[v3o]" \
\
-map "[v1o]" -c:v:0 h264_nvenc -preset p5 -tune hq \
-rc vbr -cq 23 -b:v:0 0 -bf 3 -spatial_aq 1 \
-g 48 -keyint_min 48 -sc_threshold 0 \
-map "[v2o]" -c:v:1 h264_nvenc -preset p5 -tune hq \
-rc vbr -cq 25 -b:v:1 0 -bf 3 -spatial_aq 1 \
-g 48 -keyint_min 48 -sc_threshold 0 \
-map "[v3o]" -c:v:2 h264_nvenc -preset p5 -tune hq \
-rc vbr -cq 27 -b:v:2 0 -bf 3 -spatial_aq 1 \
-g 48 -keyint_min 48 -sc_threshold 0 \
-map a:0 -map a:0 -map a:0 -c:a aac -b:a 128k \
-f hls -hls_time 6 -hls_playlist_type vod -hls_segment_type mpegts \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
out_%v/index.m3u8
There is a lot loaded into those flags, and every one earns its place:
-hwaccel_output_format cuda+scale_cudakeep the decoded frames in GPU memory. Decode, the three scales, and the three encodes all happen on the card; frames never round-trip through system RAM.-g 48 -keyint_min 48 -sc_threshold 0is the single most important line for adaptive streaming and the one people forget.-g 48forces a keyframe (a fully self-contained frame a player can start decoding from) every 48 frames — 2 seconds at 24fps.-keyint_min 48forbids ffmpeg from placing them any closer, and-sc_threshold 0disables scene-cut keyframes that would otherwise land at different timestamps on each rung. The result: every rung has its keyframes at exactly the same moments, so the-hls_time 6segments line up across the whole ladder. That alignment is what lets a player switch from 480p to 1080p mid-stream at a segment boundary without a stutter. Get this wrong and ABR silently degrades to “pick one rung and pray.”-rc vbr -cq 23 -b:v 0is NVENC’s quality-targeted mode.-rc vbris variable bitrate;-cq 23sets a constant-quality target (lower is better quality, ~19–28 is the useful band);-b:v 0removes the bitrate floor so a simple talking-head lesson — most of our content — spends almost no bits on flat backgrounds instead of padding to a fixed rate. Thecqclimbs down the ladder (23/25/27) because lower resolutions can tolerate slightly more compression before it shows.-bf 3enables 3 B-frames (bidirectionally-predicted frames that reference both past and future, the cheapest frames to store). Turing and later NVENC handle B-frames in hardware, so this is free compression — noticeably smaller files at the same quality, which is the whole game when your users are on metered prepaid data.-spatial_aq 1turns on spatial adaptive quantization: NVENC spends more bits on visually detailed regions (faces, text on a slide) and fewer on flat gradients. On instructional video with a lot of face-and-slide, it’s a visible win at no bitrate cost.-preset p5is the modern NVENC preset scale (p1fastest/worst →p7slowest/best).p5is the sweet spot for us;p4when I wanted to push throughput on a big upload day,p6for the rare high-motion source. These replaced the old named presets (slow,medium,fast) years ago — if you’re still typing-preset slowath264_nvenc, you’re on a deprecated alias.
A 20-minute, 1.2 GB HD source that took CPU libx264 the better part of a real-time hour per rung came off the RTX 4050 as a full three-rung ladder in a few minutes, with the encoder block barely warming up.
The one gotcha that makes people think the card is broken
Consumer GeForce cards cap simultaneous NVENC sessions in the driver, not the silicon — historically a small number at a time (the RTX 40-series generation lifted it, but the ceiling is real and low). It’s a licensing segmentation knob: the encoder ASIC could do more, but the driver refuses. The failure is ugly — the n+1th ffmpeg exits immediately with OpenEncodeSessionEx failed: out of memory, which sends you hunting for a VRAM leak that isn’t there.
Our architecture sidesteps it by construction: one video’s ladder at a time, and all its rungs in a single ffmpeg process (the -filter_complex split above). One process, one NVENC session, three encoders inside it — we’re never anywhere near the cap. If you instead fan out one ffmpeg per rung per video, you burn a session each and hit the wall fast. The clean workarounds, in order of preference: pack all rungs into one process (what we do); serialize jobs so only one encode is live at a time; or, if you genuinely need more parallel sessions, the community nvidia-patch lifts the driver limit — but that’s out of ToS and I wouldn’t run it on infrastructure a non-profit depends on. Structure the work so you never need it.
Pushing the ladder to Wasabi
Once the rungs exist on disk, they go to Wasabi — the S3-compatible object store I’d moved to for its near-zero egress over the Cloudflare Bandwidth Alliance. “S3-compatible” is the load-bearing phrase: boto3 speaks to Wasabi fine, but you must point it at Wasabi’s endpoint_url and use path-style addressing, otherwise the SDK builds AWS’s virtual-host bucket.s3.amazonaws.com URLs and the writes go nowhere. The upload writes the whole ladder under a temp prefix and only the backend, on done, promotes it to the final path — so a half-finished encode is never reachable:
# desktop/upload.py — boto3 against Wasabi, not AWS
import os, mimetypes, boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url="https://s3.ap-southeast-1.wasabisys.com", # NOT AWS
aws_access_key_id=os.environ["WASABI_KEY"],
aws_secret_access_key=os.environ["WASABI_SECRET"],
config=Config(s3={"addressing_style": "path"}), # bucket in the path
)
def upload_ladder(asset_id: str, ladder_dir: str) -> str:
prefix = f"_incoming/{asset_id}" # temp; promoted by backend on done
for root, _, files in os.walk(ladder_dir):
for name in files:
local = os.path.join(root, name)
key = f"{prefix}/{os.path.relpath(local, ladder_dir)}"
ctype = ("application/vnd.apple.mpegurl" if name.endswith(".m3u8")
else mimetypes.guess_type(name)[0] or "video/mp2t")
s3.upload_file(local, os.environ["WASABI_BUCKET"], key,
ExtraArgs={"ContentType": ctype})
return prefix
The explicit ContentType on the playlists matters: serve an .m3u8 as application/octet-stream and some Android players refuse to parse it.
Now the two halves join. run_ladder — the call the worker loop treated as a black box — is where the ffmpeg invocation and the Wasabi upload actually run, and the seam between them is the interesting part. ffmpeg is an external process, so I don’t subprocess.run() it and block the event loop for the several minutes an encode takes; I launch it with asyncio.create_subprocess_exec and await its completion, which yields the loop back so /healthz keeps answering while the GPU works. The upload is the opposite problem: boto3 is a synchronous library, and calling its blocking upload_file directly from an async def would stall the same event loop. asyncio.to_thread hands that blocking work to a thread so the loop stays responsive:
# desktop/service.py — the black box the worker loop awaited
import asyncio, tempfile
from .upload import upload_ladder # the sync boto3 helper above
async def run_ladder(api: httpx.AsyncClient, job: dict) -> None:
with tempfile.TemporaryDirectory() as work:
args = build_ffmpeg_args(job["source_url"], work) # the -filter_complex ladder
# Launch ffmpeg as a real subprocess; await it without blocking the loop.
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate() # drains stderr, waits for exit
if proc.returncode != 0:
# Surface ffmpeg's own diagnostics; the loop's except turns this into a release.
raise RuntimeError(
f"ffmpeg exited {proc.returncode}: "
f"{stderr.decode(errors='replace')[-2000:]}"
)
# boto3 is blocking; run it off the event loop so /healthz stays live.
await asyncio.to_thread(upload_ladder, job["asset_id"], work)
Three things earn their place here. await proc.communicate() (rather than await proc.wait()) is deliberate — NVENC’s OpenEncodeSessionEx error and every other useful failure goes to stderr, and if I don’t drain that pipe a chatty ffmpeg can fill the OS buffer and deadlock waiting for me to read it. Checking proc.returncode explicitly is what converts a failed encode into the exception the supervisor loop catches, so the lease gets released and the row heals back to pending instead of the job silently vanishing. And asyncio.to_thread(upload_ladder, ...) is the one line that lets a synchronous boto3 client live inside an async service without either rewriting it against aioboto3 or freezing the loop for the duration of a multi-file upload.
That’s the whole box: pull a job, decode-scale-encode the ladder on the GPU in one subprocess, push the segments to a temp prefix on Wasabi in a worker thread, and let the backend promote them on done. No signed URLs anywhere in that path — delivery is public HLS fronted by Cloudflare. For the private-download parts of the platform I did sign URLs, and the real shape there is botocore’s CloudFrontSigner fed an rsa_signer built on the cryptography library:
# how CloudFront signed URLs actually look (from the S3+CloudFront era)
from botocore.signers import CloudFrontSigner
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def rsa_signer(message: bytes) -> bytes:
key = serialization.load_pem_private_key(
os.environ["CF_PRIVATE_KEY"].encode(), password=None)
return key.sign(message, padding.PKCS1v15(), hashes.SHA1())
signer = CloudFrontSigner(os.environ["CF_KEY_PAIR_ID"], rsa_signer)
signed = signer.generate_presigned_url(url, date_less_than=expiry)
Living with a single point of failure — deliberately
There is no way to dress this up: one box, one GPU, one office, one power strip, one ISP. Any of them going down takes transcoding offline. I want to be clear that I chose this, eyes open, and the reasoning is the transferable part.
A single point of failure is acceptable when its blast radius is bounded and its recovery is automatic. Both were true here, by design.
- Bounded blast radius. The desktop was never on the serving path. Already-transcoded video lived in Wasabi and was fronted by Cloudflare; playback, auth, and payments had zero dependency on the box. If it died, users watching existing courses felt nothing. The only thing that degraded was the latency between an upload and that new video becoming watchable.
- Automatic recovery. The pull model made offline equal to paused, not lost. A
systemdunit restarted the FastAPI service on crash and on boot. Leased-but-unfinished jobs expired back topending, so a mid-encode power cut just meant the next lease redid it from scratch. Renditions were written to a temp prefix in Wasabi and only promoted once complete, so a half-finished encode could never be served. I put the box on a small UPS to ride out the short power blips that are a fact of life here, and the service heartbeated the backend every minute — if it went quiet, I got pinged.
The systemd unit is what turns a desktop in a corner into something that behaves like infrastructure — it comes back on power, comes back on crash, and needs no human to log in and start it:
# /etc/systemd/system/transcoder.service
[Unit]
Description=Happy Thoughts NVENC transcode service
After=network-online.target
Wants=network-online.target
[Service]
User=transcoder
WorkingDirectory=/opt/transcoder
EnvironmentFile=/etc/transcoder/env # WASABI_*, API creds, WORKER_ID
ExecStart=/opt/transcoder/.venv/bin/uvicorn service:app --host 127.0.0.1 --port 8900
Restart=always # crash, OOM-kill, ffmpeg segfault -> restart
RestartSec=5
# scratch space for the ladder before it ships to Wasabi
RuntimeDirectory=transcoder
[Install]
WantedBy=multi-user.target
Restart=always plus WantedBy=multi-user.target is the whole trick: a power cut and reboot brings the service up before anyone notices, and a segfaulting ffmpeg takes down one job, not the box. The EnvironmentFile keeps every secret out of the unit and out of the repo — the file is root-owned 0600 on the machine. That local scratch space matters for another reason too: the box kept a ~600 GB local cache of recently-transcoded output on its own disk, so a re-publish or a ladder tweak didn’t mean re-pulling the source from Wasabi and re-encoding from zero — it re-shipped from the local copy. The desktop’s cheap, roomy consumer SSD was doing work a cloud function’s ephemeral disk never could.
The lease-and-promote flow is worth seeing as a sequence, because it’s what makes a home PC safe to depend on:
sequenceDiagram
participant B as Django backend
participant D as Desktop service
participant W as Wasabi
D->>B: claim next pending job
B-->>D: lease job with expiry, or none
D->>W: upload renditions to a temp prefix
D->>B: mark job done
B->>W: promote temp prefix to final and flip asset ready
Note over B,D: if the desktop dies mid-encode the lease expires and the row returns to pendingThat’s the judgment I’d want a team to internalise: don’t ask “is this a single point of failure?” Ask “what exactly breaks when it fails, for whom, and does it heal itself?” A SPOF whose worst day is videos publish a few hours late, then catch up on their own is a completely different risk from a SPOF that drops the checkout page. Treat them differently.
The arithmetic: one RTX 4050 vs the cloud
The whole decision rests on a per-lesson cost comparison, so here is the one I actually ran. A typical lesson is a 20-minute source turned into a three-rung HLS ladder.
The full loop the desktop runs, per job:
flowchart LR
P[Poll claim endpoint] --> L[Lease one pending job]
L --> S[Pull source from Wasabi]
S --> E[NVENC ladder<br/>single ffmpeg process]
E --> T[Upload rungs to temp prefix]
T --> R[Report done to backend]
R --> P- AWS Elemental MediaConvert billed per output-minute. Three rungs of a 20-minute lesson is 60 output-minutes; at the professional-tier per-minute rate that landed around 40–60 US cents per lesson, before the S3 and CloudFront lines. Correct, elastic, zero ops — and at 150 lessons on a busy day, tens of dollars a day of pure encode, every day, forever.
- vast.ai rented NVENC-capable GPUs for cents an hour, and the encode genuinely cost cents per lesson — that’s what proved the economics. But the instances vanished mid-encode, so the real per-successful-lesson cost included retries, babysitting, and my time. Cheap silicon, unreliable delivery.
- The RTX 4050 desktop cost 600 dollars once. At ~130 W under load and Indian electricity, running it around the clock is ten to fifteen dollars a month — and it idles most of the month. Amortised across the tens of thousands of lessons it encoded over its life, the marginal cost per lesson rounds to the electricity to run the fan. Against MediaConvert’s ~50 cents a lesson, the box paid for itself in weeks, not months.
Throughput was never the constraint either. One card chewed a three-rung 20-minute ladder in a few minutes wall-clock; a 150-lesson upload flood drained overnight while the office slept, in strict upload order, with the box the only thing awake. The cloud’s elasticity — its headline feature — bought us nothing, because our burst fit comfortably inside one card working through a queue. You pay for elasticity you don’t use.
That’s the case in one line: for a bursty, bounded, non-latency-critical workload, a fixed one-time cost beats a per-unit cloud bill that never stops, and a $600 box beats a $600/month one.
What it bought us
It ran, quietly, in the corner of an office for a long stretch — well over a year. It saved a genuinely meaningful amount of money for an organisation that felt every dollar, turning a variable cloud bill I couldn’t confidently forecast into a fixed 600-dollar receipt and a rounding-error electricity line. Over its life it churned through the daily upload floods and cached roughly 600 GB of transcoded content into Wasabi, all of it delivered to users through Cloudflare with the box itself never touching a viewer.
The lesson I keep coming back to, the one I’d lead with if you’re deciding where to spend a non-profit’s money: the resourceful answer often beats the proper one. There’s enormous cultural pressure — especially from engineers who came up in well-funded shops — to reach for the managed, elastic, architecturally tasteful solution. But a 600-dollar box that works, whose failure mode you’ve thought through and tamed, beats a cloud bill you can’t pay. Proper is worthless if it bankrupts the mission. Resourceful, done with discipline, keeps the lights on.
Of course, “keeps the lights on” has a shelf life. This scrappy setup was sized for the platform we were, and the platform kept growing — more users, more courses, more renditions. Storage crept toward numbers that a shelf in a room and a fair-use plan were never meant to hold. The desktop never broke. The arithmetic did. That reckoning — 3.5 TB, and the cheapest line on the bill turning into the riskiest — is the next chapter.
