< Back to Blog

How I Auto-Generate This Blog With a $6 VPS, a Cron Job, and the Anthropic API

TL;DR / Key Takeaways

  • A single Python script runs on a $6 RackNerd VPS, calls the Anthropic API, and produces a publish-ready markdown blog post with zero human intervention.
  • The prompt stack loads four context files at runtime: a system role file, brand voice, product internals, and the subject assignment. Voice consistency comes from the context, not from re-prompting every time.
  • A GitHub Actions workflow watches the repo and triggers a Next.js rebuild whenever a new post is pushed, so the site goes live automatically.
  • The Predict & Profit bot source code is built the same way I build everything: one real problem at a time, no framework magic, just Python doing what Python does.

This post was written by the system it describes. I thought that was worth stating upfront before we get into the code.

The blog you are reading runs on a content pipeline I built in a weekend. A cron job fires on my VPS, a Python script assembles a prompt from context files, calls the Anthropic API, gets back a complete markdown post, commits it to a GitHub repo, and pushes. GitHub Actions picks it up and rebuilds the Next.js site. The post goes live without me touching anything.

Here is exactly how it works.


The Hardware

RackNerd VPS. $6 a month. 1 vCPU, 1GB RAM, 20GB SSD, Ubuntu 24. That is the entire infrastructure budget for content generation. It runs the blog pipeline, a few cron jobs for the trading bots, and a systemd service or two.

If you are paying more than that for a VPS that just runs Python scripts and git pushes, you are overpaying.


The Directory Structure

~/content/predictandprofit/
├── context/
│   ├── CLAUDE.md          # System role + writing rules + frontmatter format
│   ├── brandvoice.md      # Who Steve is, tone, philosophy
│   └── product_updates.md # Current bot internals, features, accurate numbers
├── subjects/
│   └── subjects.md        # The rotation file. Every post idea lives here.
├── published/
│   └── *.md               # Archive of generated posts
└── generate_post.py       # The script

The context directory is the prompt stack. Every file gets loaded and concatenated before the API call. The model sees all of it every time.

The subjects file has a list of post ideas with status flags: UNUSED, IN_PROGRESS, PUBLISHED. The script finds the first UNUSED entry, runs it, and flips the flag to PUBLISHED when done.


The Generation Script

This is the core of it. Stripped down to the essential pieces:

import anthropic
import os
import re
from datetime import date
from pathlib import Path

CONTEXT_DIR = Path("~/content/predictandprofit/context").expanduser()
SUBJECTS_FILE = Path("~/content/predictandprofit/subjects/subjects.md").expanduser()
OUTPUT_DIR = Path("~/repos/predictandprofit-site/posts").expanduser()

def load_context() -> str:
    files = ["CLAUDE.md", "brandvoice.md", "product_updates.md"]
    chunks = []
    for f in files:
        path = CONTEXT_DIR / f
        content = path.read_text(encoding="utf-8")
        chunks.append(f"## {f}\n{content}")
    return "\n\n---\n\n".join(chunks)

def get_next_subject() -> tuple[str, int]:
    text = SUBJECTS_FILE.read_text(encoding="utf-8")
    lines = text.splitlines()
    for i, line in enumerate(lines):
        if "STATUS: UNUSED" in line:
            return line, i
    raise ValueError("No unused subjects found.")

def mark_subject_published(line_index: int):
    text = SUBJECTS_FILE.read_text(encoding="utf-8")
    lines = text.splitlines()
    lines[line_index] = lines[line_index].replace("STATUS: UNUSED", "STATUS: PUBLISHED")
    SUBJECTS_FILE.write_text("\n".join(lines), encoding="utf-8")

def generate_post(context: str, subject_block: str) -> str:
    client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
    
    system_prompt = context
    user_prompt = f"""Write a complete blog post for the following subject.
Output ONLY the markdown starting with the frontmatter. Nothing else.

{subject_block}"""

    message = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=4096,
        system=system_prompt,
        messages=[
            {"role": "user", "content": user_prompt}
        ]
    )
    return message.content[0].text

def slugify(title: str) -> str:
    title = title.lower()
    title = re.sub(r'[^a-z0-9\s-]', '', title)
    title = re.sub(r'\s+', '-', title.strip())
    return title[:80]

def extract_title(markdown: str) -> str:
    match = re.search(r'^title:\s*"(.+?)"', markdown, re.MULTILINE)
    if match:
        return match.group(1)
    return f"post-{date.today().isoformat()}"

def save_and_push(markdown: str):
    title = extract_title(markdown)
    slug = slugify(title)
    filename = f"{date.today().isoformat()}-{slug}.md"
    output_path = OUTPUT_DIR / filename
    output_path.write_text(markdown, encoding="utf-8")
    print(f"Saved: {output_path}")
    
    os.chdir(OUTPUT_DIR.parent)
    os.system("git add posts/")
    os.system(f'git commit -m "Auto-post: {slug}"')
    os.system("git push origin main")

