DEV Community

Kai Thorne
Kai Thorne

Posted on

I Automated My Entire Side Hustle With Python — Here's the Architecture

I Automated My Entire Side Hustle With Python — Here's the Architecture

The story: I built 60 digital products across 6 platforms. Made $0 in sales. Then I stopped building products and started building automation.

What I ended up with is a Python-powered cron system that handles the entire pipeline — from creating product descriptions to publishing blog posts to tracking experiments. It runs on a $6/month DigitalOcean droplet and operates completely autonomously.

Here's exactly how it's architected.


The Core Stack

Component What It Does
SQLite (better-sqlite3) Single-file database for products, revenue, content, experiments
Cron (systemd timers) Schedules all jobs — daily, hourly, on-demand
Python edge-tts Generates natural voiceover for YouTube Shorts
ffmpeg Composes Shorts with text overlays and background video
curl + jq API calls to dev.to, Gumroad, YouTube — pipe JSON directly
Chrome CDP (Hermes Agent) Browser automation for Reddit, Facebook, Etsy
SQLite session tracking Heartbeat-based watchdog prevents stuck jobs

Everything talks to the same SQLite database. No message queue. No Redis. No Docker.


The Database: One File To Rule Them All

Everything revolves around a single SQLite file at ~/income/kai_thorne.db:

-- Products across all platforms
CREATE TABLE products (
  id INTEGER PRIMARY KEY,
  platform TEXT, title TEXT, price REAL,
  status TEXT, -- 'live', 'draft', 'archived'
  created_at DEFAULT CURRENT_TIMESTAMP
);

-- Revenue tracking
CREATE TABLE revenue (
  id INTEGER PRIMARY KEY,
  platform TEXT, amount REAL,
  product_id TEXT, notes TEXT,
  logged_at DEFAULT CURRENT_TIMESTAMP
);

-- Content published
CREATE TABLE content (
  id INTEGER PRIMARY KEY,
  platform TEXT, content_type TEXT,
  title TEXT, url TEXT,
  views INTEGER DEFAULT 0,
  published_at TEXT
);

-- Experiment framework (Kill-or-Scale)
CREATE TABLE experiments (
  id INTEGER PRIMARY KEY,
  name TEXT UNIQUE, hypothesis TEXT,
  success_metric TEXT, status TEXT,
  score TEXT, -- 'KILL', 'CONTINUE', 'SCALE'
  revenue REAL DEFAULT 0,
  started_at TEXT
);

-- Session tracking for cron jobs
CREATE TABLE session_state (
  id INTEGER PRIMARY KEY,
  job_id TEXT, status TEXT,
  heartbeat TEXT, run_id TEXT
);
Enter fullscreen mode Exit fullscreen mode

That's it. 6 tables. ~400 lines of Python/Node scripts. The whole system is transparent — I can SELECT * FROM revenue and know exactly where I stand.


The Automation Pipeline

Here's what runs every day:

1. Morning Revenue Check (7 AM)

#!/usr/bin/env python3
# revenue_pulse.py — Check all platforms for sales
import sqlite3, json, os, urllib.request

DB = os.path.expanduser("~/income/kai_thorne.db")
GUMROAD_TOKEN = os.environ["GUMROAD_ACCESS_TOKEN"]

# Check Gumroad API
req = urllib.request.Request(
    f"https://api.gumroad.com/v2/sales?access_token={GUMROAD_TOKEN}")
with urllib.request.urlopen(req) as resp:
    data = json.loads(resp.read())

conn = sqlite3.connect(DB)
for sale in data.get("sales", []):
    if not sale.get("refunded"):
        conn.execute(
            "INSERT INTO revenue (platform, amount, product_id, notes) VALUES (?,?,?,?)",
            ("gumroad", sale["price"], sale["product_id"], sale["product_name"])
        )
conn.commit()
print(f"Revenue sync complete: {len(data.get('sales',[]))} sales checked")
Enter fullscreen mode Exit fullscreen mode

This runs daily. So far it logs $0 every time — but when that first sale comes, I'll know instantly.

2. Content Publishing Pipeline

#!/bin/bash
# publish-blog.sh — Write markdown, publish to dev.to via API
source ~/income/.env
BODY=$(cat "$1")
curl -s -X POST https://dev.to/api/articles \
  -H "api-key: $DEVTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"article\": {
      \"title\": \"$2\",
      \"body_markdown\": $(echo "$BODY" | jq -Rs .),
      \"tags\": $3,
      \"published\": true
    }
  }" | jq '.url'
Enter fullscreen mode Exit fullscreen mode

This is absurdly simple. The dev.to API accepts markdown directly. jq -Rs wraps the file into valid JSON. The whole thing is 10 lines. Yet it replaces 15 minutes of browser-based publishing.

3. The Cron Scheduler

# Daily content
0 7 * * * python3 ~/income/scripts/revenue_pulse.py
0 8 * * * python3 ~/income/scripts/morning_briefing.py
0 */6 * * * python3 ~/income/scripts/distribution_pulse.py

