~/douglas-montanus
← All posts

Automating Blog Posts with a Local Gemma Model

October 25, 2026 · 10 min read

AIOpen SourceGemmaAutomationNext.js

This site is deliberately simple. Posts are MDX files in content/blog, the frontmatter is parsed with gray-matter, Next.js builds a standalone server, and a shell script ships the container to Cloud Run. There is no CMS and no database.

That simplicity is what makes it easy to automate. A blog post here is just a text file with a predictable shape, and producing predictable text files is something a local open-weight model does well. So I put Gemma in the loop: I hand it rough notes, it gives me back a structured draft, a script turns that into a valid MDX file, and the same script can build and deploy the site.

This post walks through that setup, why I run the model locally instead of calling a hosted API, and the guardrails that keep it from publishing nonsense.

Why a local model for this

I use hosted frontier models every day, and for hard reasoning they are still the right tool. Drafting a blog post from my own notes is not that kind of problem. It is closer to structured rewriting: take bullet points, keep my voice, add headings, produce clean frontmatter. Open models in the Gemma family handle that comfortably, and running one locally has some real advantages for this specific job:

  • No cost per run. I can regenerate a draft ten times while I tune a prompt and it costs nothing but a little electricity.
  • My notes stay on my machine. Rough notes often include client names, internal details, or half-formed opinions I would never publish. They never leave the laptop.
  • It works offline. I can draft on a plane and deploy when I land.
  • It is stable. The model version only changes when I pull a new one, so a prompt that works today keeps working.

The trade-off is quality ceiling and speed. A mid-sized Gemma model on a laptop GPU is slower than a hosted API and will not write as well as the best frontier models. For a first draft that I am going to edit anyway, that is a trade I am happy to make.

The pipeline

notes/my-idea.md          rough bullet points, links, code snippets
       │
       ▼
scripts/draft-post.mjs
  ├─ Ollama (localhost:11434) ── Gemma ── returns JSON: title, summary, tags, body
  ├─ validate: slug, frontmatter, MDX-unsafe characters
  └─ write content/blog/<slug>.mdx
       │
       ▼
  human review + edits          (this step is not optional)
       │
       ▼
  npm run build                 catches broken MDX before it ships
       │
       ▼
  ./deploy.sh                   Cloud Build → Cloud Run

Every arrow is a plain command. No orchestration framework, no agents, no queue. The model does one job, in the middle, and everything around it is ordinary code that I can read and test.

Running Gemma locally

I use Ollama because it gives me a model runtime with a simple HTTP API in one install:

# install Ollama, then pull a Gemma model
ollama pull gemma3:12b

# quick sanity check
ollama run gemma3:12b "Write one sentence about Cloud Run."

Model size is the main knob. The 12B variant is the sweet spot on my machine: good enough prose, fast enough to iterate. The smaller variants are noticeably weaker at keeping a consistent voice across a long post, and the 27B variant is better but slower than I want for a drafting loop. If you are on a machine with less memory, start small and move up. Newer Gemma releases drop in by changing the model tag.

Ollama exposes the model at http://localhost:11434, so anything that can make an HTTP request can use it. No SDK needed.

Asking for structure, not prose

The single most useful decision was to never ask the model for a finished MDX file. Asking for a raw file means parsing whatever comes back: sometimes there is a stray "Here's your post!" line, sometimes the frontmatter is wrapped in a code fence, sometimes the YAML quoting is wrong.

Instead I ask for JSON that matches a schema, and Ollama enforces the schema through its format parameter. The model fills in fields; my code assembles the file. That moves all of the formatting rules out of the prompt and into code, where they are reliable.

// scripts/draft-post.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';

const MODEL = process.env.GEMMA_MODEL ?? 'gemma3:12b';
const OLLAMA_URL = 'http://localhost:11434/api/chat';

const schema = {
  type: 'object',
  properties: {
    title: { type: 'string' },
    summary: { type: 'string' },
    tags: { type: 'array', items: { type: 'string' }, maxItems: 5 },
    body: { type: 'string' },
  },
  required: ['title', 'summary', 'tags', 'body'],
};

const notesPath = process.argv[2];
if (!notesPath) {
  console.error('Usage: node scripts/draft-post.mjs notes/my-idea.md');
  process.exit(1);
}

const notes = await fs.readFile(notesPath, 'utf8');
const styleGuide = await fs.readFile('scripts/style-guide.md', 'utf8');

const res = await fetch(OLLAMA_URL, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: MODEL,
    stream: false,
    format: schema,
    options: { temperature: 0.4 },
    messages: [
      { role: 'system', content: styleGuide },
      { role: 'user', content: `Turn these notes into a blog post:\n\n${notes}` },
    ],
  }),
});

if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
const { message } = await res.json();
const post = JSON.parse(message.content);

A low temperature keeps the output close to my notes. I want the model to organize and smooth what I wrote, not invent new points.

The style guide is the real prompt