def main():
    context = load_context()
    subject_line, line_index = get_next_subject()
    
    # Grab the full subject block (a few lines around the match)
    subjects_text = SUBJECTS_FILE.read_text(encoding="utf-8")
    # Find the block starting at the UNUSED marker
    start = subjects_text.find(subject_line)
    block = subjects_text[start:start+800]
    
    print(f"Generating post for: {subject_line}")
    markdown = generate_post(context, block)
    
    save_and_push(markdown)
    mark_subject_published(line_index)
    print("Done.")

if __name__ == "__main__":
    main()

The API key lives in the environment. The script never touches a .env file at runtime, just reads os.environ. The cron job exports the key before calling the script.


The Cron Job

# /etc/cron.d/blog-generator
# Fires at 7am ET on Tuesday and Friday
0 11 * * 2,5 steve /home/steve/content/predictandprofit/run_generate.sh >> /var/log/blog_generator.log 2>&1

The shell wrapper:

#!/bin/bash
# run_generate.sh
export ANTHROPIC_API_KEY="$(cat /home/steve/.secrets/anthropic_key)"
export PYTHONPATH="/home/steve/content/predictandprofit"
cd /home/steve/content/predictandprofit
/home/steve/.venv/bin/python generate_post.py

The key lives in a file with 600 permissions. Simple. Not fancy. Works.


The GitHub Actions Workflow

The Next.js site repo has this in .github/workflows/deploy.yml:

name: Deploy on Push

on:
  push:
    branches: [main]
    paths:
      - 'posts/**'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          NEXT_PUBLIC_SITE_URL: ${{ secrets.SITE_URL }}

      - name: Deploy to server
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          source: ".next/,public/,posts/"
          target: "/var/www/predictandprofit"

      - name: Restart Next.js service
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /var/www/predictandprofit
            pm2 restart predictandprofit

The VPS runs the Next.js app under pm2. The Actions workflow builds, deploys the build artifacts, and restarts the process. From git push to live post is about four minutes.


Prompt Engineering for Voice Consistency

The hardest part of this was not the code. It was making the model write in my voice consistently across thirty-plus posts.

The solution is context loading, not prompt cleverness. The CLAUDE.md file has explicit rules: no em dashes, no hype words, short paragraphs, active voice, include real code. The brandvoice.md file describes who I am in enough detail that the model can reason about tone decisions, not just follow a style checklist.

The product_updates.md file is critical for accuracy. Every time the bots ship a feature or fix a bug, I update that file. The generation script loads it fresh at runtime. The model never makes up technical details because the real details are in the context window.

A few rules I learned the hard way:

Tell the model what NOT to do, not just what to do. "No em dashes" works better than "use short dashes." "No hype words: game-changing, revolutionary, groundbreaking" works better than "be straightforward."

Give it a subjects file with real structure. Each subject entry has a title, an angle, a tone descriptor, and sometimes a list of things to include. The more specific the subject block, the less the model has to guess.

Use a "DO NOT REPEAT" list. I keep a running list of every topic that has already been published. The model checks against it and avoids retreading. Without this, it would rediscover the same five angles over and over.


The Subjects File

The rotation file looks like this (simplified):

### SUBJECT 001 — STATUS: PUBLISHED
**Title:** Why I Filter 90% of Trades
**Angle:** The bot sees hundreds of candidates but only fires on a small fraction...

### SUBJECT 002 — STATUS: PUBLISHED  
**Title:** The Kelly Criterion Post
**Angle:** Position sizing math for binary markets...

### SUBJECT 033 — STATUS: UNUSED
**Title:** How I Auto-Generate This Blog With a $6 VPS
**Angle:** The exact pipeline...

The script scans for STATUS: UNUSED, grabs the block, generates the post, and flips the status. I add new subjects whenever I think of something worth writing about. The queue fills up faster than the cron job drains it.


What This Costs

Anthropic API for a 4096-token blog post using Claude Opus: roughly $0.06 to $0.10 per post depending on context length. Two posts a week is under a dollar a month in API costs. The VPS is $6. Total infrastructure: about $7 a month to run a content operation that would otherwise take me two to three hours per post.

I do read every post before it goes live. The workflow is not truly zero-touch. I have a review step where I scan the output, fix anything that reads off, and then manually trigger the push if I am not confident in the automation that week. Most posts go straight through. Some need a sentence or two adjusted. Maybe one in ten I rewrite a section of.

The model writes at about 80% of where I want to be without intervention. The context files close most of that gap. The rest is the occasional human pass.


Why This Matters for Solo Builders

I run a day job at QTS Data Centers. I run two trading bots. I maintain the bot source code product. I am learning the Philippines retirement math. I do not have time to sit down and write a blog post from scratch twice a week.

But I do have time to add a subject to a text file and check a generated post over coffee.

That asymmetry is the whole point. The pipeline does the labor. I do the judgment. For a solo builder with a full-time job, that is the only model that actually scales.

The same logic applies to the bots. The bot watches Kalshi temperature markets and CPI markets while I am in meetings. I do not have to be present for every trade. I do have to be present to set the rules, check the logs, and tune the parameters when something breaks.

Automation does not replace judgment. It replaces the repetitive execution that burns your time and your attention. Know which is which.


This whole post, including the code, the cron job, the GitHub Actions workflow, and the prompt engineering notes, came out of the same pipeline. The subject was in the queue. The cron job fired. You are reading the output. Make of that what you will.