One GPU, Three Blogs, Five Social Accounts: The Self-Hosted AI Content Engine I Run in Production for $5 a Month
One GPU runs a local LLM and Flux that write, illustrate, and route content across 3 brand blogs and 5 social accounts — for about $5/month, with a human gate on every post.
Every weekday, a script on my desktop pulls a topic from a content calendar, writes a full article on a local model, generates a custom thumbnail on the same GPU, and drops the draft into a private Discord channel for review. When I click Approve, a routing layer decides which of three brand blogs the piece belongs to, deploys it through that brand's own pipeline, and pushes it across whichever of five connected social accounts are wired up for that brand. No API meter runs during generation, and nothing happens between "approve" and "live" except that click.
This is an architecture writeup, not a build log: the boundaries between services, where I deliberately kept a human in the loop, what local inference actually costs versus a metered API, and the production bugs that taught me the most about where a system like this breaks. None of the technology is exotic — the interesting decisions are all at the seams.
System Shape
Six moving pieces, plus a routing layer that decides where each piece of content goes:
- Content generation — local Ollama (
qwen3:30b, upgraded fromllama3.1:8bmid-build) writes a master article plus platform spinoffs (LinkedIn post, TikTok script, X thread), gated behind JSON-mode output and a validation pass. - Image generation — ComfyUI running Flux.1-schnell (fp8) on the same GPU produces background art; a Python compositing step renders the actual typography on top.
- Approval — a Discord webhook posts the draft as a rich embed with Approve/Reject/Preview links; a FastAPI service listens for the click and writes the decision to DynamoDB.
- Routing — maps the article's content pillar to one of three brand sites, and that brand to a scoped set of connected social accounts.
- Publishing and deploy — three brand-specific publishers, each triggering a different deploy path: git push into a CodeBuild/S3/CloudFront pipeline, a local
terraform applythat builds and uploads a static site, and a direct write to a live DynamoDB-backed API. - Social distribution — a self-hosted Postiz instance fans the approved post out to Instagram, Facebook, LinkedIn, TikTok, YouTube, and X, using per-brand credentials so one brand can never post through another brand's account.
The architecture diagram below shows how these pieces connect end to end.

