Skip to content
All case filesThat Paleo Chick

Digital Tide, flagship engagement

That Paleo Chick

A Ghost publication with a theme I built, a content pipeline that fills it, an iOS app that reads the same structured data the theme renders, and the infrastructure all three run on. I designed it, I operate it, and I am the person who gets paged.

Foundation
Ghost 6, on a theme I build
Custom templates
Sixteen
One recipe, rendered by
Search, the site, and the app
App status
In TestFlight, submitted for review

This is a Ghost site first. Everything else here exists to serve it: the pipeline creates drafts in Ghost, the social scheduler distributes what Ghost publishes, and the iOS app reads Ghost through its content API. If you want to understand the system, start with the theme, because the theme is where the decision that shaped everything else got made.

That decision was to make a recipe one piece of structured data rather than one page. Everything after it follows.

The theme

  1. 01

    One recipe feeds search, the site and the app

    Every recipe post carries a schema.org Recipe block: ingredients, method, times, yield, nutrition. Three separate consumers read that same block. Search engines read it for rich results, which is the entire organic acquisition strategy for a recipe publication. The theme reads it in the browser to build the recipe card a reader actually cooks from. And the iOS app parses it out of the content API to render the recipe natively. One canonical representation, three renderers, and no second database anywhere. Change an ingredient and it changes in all three, because there is only one place it was ever written.

  2. 02

    Recipe cards render from structured data, not post fields

    The recipe card in the theme is a Handlebars partial with no content in it at all. It is structure, hydrated in the browser from the structured data. That is not a cute trick, it is what makes the previous point true: if the template rendered the ingredients from post fields, the post would be the source of truth and the structured data would be a copy that drifts.

  3. 03

    Parsing schema.org when the real data is messy

    Schema in the wild is looser than the specification suggests. The type can be a plain string or an array, and the block can be a single node, a graph, or a bare top-level array. All of those are valid and all of them occur, so the parser accepts every one. If the card lands on a page whose structured data is missing or malformed, it removes itself rather than showing a reader empty Ingredients and Instructions headings above a Print button that prints nothing. A missing card is better than a broken one.

  4. 04

    Sixteen custom templates on a commercial base theme

    The base is a commercial Ghost theme, and I want to be straight about that because the work worth describing is not the typography. It is the sixteen custom templates on top of it: recipe posts, meal plan posts, guided program posts, the hubs those two sit in, the membership and premium landing pages, sign-in, sign-up and account, the tag archive, and several post header treatments. Around 125 commits, roughly 1,700 lines of JavaScript and 5,000 lines of stylesheets.

  5. 05

    Category index: working around a 100-row query limit

    Ghost's template query returns at most 100 rows and ignores a request for more, with no pagination and no warning. The publication carries about 490 tags, so the category index chunks its query by first letter using starts-with filters, keeping every block under the cap and stating the limit explicitly in each one rather than relying on a silent default.

The pipeline

The brief was a publication that could keep a real posting cadence across the website and six social networks without a staff. The obvious build is a bot that writes and publishes. I did not build that, and the reason is the whole point of the system.

An automated publisher that is wrong is not embarrassing in the moment. It is embarrassing for as long as it takes somebody to notice, on a site with real readers and a real person's name on it. So the pipeline automates everything up to the decision and nothing past it.

The content pipeline

on approvalafter reviewScheduled runGitHub Actions, OIDC intoAWSDraft the postClaude, per content typeMake the imageryImagen 4, then sharp to WebPValidation gatesTaxonomy contractRecipe JSON-LD shapeDuplicate ledgerGhost draftNever published by a machineApproval cardSlack, allowlisted peopleonlyPublished to the siteWeb only, mails nobodyScheduled to socialPostiz, six networks
A scheduled run in GitHub Actions generates copy and imagery in parallel, puts both through validation, and creates a Ghost draft. That is as far as automation goes. The draft becomes a card in Slack, and a person decides whether it publishes to the site, fans out to social, or stays a draft.