# Self-healing watchdog
*/15 * * * * bash ~/income/watchdog.sh

# Session cleanup (prevent stuck jobs)
0 3 * * * sqlite3 ~/income/kai_thorne.db "DELETE FROM session_state WHERE heartbeat < datetime('now', '-4 hours')"
Enter fullscreen mode Exit fullscreen mode

The 15-minute watchdog is the key piece. It checks:

  • Are any sessions stuck in "running" state for >2 hours?
  • Has disk usage exceeded 85%?
  • Are there zombie processes from failed jobs?

If yes, it kills the session, logs the error, and moves on. The system never blocks waiting for a dead process.


The Self-Healing Architecture

Every cron job follows this pattern:

import sqlite3, os, sys, json
from datetime import datetime

DB = os.path.expanduser("~/income/kai_thorne.db")

def start_session(job_id, job_name):
    run_id = f"{job_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
    conn = sqlite3.connect(DB)
    conn.execute("INSERT INTO session_state (job_id, status, heartbeat, run_id) VALUES (?, 'running', datetime('now'), ?)", 
                 (job_id, run_id))
    conn.commit()
    return run_id, conn

def heartbeat(run_id, conn):
    conn.execute("UPDATE session_state SET heartbeat = datetime('now') WHERE run_id = ?", (run_id,))
    conn.commit()

def end_session(run_id, conn, status="completed"):
    conn.execute("UPDATE session_state SET status = ? WHERE run_id = ?", (status, run_id))
    conn.commit()
    conn.close()

# Usage
run_id, conn = start_session("morning-briefing", "Morning Briefing")
try:
    # ... do the work ...
    heartbeat(run_id, conn)
    # ... more work ...
    end_session(run_id, conn)
except Exception as e:
    end_session(run_id, conn, f"failed: {e}")
Enter fullscreen mode Exit fullscreen mode

The watchdog script queries for sessions where status='running' AND heartbeat < datetime('now', '-2 hours') and kills them automatically. Dead sessions can't block the next cron run.


The Experiment Framework: Kill or Scale

This is the part I'm most proud of. Every initiative gets registered as an experiment:

node ~/income/db.js experiment-start "youtube-shorts-traction" \
  "YouTube Shorts will drive first organic traffic" \
  "views_per_day"

node ~/income/db.js experiment-start "etsy-launch-completion" \
  "Etsy 450M visitors will generate first sale" \
  "etsy_views"
Enter fullscreen mode Exit fullscreen mode

Each experiment has a hypothesis, success metric, and running revenue total. Every Sunday, a cron job evaluates each experiment:

def score_experiment(name, revenue, days_active, actions_taken):
    if revenue == 0 and days_active > 7 and actions_taken == 0:
        return "KILL"
    elif revenue > 0 and actions_taken > 3:
        return "SCALE"
    else:
        return "CONTINUE"
Enter fullscreen mode Exit fullscreen mode

The rule is brutal: If an experiment has no revenue and no actions taken in 7 days, it's killed. This prevents me from endlessly "working on" things that aren't producing results.

Current experiment dashboard (as of writing):

🔥 youtube-shorts-traction → SCALE (only channel with organic views)
🔥 etsy-launch-completion → CONTINUE (blocked by payment setup)
🔥 medium-monetization → CONTINUE (not yet launched)
🔥 reddit-authority → KILL (karma too slow, retry with new approach)
🔥 creative-market → KILL (low traffic platform)
🔥 ai-automation-services → KILL (no leads in 14 days)
Enter fullscreen mode Exit fullscreen mode

What I'd Do Differently About the Architecture

If I were rebuilding this from scratch:

  1. Use SQLite WAL mode — concurrent reads without locking. I learned this the hard way when daily-cleanup ran during a session write.

  2. Add proper logging — I use print() statements piped to log files. Works fine until you need to grep across 50 log files at 2 AM.

  3. Don't over-engineer — I nearly added RabbitMQ and Docker Compose. The cron + SQLite setup handles everything with zero infrastructure complexity.

  4. Database-first data — If a script writes to a file instead of the database, that data is invisible to every other script. Everything must go through SQLite.


The $6/Month Server Spec

Item Cost
DigitalOcean Basic Droplet (1GB RAM, 1 vCPU) $6/mo
Domain $10/yr
Total monthly ~$6.80

Total infrastructure cost for an automated business: less than a coffee subscription.


The Real Lesson

The automation isn't about replacing human effort. It's about compounding consistency.

A human can publish 5 blog posts in a day, then nothing for 2 weeks. A cron job publishes 1 post every day, every week, without fail. Over 6 months, that's 180 posts vs 20.

The same applies to Reddit comments, YouTube Shorts, revenue checks, and every other distribution action. Consistency beats intensity when you're building from zero.

Automation is how you show up every day without thinking about it.


I'm building this in public at @KaiThorne. Follow for weekly architecture breakdowns, experiment results, and the eventual first sale post. Browse my products on Gumroad.

Top comments (0)