Nothing here is novel in isolation — local LLM, diffusion model, webhook, key-value store, static site deploy. What makes it worth a writeup is routing content correctly across three brands with different deploy mechanics and non-overlapping credentials, gating every publish behind a human, and surviving the specific ways a small local model and a third-party media fetch actually fail.
Routing: One Pipeline, Three Brands, No Cross-Contamination
The three brands — jordanamman.dev, jordanamman.ai, and getajobcoding.com — are not three copies of the same publish function with different config. Each deploys differently and has its own set of connected social accounts. Routing content generated in one place to exactly the right destination, with no leakage between brands, is the actual architectural problem this system solves.
Routing starts at content generation. Every article gets a pillar — a category like aws_architecture, ai_engineering, career_growth, or builder_journey — assigned from the topic calendar. A routing table maps each pillar to a brand: architecture and builder-journey content to jordanamman.dev, AI-engineering content to jordanamman.ai, career content to getajobcoding. That mapping is a plain dictionary, checked before anything fires downstream, with an override so a human reviewing a draft can redirect it if the pillar guessed wrong.
Once the brand is resolved, routing answers a second question: which social accounts does this brand's content go out on? LinkedIn and X are shared across all three brands through one personal account, an intentional choice about whose voice those platforms carry. Every other platform is brand-scoped: getajobcoding has its own dedicated Instagram, Facebook, TikTok, and YouTube business accounts, separate from the personal accounts jordanamman.dev and jordanamman.ai share. A brand-scoped lookup checks for that brand's specific credential first, and returns nothing — never falling back to a different brand's account — if it isn't found. That refusal to fall back is the design decision that matters: the failure mode worth preventing is content silently landing on a different brand's public account, not a post that fails to send. Routing never hard-fails on an unconnected platform either; it's reported as skipped, so the pipeline doesn't block on the slowest integration to configure.
Publishing and Deploy: Three Different Live Paths
Resolving the brand is only half the problem — each also deploys differently.
- getajobcoding.com: writes into a sibling Angular repo's working tree, then commits and pushes to
master, triggering GitHub Actions already configured on that repo — a CodeBuild job that deploys to S3 behind CloudFront. The pipeline's job is to write the right files and push; the deploy is someone else's CI, already trusted. - jordanamman.ai: rather than stand up a second CI pipeline with its own IAM user, the publish step runs
terraform applylocally, using this process's own AWS credentials — the same ones already used for every DynamoDB and S3 call elsewhere. Terraform's config uploads the built static site to S3 as part of that apply, so oneterraform applyis a complete deploy, not just provisioning — reusing credentials already present in the environment beat provisioning a second identity. - jordanamman.dev: no git step and no build step — its publisher writes straight to the live DynamoDB table the site's own API reads from. Publishing here is a database write, full stop.
Because these paths differ so much, the publisher layer treats "did this go live" as its own explicit signal rather than assuming a write means a deploy. A getajobcoding push is skipped if the target repo already has unrelated uncommitted changes — staging on someone else's pending edit would ship code nobody reviewed — and the same guard applies to jordanamman.ai, since terraform apply builds from whatever's on disk, not from git. Every publish result carries a deployed flag and a note explaining why, so a skipped deploy is visible, not a silent gap.
Text Generation: Small Models Need a Validation Layer
The article generator sends a prompt to Ollama in JSON mode and gets back a structured draft — title, slug, excerpt, tags, pillar, full markdown body — typically in six to ninety seconds depending on length and how warm the model is. llama3.1:8b is a small model, and small models have a specific, predictable failure mode: JSON mode constrains syntax, not semantics. Ask for one value from an enum and the model sometimes returns the whole enum concatenated instead of picking one.
The fix is the same one you'd apply to any untrusted input: validate every structured field against the known schema, and prefer a curated value over a generated one whenever a trustworthy source exists. The pillar field comes from the topic calendar first; the model's output is only a fallback, checked against the set of valid pillar names rather than trusted verbatim, with a safe default if nothing valid is present. It's a small amount of code, and it's the difference between a pipeline that occasionally corrupts its own routing data and one that doesn't — the price of an 8B model instead of a frontier one behind a metered API is rougher instruction-following, paid in validation code rather than assumed away.
I've since swapped the default model to qwen3:30b, a mixture-of-experts model that runs nearly as fast on the same GPU and hasn't reproduced the enum failure once. The validation layer stays anyway — the lesson was never about one model, and the next model swap shouldn't require re-learning it.
Images: Splitting the Model's Job from the Renderer's Job
Flux.1-schnell generates a usable background image in two to twenty-five seconds, at four sampler steps and a classifier-free guidance value of 1.0 — schnell is a distilled model built for near-instant, low-step generation:
{
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1.0
}
Diffusion models are still unreliable at one task: rendering exact text. Ask for a headline baked into the image and you get something that reads as language from a distance and falls apart on inspection — warped glyphs, invented characters, near-words. That's an architectural constraint, not something a better prompt fixes.
So the system doesn't ask Flux to do it. Flux only generates abstract, on-brand background art per content pillar, with an explicit negative prompt against any text. Every pixel of actual typography — headline, category tag, accent bar, brand mark — is composited afterward with PIL: real font rendering, line wrapping measured in pixel width rather than character count, and a contrast check that flips the brand mark between light and dark variants depending on the brightness underneath it. The brand mark is scoped per site too — getajobcoding composites its real logo in whichever color variant contrasts with the background, while the personal sites get a drawn monogram badge until they have a logo file. AI for atmosphere, deterministic code for words and branding: identify what a model is unreliable at, and route that responsibility to code you can test.
The Approval Gate
An automated pipeline publishing to five social accounts across three brands without a human checkpoint is not a convenience — it's a liability with your name attached to every account it touches. One bad generation, one routing mistake that puts brand-scoped content on the wrong account, and it's live and public. Nothing here publishes without an explicit approval click.
Every draft — article, thumbnail, caption — posts to a private Discord channel as an embed with title, pillar, read time, tags, and the actual image that would go out, alongside Approve, Reject, and Preview links. Approve hits a small FastAPI server, which writes the decision to DynamoDB; a background watcher picks up the change and only then triggers routing and publish. Reject writes the same way and nothing downstream fires. I treat this as the one non-negotiable part of the design: an unreviewed post under a real identity costs a lot, and a few seconds of attention costs almost nothing.
The Bug That Taught Me the Most About Integration Seams
The first time a draft made it through to Instagram, the publish step failed with a "media fetch failed" error from Postiz, the self-hosted scheduler used for social distribution. The cause: Postiz's local storage was serving the thumbnail from a localhost URL — one that resolves fine on the machine running the pipeline and resolves to nothing when Meta's servers try to fetch the image over the public internet, since most platforms fetch media server-side rather than accepting a direct upload.
The fix was structural, not a patch: stop trusting local file serving for anything a third party needs to reach. The publisher now uploads every image to a public S3 bucket first and hands Postiz — and by extension every connected platform — a URL that means the same thing to a stranger's server as it does locally. The lesson generalizes: whenever an external system fetches a resource rather than receiving a direct upload, "it opens fine in my browser" is not evidence that it works — that class of bug only shows up once you cross a boundary between two systems you don't fully control, and no local testing surfaces it.
Cost Model
The economics are the actual reason this runs on local inference instead of a metered API. A daily article-plus-image run, billed against a paid API, scales linearly with publishing frequency — the more valuable the system becomes by publishing more, the more it costs to run. Local generation on hardware already owned inverts that curve: cost per run is zero, because the GPU is a sunk cost and the electricity draw is negligible against what an equivalent API call would bill.
The only recurring cost is the portion of the system that has to be actually cloud — DynamoDB for state, S3 for public media — and that runs about $2 to $5 a month at this volume, with Terraform provisioning both and tracking state in its own S3 backend. Local-first inference isn't a cost-cutting layer on an otherwise cloud-native design; it's the decision that determines whether publishing's marginal cost is zero or nonzero.
Permanent History and Iteration
Every draft and every published post is written to DynamoDB and kept, never overwritten by a new draft. Each iteration record links back to the draft it improved on, so a post's full revision history is walkable. Any past post can be fed specific feedback — "the technical section needs more depth," "the opening is weak" — and reprocessed into a revision that republishes to the same URL, same brand, same deploy path, instead of creating a duplicate. Publishing stops being a one-shot bet and becomes a loop you can run again with better input.
What Actually Transfers
The specific stack — Ollama, ComfyUI, Postiz, DynamoDB, three brand-specific deploy paths — is incidental. What transfers: validate every structured model output against a schema, like any other external input. Put the human checkpoint at the point of maximum consequence, not maximum convenience — immediately before anything reaches a public account. Design credential scoping so a failure mode defaults to "nothing happens" rather than "the wrong thing happens." And model the cost curve, not just the unit cost: a $0-per-call local pipeline and a metered one look similar at day-one volume and diverge hard as frequency scales, so that divergence should drive the build-vs-buy call up front.
Subscribe for weekly architecture deep-dives → jordanamman.dev