What the machine cannot do

  • A scheduled run cannot publish.

    Every orchestrator ends at a draft. There is no code path from the cron trigger to a live post. The only thing that publishes is a person pressing a button.

  • Publishing cannot email the subscriber list.

    The publish function is web only and does not carry the parameter that mails anyone. A second, separate function is the only thing that can mail the list, and its single caller is a human sending the weekly recap. It is two functions rather than one function with a flag, precisely because a flag is one typo away from mailing every subscriber on every recipe.

  • Being in the room is not authorization.

    Every button that publishes, spends on model calls or edits a post checks an explicit allowlist and refuses everyone else. Membership of the workspace is not a trust boundary, and treating it as one means the access control list is whoever happened to get invited to a channel.

  • Nothing reaches social until the captions have been read.

    Scheduling generates per-network copy and then stops on a preview. Nothing is created in the scheduler until somebody confirms it, and any network that already carries that post is pre-unchecked so the same thing cannot go out twice.

One taxonomy, held in place by a test

Four surfaces name a category: the site, the newsletter, social routing, and the theme's own archive template. All four read one file, which is the single source of truth for the whole taxonomy. The contract is strict because routing depends on it. A post carries exactly one section tag and it has to be first, because the first tag is what distribution keys on.

A drift test asserts that contract across all four surfaces at once, parsing the theme and reading the live site navigation to do it. The four cannot import each other, so nothing structural stops them drifting apart. The test is what does.

Design decisions

  1. 01

    Image prompts: candid photos beat professional ones

    The instruction that produces a believable food photograph is the opposite of the obvious one. Not crisp, bright and professional, which reads as synthetic at a glance, but a candid homemade phone photograph: natural window light, imperfect, crumbs and drips, not studio. Style is picked deterministically from the dish, so the feed varies while rerunning the same recipe produces the same image choice. Reruns stay predictable, which is what matters in a pipeline you have to debug.

  2. 02

    Affiliate links: one registry for site and newsletter

    Product links are generated from a registry that lives in the pipeline repository rather than the theme, which looks backwards until you notice that the weekly newsletter has to build the same links and email clients run no theme JavaScript. One registry and one matcher feed both surfaces, so the same sentence links to the same product in the email and on the page. The generated theme file carries a fingerprint of the sources it came from, and a test asserts that the fingerprint in the shipped bundle is current, so a regenerated registry cannot go out without its rebuilt bundle.

  3. 03

    Ad placement at section breaks, requested lazily

    In-content advertising is placed immediately before a section heading, so it reads as a break between sections rather than something dropped mid-thought, and never beside the recipe card. Each slot is handed to the ad network only when it comes near the viewport, so a reader who bounces off the introduction never paid to download three advertisements they scrolled past. The signed-in member check is the absence of the configuration rather than a second condition: the config renders nothing at all for a member, so there is one place that decision is made.

  4. 04

    A companion planner instead of forking the scheduler

    Scheduling published articles out to social needed a visual board, and the scheduler has no plugin system, so extending it would have meant forking and maintaining a container image. I built a small companion application beside it instead, driving it through its API, reachable only through the reverse proxy and authenticated against the scheduler itself. It shows every published article against live per-network status, generates captions with a model, and creates the scheduled posts after a human edits them.

  5. 05

    Why I did not build Google sign-in

    Single sign-on into the scheduler looked like an afternoon. It is not. The provider model is one authentication method per account, so linking an existing account breaks its password login outright; the sign-in flow reuses an unrelated integration client and redirects to a page that is itself behind authentication, so a logged-out user never completes it; and registration is disabled by design. Making it work would mean patching the callback and forking the project. I wrote down why, and left password authentication alone. Per-network connections still use OAuth, because there they genuinely work.

  6. 06

    Billing on the web to stay inside App Store rules

    Membership is billed through Stripe on the website. The app reflects what somebody has paid for and never sells anything, which keeps it inside the reader-app rules and keeps a payment platform out of the mobile release cycle. The gating ships switched off: every gate is currently inert, and it stays that way until the tiers are deliberately turned on.

