Four channels off one $15 box

Four YouTube channels, nine publishes a week, four distinct editorial voices — all from one codebase on a single $15-a-month server. The thing that made it possible was not the pipeline. It was refusing to ever write if channel ==.

The fork you do not notice you are making

The first channel is easy. You write a script that finds a topic, researches it, writes a script in a particular voice, renders a video, and uploads it. It works. You are pleased.

The second channel is where the damage happens, because the second channel is almost the same. Different subject area, slightly longer videos, calmer narration. So you do the obvious thing:

if channel == "property_pulse":
    length = 9
    voice = "calm_expert"
else:
    length = 6
    voice = "energetic"

That is one branch and it is harmless. The problem is that it is never one branch. By the third channel you have that conditional in the script writer, the TTS wrapper, the thumbnail generator, the topic filter and the upload metadata builder. Every one of them has to be edited to add a channel, and every one is a place to forget. You have not built a multi-channel system; you have built four products that share a directory and drift apart a little more each week.

Config overlays

The fix is that a channel should be data, not a code path. One set of defaults, one small file per channel that overrides only what differs, deep-merged at load time.

config/defaults.yaml holds everything shared:

video:
  resolution: [1920, 1080]
  fps: 30
  target_length_min: 6
research:
  provider: tavily
  max_sources: 6
  max_age_days: 30
voice:
  engine: edge-tts
  style: energetic
dedup:
  topic_window: 40
  similarity_threshold: 0.82
upload:
  privacy: private
  category: 27

config/channels/property_pulse.yaml holds only the difference:

name: property_pulse
video:
  target_length_min: 9
voice:
  style: calm_expert
  rate: "-6%"
feeds:
  - https://example.com/housing.rss
  - https://example.com/mortgage-rates.rss
upload:
  publish_at: "14:30"

Loading is a recursive merge, and it is genuinely this short:

def deep_merge(base, over):
    out = dict(base)
    for k, v in over.items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            out[k] = deep_merge(out[k], v)
        else:
            out[k] = v
    return out

def load_profile(name):
    base = yaml.safe_load(open("config/defaults.yaml"))
    over = yaml.safe_load(open(f"config/channels/{name}.yaml"))
    return deep_merge(base, over)

Every stage of the pipeline now takes a profile and reads what it needs from it. The script writer asks for profile["video"]["target_length_min"]. It has no idea which channel it is serving, and it must not.

Adding a fifth channel is one new YAML file and one crontab line. No Python is touched. That is the whole test of whether the abstraction is real: if adding a channel requires editing code, the config system is decorative.

Sequential, not parallel

The obvious next move is to run four channels concurrently. I do not, and the reason is the box.

A moviepy render of a nine-minute 1080p video is CPU-bound and writes about 400 MB of intermediates. Four of those at once on a small VPS means swapping, thermal throttling, and a disk that fills mid-render. Running them one after another means the peak footprint is one render, and the whole sequence still finishes in well under an hour.

The runner walks the channels in order with per-channel failure isolation:

for name in channels_due_today():
    profile = load_profile(name)
    try:
        run_sequence(profile)
    except Exception as e:
        log.error("channel %s failed: %s", name, e)
        db.mark(name, status="failed", error=str(e))
        notify_failure(name, e)
        continue          # one bad channel does not stop the rest

One channel failing must not take down the other three. But — and this is the mistake that cost me a month-long outage — isolating a failure is not the same as pretending it did not happen. The continue is right. Swallowing the error and recording success is not.

Staggering, and why it is not about CPU

The crontab spreads nine publishes across the week so no two land in the same hour. Partly that is load. Mostly it is blast radius.

If all four channels publish at 14:00 Tuesday and the YouTube API is having an afternoon, I lose four videos to one incident. Staggered, the same incident costs me one, and the failure alert arrives while the other three are still hours away — enough time to fix the credential or wait out the rate limit before the next one fires.

The same logic drives the schedule shown on the front page of this site: it is read straight from the real crontab, with the channel names withheld.

What one box actually holds

The full inventory on that server: four channels' worth of pipeline, a live product with its own database and nightly backups, nginx, SSL, systemd units, the cron schedule, and this website. It costs about $15 a month.

People assume a system like this needs orchestration — containers, a queue, a scheduler service. It needs cron, a disk guard, and the discipline to keep per-channel behaviour out of the code. The interesting engineering was not in scaling up. It was in making four different things be the same thing wearing different configuration.