The system prompt lives in its own file, scripts/style-guide.md, so I can edit it without touching code. It is short and specific:

You write technical blog posts for Doug Montanus, a software architect.

Voice:
- First person, practical, direct. Explain decisions and trade-offs.
- Short paragraphs. No marketing language, no "in today's fast-paced world".
- Only include facts, numbers, and opinions that appear in the notes.
  If something is missing, leave it out rather than guessing.

Structure:
- Open with the problem in 2-3 short paragraphs. No heading before it.
- Use ## headings for sections. End with a short "Takeaways" section.
- Put code in fenced blocks with a language tag.

Output:
- `body` is Markdown only. No frontmatter, no title heading.
- `summary` is one or two sentences, under 220 characters.
- `tags` are 2-5 short Title Case tags.

The line about only using facts from the notes matters most. Smaller models are confident fillers of gaps. Telling them explicitly that leaving something out is the right answer cuts down on invented benchmarks and made-up version numbers. It does not eliminate them, which is why review is a required step.

Including one or two of my existing posts as examples in the system prompt helps the voice a lot, at the cost of a longer context and a slower run. I keep one short post in there.

Validating before writing anything

The model's output goes through ordinary checks before it becomes a file. These are the ones that have actually caught problems:

const slug = post.title
  .toLowerCase()
  .replace(/[^a-z0-9]+/g, '-')
  .replace(/^-|-$/g, '')
  .slice(0, 60);

const outPath = path.join('content/blog', `${slug}.mdx`);

// Never overwrite an existing post
try {
  await fs.access(outPath);
  throw new Error(`${outPath} already exists`);
} catch (err) {
  if (err.code !== 'ENOENT') throw err;
}

// MDX treats < and { as JSX outside of code. Flag them for review.
const outsideCode = post.body.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '');
const risky = outsideCode.match(/[<{][^\n]{0,20}/g);
if (risky) {
  console.warn('Possible MDX-breaking characters outside code:', risky);
}

if (post.summary.length > 220) {
  console.warn(`Summary is ${post.summary.length} chars, consider trimming`);
}

const today = new Date().toISOString().slice(0, 10);
const file = matter.stringify(post.body.trim() + '\n', {
  title: post.title,
  date: today,
  summary: post.summary,
  tags: post.tags,
});

await fs.writeFile(outPath, file);
console.log(`Draft written to ${outPath}`);

Using gray-matter to write the frontmatter, the same library the site uses to read it, means quoting and escaping are always consistent with what the site expects. I never let the model write YAML directly.

The MDX check is the one I would not skip. In MDX, a bare < or { in prose is parsed as JSX. A sentence like latency dropped to <200ms will break the build. (I found this out writing this very post: the first draft of this paragraph left that example outside backticks and failed to build.) The model has no idea it is writing MDX rather than Markdown, so I catch it in code.

Build as the gate, then deploy

Once I have edited the draft, the rest is the same as any other post. The build is the final check: if the MDX does not compile, next build fails and nothing ships.

I wrapped the steps in a small script so the whole flow is one command after review:

#!/usr/bin/env bash
# scripts/publish.sh
set -euo pipefail

npm run lint
npm run build          # fails on invalid MDX or frontmatter

git add content/blog
git commit -m "Add post: ${1:?usage: publish.sh \"Post title\"}"
git push

./deploy.sh            # Cloud Build → Cloud Run

deploy.sh submits the repo to Cloud Build, which builds the multi-stage Docker image with Next.js standalone output, then rolls it out to Cloud Run. Building in Cloud Build means I do not need Docker running locally, and it means the image that ships was built from exactly what I just committed.

What I deliberately did not automate

It would be easy to wire this into a fully hands-off loop: watch a notes folder, generate, build, deploy. I do not, for a few reasons.

  • The model is a drafting tool, not an author. Every post on this site is something I stand behind. Gemma gets me from notes to a structured first draft in a minute instead of an hour, but the editing pass is where the post becomes mine.
  • Small models still hallucinate details. Version numbers, API names, and benchmark figures are the most common problems. A human read catches them; a build does not.
  • Publishing is outward-facing. A bad deploy to a personal blog is low stakes, but it is still public. One manual step before it goes live is cheap insurance.

The automation sits where the tedious work is: structuring, formatting, frontmatter, building, and deploying. The judgment stays with me.

Takeaways

  • Local open models are a good fit for structured rewriting. Turning your own notes into a formatted draft does not need a frontier model, and running it locally is free, private, and works offline.
  • Ask for JSON, assemble the file in code. Schema-constrained output plus your own templating is far more reliable than asking a model to emit a finished file.
  • Put formatting rules in code, voice rules in the prompt. Slugs, frontmatter, escaping, and MDX hazards belong in validation. Tone and structure belong in a style guide.
  • Let the build be the gate. If the site compiles, the post is at least structurally valid. Everything else is a human review.
  • Keep the model in one box. One well-defined job in the middle of an ordinary script is easier to trust, debug, and swap out than an agent that owns the whole pipeline.