Deploying the theme

Merging to the main branch zips the working tree and posts it to the production publishing API. There is no staging environment, which is a deliberate trade for a one-person operation and means the discipline lives in the repository instead.

Compiled bundles are committed artifacts rather than built in continuous integration, so the standing rule is that a source change ships only when its rebuilt bundle ships with it, and that rule is stated on the first screen of the repository rather than buried. A verification script runs before every push: required template files present, every script syntax-parsed, and the platform's own theme validator run as a ratchet, where any violation not already on the known list blocks the push.

What runs where

creates draftscontent APIAWS Secrets ManagerRead over OIDC, nevercommittedGitHub ActionsThe pipeline, on a schedulePublication hostGhost 6MySQLnginxOperations hostPostiz schedulerSlack control appPlanner appCloudflareDNS, WAF, CDN, objectstoragethatpaleochick.comOrigin locked to the edgeiOS appReads the same content APISix social networks
Two small virtual machines, one AWS account, and deliberately no dependency between them. The publication can serve while social scheduling is down, and social scheduling keeps running while the site is down. Credentials live in one place and are read at run time, so nothing holds a copy.

Operating it

Continuous integration reaches the secret store through federated identity rather than a stored key, which means there is no long-lived credential to leak from the pipeline, and nothing on either host holds a copy of anything.

It is also documented the way I would want a team to document something. There is an operator runbook written for somebody who did not build it and is looking at it under pressure at two in the morning: symptom, which host, what to do. The failure modes that cost real time are written down as failure modes, including the silent ones.

The iOS app

React Native and TypeScript, reading the same content API the website is built on, and parsing the same structured recipe the theme renders. Currently in TestFlight and submitted for review.

The app home screen, showing today's planned meals and the newest recipes

Home. Today's plan up top, then the catalog.

1 / 6

How the app is built

  1. 01

    Offline-first caching, for a kitchen with no signal

    Reads go through a cache with a time-to-live: inside the window it serves from cache, past it, it refetches and falls back to the expired cache on any network error. Favorites, the shopping list and the meal plan live on the device. The result is that a kitchen with no signal is a working app rather than a spinner, which is the actual context this gets used in. The content API also caps every page at 100 rows, the same cap that broke the category index, so the client paginates rather than trusting a request for everything.

  2. 02

    Feature flags and automatic degraded-service detection

    Each area of the app is gated by a flag that combines an owner override, remote configuration and a shipped default, and that is then combined with automatic degraded-service detection, where repeated network failures mark a capability degraded on their own. Both layers are offline safe and neither can throw or block a screen. The standing rule is that a new feature ships with its flag, its gate, its health wiring and a three-case test, or it does not ship.

  3. 03

    Release signing without a shared certificate repo

    Releases authenticate and sign through an App Store Connect API key with automatic provisioning, rather than a shared certificate repository, which does not work on this team's account. Version comes from the git tag and the build number is derived from what is already uploaded, so two people cannot collide. A tag produces a TestFlight build; submission is a deliberate, separate action.

Stack

Publication
Ghost 6, Handlebars theme, sixteen custom templates, SCSS and Gulp, schema.org structured data, MySQL, nginx, Stripe memberships
Pipeline
Node.js and TypeScript, Claude, Imagen 4, sharp, GitHub Actions on a schedule, AWS Secrets Manager over federated identity
Distribution and control
Self-hosted social scheduler, Per-network OAuth connections, A Slack app over a socket connection, no open ports, A companion planning application behind the proxy, Postgres and Redis
Edge and revenue
Cloudflare DNS, WAF and CDN, Object storage on the CDN, Amazon Associates, generated from one registry, AdSense, placed by section, Transactional email
App
React Native, TypeScript, React Navigation, On-device persistence, Passwordless sign-in and universal links, Fastlane and GitHub Actions