I rebuilt my personal site last year on Hugo with a Strapi backend, all running inside Docker Compose on a box in my home lab. The stack is pretty conventional up to the point where you try to wire Strapi content through to a Hugo instance that’s already running. At that point the usual integration guides stop applying and you have to figure out the rest yourself. This post is about that last mile.

I also want to do something the older version of this post didn’t: compare what I built to the approaches everyone else uses. I went and read the established patterns after the fact, and the honest answer is that my setup is a small deviation from the norm for a specific reason — not a better mousetrap, just a different shape of problem. Worth explaining both.

The stack

Nothing exotic:

  • Hugo with the Toha theme — a personal-site/portfolio theme with a good projects section and decent post list. Hugo runs in a container as hugo server --bind 0.0.0.0 --poll 1s --buildFuture, serving live the whole time (not a one-shot build).
  • Strapi v5 as the headless CMS, running as a sibling container with a sqlite database for simplicity. Strapi handles content modeling (blog posts, projects, experience, skills, etc.) and exposes it via the standard REST API.
  • nginx in front for TLS and routing.
  • A small webhook daemon — another Node.js container on the same Docker network — that receives Strapi webhooks and converts them into filesystem changes Hugo can pick up.
  • Everything glued together with a single docker-compose.yml on an Unraid box.

No CI pipeline. No build step run by a CI/CD system. No Netlify, no GitHub Actions, no “rebuild on push” flow. The site is a long-running Hugo process. That last detail is what makes the integration story different from what most tutorials describe.

How Strapi → Hugo is usually done

Before I get to what I built, here’s the canonical set of approaches for wiring Strapi (or any headless CMS) into Hugo. I went looking for these after I’d already built my solution, partly because I wanted to make sure I wasn’t reinventing a wheel someone already got round.

Approach 1: Build-time fetch with resources.GetRemote. Hugo templates call the Strapi API directly during the hugo build. This is the approach Strapi’s own integration documentation recommends. Your templates include something like {{ $data := resources.GetRemote "https://strapi.example.com/api/posts?populate=*" }} and Hugo pulls content live at build time. No intermediate files, no duplicated state — Hugo is just a consumer of the Strapi API.

Approach 2: Pre-build Node.js fetch script. A small script runs before hugo. It hits the Strapi API, iterates over the collections you care about, writes the results to content/posts/*.md (or data/*.yaml, depending on what kind of content), and then invokes hugo to build the static site. This is the pattern most community tutorials show. It’s more flexible than resources.GetRemote because you can transform the content however you want before Hugo sees it.

Approach 3: Webhook → CI → rebuild. Strapi fires a webhook on every publish. The webhook hits your CI system (GitHub Actions, Netlify, Vercel, whatever). CI runs Approach 1 or 2 and redeploys the static output to your CDN. This is the most common production setup I’ve seen — it pairs cleanly with static hosting and keeps the CMS workflow editor-friendly.

Approach 4: Standalone webhook server that runs hugo as a subprocess. Less common, but there’s at least one public Go tool — ez-connect/strapi-hugo-webhook — that listens for Strapi webhooks on /entry and /media, generates Hugo content or data files from the payload, and then shells out to hugo --gc --minify to rebuild the static site.

All four of these assume the same underlying model: hugo is a command you run to produce a site, and you run it again when something changes. Webhooks exist to tell the system when to run that command.

Why that model didn’t fit

I don’t have a CI pipeline for this site. I don’t want one — the personal site is small, I’m the only editor, and paying the round-trip through GitHub Actions to edit a blog post from the Strapi admin feels absurd. The whole point of running Strapi in my home lab is to have a fast authoring loop without pushing through Git every time I fix a typo.

So I don’t run hugo as a one-shot command. I run hugo server --bind 0.0.0.0 --poll 1s --buildFuture, continuously, inside a Docker container. Hugo watches the filesystem and serves the latest content on every request. When a file changes, Hugo notices and rebuilds the affected pages in-place. No external rebuild command, no deploy step.

This is a really nice development experience. It also breaks all four of the canonical approaches above — they all assume “run hugo again” is the thing a webhook triggers. In my setup there is no second hugo to run. There’s just one running continuously, and the question is how to make it notice that content changed.

My first instinct was that Hugo’s file watcher would handle it. Write a webhook server that regenerates content/posts/*.md from Strapi, drop the file in place, and Hugo’s --poll 1s should pick it up on the next tick. Simple.

It mostly works. It also fails in a very specific and frustrating way that cost me two hours of “but the file is right there” debugging before I understood what was going on.

The gotcha worth writing down

Hugo’s --poll 1s file watcher is reliable for changes to files in directories that existed when the server started. It is not reliable for:

  • New files inside subdirectories of static/ or content/ that were added after the server started.
  • Files added inside a brand-new subdirectory (e.g., static/myproject/index.html when static/myproject/ didn’t exist at boot).
  • Some byte-identical content replacements where the inode changes (atomic writes from some editors, including the ones my Node script does by default).

The symptom is maddening: you scp a new HTML file into static/somedir/ or rewrite a post in content/posts/, you check on disk with ls -la (yep, it’s there), you hit the live URL (old content). You restart Hugo — new content appears. You conclude something is deeply broken. You blame yourself.

The fix I stumbled into — and eventually made the documented standard for this site — is dumb and works perfectly:

After writing new content files, touch the top-level hugo.yaml config file.

Hugo’s config-file watcher is much more aggressive than its content watcher. Any change to hugo.yaml triggers a full config reload, which re-reads the entire content/, data/, and static/ tree from scratch and invalidates every cached entry. The poll watcher’s stale in-memory state is wiped. The new files are picked up. This is a ~10-second full rebuild instead of Hugo’s normal sub-second incremental, but ten seconds is fine — editors don’t re-save fifty times a minute.

The whole workaround is a one-line call to fs.utimesSync(path.join(root, 'hugo.yaml'), new Date(), new Date()) at the end of the sync path. That’s it. It’s not pretty and it’s not documented anywhere in Hugo’s docs as far as I could find, but it is extremely reliable and the total cost is a scheduled utime syscall and a full config reload every time we publish.

I’d love to tell you I figured out a cleaner way. I did not. If you know one, please tell me. In the meantime, touching the config file gets the job done and I’ve stopped trying to be clever about it.

What I actually built

The complete system is three pieces:

  1. A webhook daemon (a Node.js container on the same Docker network as Strapi and Hugo) that listens on /refresh for Strapi webhook POSTs.
  2. A sync script (strapi-blog-to-md.js) that the webhook daemon calls, which pulls /api/blog-posts from Strapi, materializes them as content/posts/*.md files with a specific frontmatter marker, and downloads any attached hero images into assets/images/uploads/.
  3. A Strapi webhook configured to POST to http://webhook:9000/refresh (using Docker’s internal DNS) with a bearer secret, fired on entry.create / update / publish / unpublish / delete.

Let me walk each one.

The sync script

The sync script is the load-bearing piece. It’s a single self-contained Node.js file, safe to require() from a long-running parent process (which matters — more on that below), and it handles fetching, hero image download, ownership tracking, and deletion semantics. About 230 lines total. Here’s the annotated core:

const ROOT = path.join(__dirname, '..');
const DATA_FILE = path.join(ROOT, 'data', 'strapi', 'blog-posts.json');
const POSTS_DIR = path.join(ROOT, 'content', 'posts');
const HERO_DIR = path.join(ROOT, 'assets', 'images', 'uploads');
const HERO_URL_PREFIX = 'images/uploads';
const STRAPI_BASE_URL = process.env.STRAPI_API_URL || 'http://strapi:1337';
const STRAPI_TOKEN = process.env.STRAPI_TOKEN || '';

The bases are worth spelling out: ROOT is the Hugo project root, POSTS_DIR is where Hugo looks for blog post markdown, HERO_DIR is under assets/ (not static/) so that Hugo’s image pipeline picks up the hero images and can resize / fingerprint them. STRAPI_BASE_URL defaults to http://strapi:1337 because the script runs inside Docker Compose and uses the strapi service name for DNS.

async function fetchBlogPosts() {
    const endpoint =
        '/api/blog-posts?populate=*&sort=publishedDate:desc&pagination[pageSize]=100';
    const response = await fetchJson(endpoint);
    const data = Array.isArray(response.data) ? response.data : [];
    fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
    fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
    return data;
}

One authenticated GET against Strapi with populate=* to pull the featured image metadata along with the post body, bounded at 100 posts per page. The raw response is also cached to data/strapi/blog-posts.json so Hugo’s templates can use it directly if they want — which turned out to be useful later when I wanted a listing page that reads from Strapi data without going through the content files.

function buildFrontmatter(post, heroPath) {
    const date = post.publishedDate || post.publishedAt || post.createdAt;
    const lines = ['---'];
    lines.push(`title: ${yamlEscape(post.title)}`);
    lines.push(`date: ${date}`);
    if (post.excerpt) lines.push(`description: ${yamlEscape(post.excerpt)}`);
    lines.push(`slug: ${yamlEscape(post.slug)}`);
    if (heroPath) lines.push(`hero: ${heroPath}`);
    lines.push(`strapi_document_id: ${yamlEscape(post.documentId)}`);
    lines.push('---');
    return lines.join('\n');
}

Standard Hugo frontmatter, with one important addition: strapi_document_id. This is the ownership marker — it’s the Strapi v5 document ID, and it tells the sync script “this file was generated by me, from this specific Strapi record.” Files without this field are considered unowned — legacy markdown, manually-authored posts, whatever — and the sync script leaves them alone. This single field is what makes the rest of the system safe to re-run on every webhook without worrying about clobbering anything the user wrote by hand.

async function renderPosts(posts) {
    const published = posts.filter(
        (p) => p && p.slug && p.content && (p.visibility === 'published' || !p.visibility)
    );

    const liveDocIds = new Set();
    const written = [];

    for (const post of published) {
        liveDocIds.add(post.documentId);
        const slug = post.slug.replace(/[^a-zA-Z0-9._-]/g, '-');
        const filePath = path.join(POSTS_DIR, `${slug}.md`);
        const existingOwner = readOwnedDocId(filePath);

        if (fs.existsSync(filePath) && !existingOwner) {
            console.log(`skip (unowned legacy file): ${slug}.md`);
            continue;
        }

        const heroPath = await syncHeroImage(post, slug);
        const frontmatter = buildFrontmatter(post, heroPath);
        const body = stripLeadingImage(post.content.replace(/\r\n/g, '\n'));
        fs.writeFileSync(filePath, `${frontmatter}\n\n${body}\n`, 'utf8');
        written.push(slug);
    }

    // Cleanup: remove owned files whose documents no longer exist in Strapi.
    for (const entry of fs.readdirSync(POSTS_DIR)) {
        if (!entry.endsWith('.md')) continue;
        const filePath = path.join(POSTS_DIR, entry);
        const owner = readOwnedDocId(filePath);
        if (owner && !liveDocIds.has(owner)) {
            fs.unlinkSync(filePath);
            console.log(`removed stale owned file: ${entry}`);
        }
    }
}

Two passes. The first pass writes every published post, skipping any file at the target slug that doesn’t have the strapi_document_id marker (that’s an unowned legacy file we don’t touch). The second pass scans content/posts/ for any markdown file that does have the marker but whose document ID isn’t in the set we just fetched — that’s a post that was deleted or unpublished in Strapi since the last sync, and we delete it from disk. Between those two passes, the content directory matches Strapi’s current state for every post the sync owns, without affecting anything it doesn’t.

Two small helpers fill out the design:

async function syncHeroImage(post, slug) {
    const img = post.featuredImage;
    if (!img || !img.url) return null;
    const ext = (img.ext || path.extname(img.url) || '.jpg').toLowerCase();
    const filename = `${slug}-hero${ext}`;
    const destPath = path.join(HERO_DIR, filename);
    const srcUrl = img.url.startsWith('http')
        ? img.url
        : STRAPI_BASE_URL.replace(/\/$/, '') + img.url;
    try {
        await downloadToFile(srcUrl, destPath);
        return `${HERO_URL_PREFIX}/${filename}`;
    } catch (e) {
        console.warn(`  hero: download failed (${e.message}); skipping frontmatter hero`);
        return null;
    }
}

function stripLeadingImage(content) {
    // Remove a leading markdown image line (and any blank lines around it) so
    // it doesn't render a second time below the Toha hero.
    return content.replace(/^\s*!\[[^\]]*\]\([^)]+\)\s*\n\n?/, '');
}

The hero image handling pulls the attached image from Strapi’s media server and writes it to assets/images/uploads/<slug>-hero.<ext>, then puts a hero: frontmatter field pointing at it. The stripLeadingImage helper handles the case where Strapi’s rich-text editor inserted the featured image as a leading ![](/images/uploads/...) line inside the body content — the Toha theme already renders the hero at the top of the post from frontmatter, so if we leave the leading image in the body it renders twice. One regex, problem gone.

Hero download failures are logged but non-fatal. The post still renders; it just won’t have a hero image. That’s the right tradeoff for a sync that runs on every content save — a flaky Strapi media response shouldn’t break publishing.

The webhook daemon

The webhook daemon is a tiny Node.js HTTP server on port 9000 that the rest of the stack reaches at http://webhook:9000/refresh. It listens for Strapi webhook POSTs, authenticates them with a bearer secret, debounces for 5 seconds (so a rapid sequence of edits coalesces into one refresh), and on flush calls the sync script.

const { sync: syncBlogPosts } = require('./strapi-blog-to-md');

async function refreshBlogPosts() {
    const summary = await syncBlogPosts();
    console.log(
        `[refresh] blog-posts: wrote=${summary.written.length} removed=${summary.removed.length}`
    );
}

async function flush() {
    if (blog) {
        await refreshBlogPosts();
    }
    // ... sections handling for non-blog Strapi collections ...
    touchHugoConfig();
}

function touchHugoConfig() {
    const now = new Date();
    fs.utimesSync(HUGO_CONFIG, now, now);
    console.log('[refresh] touched hugo.yaml');
}

That’s the touch hugo.yaml trick in code. One utimesSync call at the end of every refresh cycle. Hugo’s config watcher fires, re-reads everything, picks up the new content, and the site is live about twelve seconds later.

The “safe to require() from a long-running parent process” detail from earlier matters here. The original version of this system tried to re-use Strapi’s own fetch-strapi-data.js script as a library, but that script calls process.exit(0) on success — which kills the webhook daemon entirely. The daemon would silently restart, the current refresh would be lost, and the next request would go through on the second attempt. Debugging that was a rite of passage. The fix was to write strapi-blog-to-md.js as a proper module that exports functions and never calls process.exit() from anything other than the CLI entry point. A small lesson: if you’re going to require a library from a server, make sure the library actually behaves like a library.

The Strapi webhook itself

Strapi v5 ships a webhook system in its admin panel, but for a one-person personal site I didn’t want to click through the UI every time I spin this up somewhere new. Strapi stores webhooks in a strapi_webhooks sqlite table, so I inserted the webhook with a single SQL statement:

INSERT INTO strapi_webhooks (name, url, headers, events, enabled) VALUES (
    'Hugo Refresh',
    'http://webhook:9000/refresh',
    '{"Authorization":"Bearer <secret>","Content-Type":"application/json"}',
    '["entry.create","entry.update","entry.publish","entry.unpublish","entry.delete"]',
    1
);

Strapi loads webhooks at boot, so after the INSERT I restart the Strapi container and it starts firing webhooks to the daemon. Five events are covered: creating a new entry, updating an existing one, publishing, unpublishing, and deleting. The sync script’s two-pass render-and-cleanup handles every one of those events correctly without any event-specific logic in the daemon itself. Deletes work because the liveDocIds set doesn’t contain the deleted document ID, so the cleanup pass unlinks the file. Unpublishes work the same way. New entries work because the render pass writes them. Updates work because the render pass overwrites in place.

The one small wart is that Strapi v5 creates both a draft and a published record for every entry, and the REST API returns only the published one by default. If you PUT to update a post, Strapi updates both the draft and the published version — but if you want the draft alone (e.g., to inspect what the admin UI shows), you need ?status=draft on the fetch. The sync script only cares about published posts, so this mostly doesn’t come up. But if you’re wondering why you see two numeric IDs for the same document in logs, that’s the draft/publish split.

Extending it to your own content types

Nothing about this pattern is specific to blog posts. Once the webhook daemon is in place, every Strapi content type you care about plugs into one of two places depending on what Hugo needs from it:

  • A markdown file per record (like blog posts or pages) — use a strapi-blog-to-md.js-shaped script that writes content/<something>/<slug>.md with a strapi_document_id ownership marker and a two-pass render-and-cleanup loop.
  • A single data file (like the projects, experiences, skills, about, author-profile, or site-configuration collections on this site) — write a transformer function that pulls from the Strapi API and writes data/.../<name>.yaml in whatever shape your Hugo theme expects.

The second shape is especially useful for things your theme reads as structured data rather than as pages — section lists on a front page, a skills grid, a team directory, an author card, site-wide config. Your template reads .Site.Data.<path>.<field> and doesn’t care how the file got there.

Here’s the pattern as pseudocode. Adapt the Strapi endpoint, the yaml shape, and the target path to whatever your content looks like:

// ---- 1. The transformer itself ----
async function transformMyThing() {
    // Pull from the Strapi REST API. `populate=*` expands media and
    // nested components; `sort` and `pagination` are as usual.
    const res = await fetchJson('/api/my-things?populate=*&sort=order:asc');
    const items = res.data || [];
    if (!items.length) return 0;  // nothing to write; keep existing yaml

    // Target file that your Hugo templates already read.
    // For a collection that becomes a section, this is usually under
    // data/en/sections/ or data/<lang>/<something>.yaml.
    const target = path.join(__dirname, '..', 'data', 'en', 'sections', 'my-thing.yaml');

    // Read the existing yaml so we can merge into it rather than
    // replacing outright. This preserves any section metadata your
    // theme expects (`section: { name, id, weight, showOnNavbar, ... }`)
    // that isn't modelled in Strapi.
    const doc = readYaml(target);

    // Rebuild only the fields Strapi owns. Leave everything else alone.
    doc.myThings = items.map((item) => ({
        name:    item.name,
        summary: item.summary,
        tags:    item.tags || [],
        // For media fields, mediaUrl() handles both v4 (nested) and
        // v5 (flat) Strapi response shapes and resolves relative URLs.
        image:   mediaUrl(item.heroImage) || '',
        // Nested components come back as arrays of plain objects.
        links:   (item.links || []).map((l) => ({ label: l.label, url: l.url })),
    }));

    writeYaml(target, doc);
    return items.length;
}

// ---- 2. Register it ----
const TRANSFORMERS = {
    // ... existing transformers ...
    'my-thing': transformMyThing,
};

// ---- 3. Map the Strapi model name to this transformer ----
// In webhook-server.js, so edits to this content type route to this
// transformer specifically instead of falling through to the refresh-all path.
const MODEL_MAP = {
    // ... existing mappings ...
    'my-thing': 'my-thing',   // Strapi model UID → TRANSFORMERS key
};

Three things to watch out for when you do this on your own content:

Merge, don’t replace. Hugo themes often expect a yaml file to contain both data and metadata — a section: block, a weight, an enable flag, presentation config. Strapi only models the data. If you writeYaml(target, { myThings: [...] }) without reading the existing yaml first, you’ll erase the metadata and your template will stop rendering. The readYaml(target) + mutate + writeYaml pattern is what preserves the non-Strapi fields.

Rebuild nested lists from scratch, single fields from Strapi when populated. For lists like contactInfo, customMenus, or a repeatable component, rebuild the whole array from the Strapi response so deleting an item in the CMS actually removes it from the yaml. For single values like name or copyright, only overwrite when Strapi has a populated value — that way an optional field left blank in the CMS doesn’t blow away a hand-edited default. On this site, the author transformer does exactly this:

if (d.name) doc.name = d.name;
if (d.nickname) doc.nickname = d.nickname;
// ... single-value fields only overwrite when Strapi is populated ...

// ... but the contactInfo list is rebuilt from scratch ...
const contact = {};
for (const item of d.contactInfo || []) {
    contact[item.name] = extractHandle(item.name, item.url) || item.url;
}
if (Object.keys(contact).length) doc.contactInfo = contact;

That way, turning a social link off in Strapi makes it disappear from the site, but clearing the nickname field by accident doesn’t nuke the nickname the theme was already rendering.

Shape-match the theme, not Strapi. Your transformer is a translation layer. Strapi models data however makes sense for editors; your theme expects whatever shape it was designed around. The transformer is where you reconcile the two. On this site, Strapi stores contacts as [{ name, icon, url }] (repeatable component), but the Toha theme expects { github: "handle", linkedin: "handle" } (flat dict keyed by provider, handle not URL). The transformer does the pivot — including extracting the handle from the URL for known social domains — so the template doesn’t have to know Strapi exists.

Pages and other content-file collections. If your content type becomes pages rather than data — long-form content with its own URL — don’t use a transformer. Write a second strapi-<type>-to-md.js script modelled on the blog-post sync: fetch from /api/<pluralname>, write content/<type>/<slug>.md with a strapi_document_id frontmatter marker, and run the same two-pass render-and-cleanup so deletes propagate. Wire it into the webhook daemon’s flush() alongside the blog sync. The ownership-marker approach is identical; only the output directory changes.

One more thing worth knowing: on the webhook daemon, any Strapi model that isn’t explicitly in MODEL_MAP or BLOG_MODELS still triggers a “refresh everything” pass through the transformer map. That means if you add a transformer to TRANSFORMERS but forget to wire up MODEL_MAP, edits still propagate — they just refresh every transformer instead of the one you changed. It’s a safe default, but adding the MODEL_MAP entry is worth it for the efficiency and the log clarity.

Comparison to the canonical approaches

I went and researched the established patterns after I’d built this. Here’s how my setup stacks up against the four I listed at the top:

Approach Where Hugo runs Who invokes the rebuild Right fit when…
resources.GetRemote One-shot hugo in CI Strapi webhook → CI You have CI and don’t mind build latency tied to API uptime
Pre-build Node fetch script One-shot hugo locally or in CI Script runs before hugo You want flexible content transformation; you accept manual or CI-triggered builds
Webhook → CI → rebuild CI-owned build box Strapi webhook hits CI Production web publishing with CDN delivery
Go webhook server + subprocess Wherever the webhook server runs Webhook server calls hugo directly You want to avoid CI but still do one-shot builds
What I built Long-running hugo server --poll 1s in Docker Webhook daemon writes files + touches hugo.yaml You have no CI, you want Hugo always-on for fast authoring, and you’re okay with a ~10s reload per publish

The honest framing: if I had a CI pipeline for this site, I would use Approach 3 (webhook → CI → rebuild). It’s the industry standard, it keeps the content pipeline stateless, and it integrates cleanly with CDN hosting. For a team site, a company blog, or anything with multiple authors, that’s the right answer.

I don’t have CI because I’m one person running this on a box in my house. Setting up GitHub Actions + a deploy key + a webhook receiver + an artifact upload pipeline is a weekend of work I don’t need. Running hugo server continuously in Docker is already doing 90% of what CI would do, at 5% of the operational weight. The missing 10% is “notice when content changes,” which is where the webhook daemon comes in.

The less-obvious part is that my setup has a fast authoring loop the CI approach can’t match. From “click Save in Strapi” to “change visible on live site” is about eighteen seconds: five for the webhook debounce, one for the API fetch and file write, then twelve for Hugo’s full rebuild. A CI-based flow is usually two to four minutes. If I’m editing a post in the admin and want to see how a paragraph lands, eighteen seconds is inside my attention span. Two minutes is not.

When this shape makes sense

This specific pattern — webhook daemon that writes files and pokes a running Hugo server — is worth considering when:

  • You’re running Hugo in server mode continuously, not as a one-shot build. Either because you’re hosting it yourself on a long-running VM / container, or because you value the fast reload for authoring.
  • You don’t have a CI/CD pipeline you can repurpose, or the overhead of wiring one up exceeds the value for your project.
  • You want the CMS on the same host as the site, so Docker-internal networking can do the webhook delivery without exposing anything to the public internet.
  • Publishing frequency is low enough that debounced full rebuilds are acceptable — for a personal blog, that’s fine; for a high-traffic news site with hundreds of edits an hour, you want something smarter.

If any of those don’t hold, use one of the canonical approaches. Approach 1 (resources.GetRemote in a CI build) is probably the default I’d recommend for anyone starting fresh. Approach 3 (webhook → CI → rebuild) is the standard for production sites with real editorial workflows.

The larger point

Three things worth taking away from this whole exercise:

First, the conventional wisdom about Strapi + Hugo assumes a specific model: hugo is a command you run, and content integrations exist to tell the system when to run it. If your deployment doesn’t match that model — if you’re running hugo server continuously — you need a different shape of glue, and nobody writes about that shape publicly because it’s a less common deployment. That’s fine. Write it yourself and document the gotchas.

Second, the gotcha worth documenting in this particular case is that Hugo’s content file watcher is less aggressive than its config file watcher, and the workaround for stale-content issues is to touch hugo.yaml at the end of whatever pipeline changes content. It’s a ten-line workaround that saves two hours of “but the file is right there” debugging for anyone who tries the same setup later. If you take one thing from this post, that’s the one.

Third, ownership markers make generated files safe. The strapi_document_id frontmatter field is the whole reason the sync script can run on every webhook without worrying about clobbering anything hand-written. Any file without the marker is off-limits. Any file with the marker is regenerated to match Strapi. Deletes propagate correctly because the cleanup pass knows which files it owns. This is a small design decision that paid for itself ten times over — I’ve recommended it to two other people since, for completely unrelated sync problems.

If you’re running the same kind of stack I am, hopefully this saves you the debugging session. And if you’re using one of the four canonical approaches, hopefully this makes it easier to articulate why it’s the right answer for your situation — because once I understood the shape of “long-running Hugo server” as a distinct deployment model, it also made it clearer why the other approaches are better for the common case and this one is specifically better for mine.


Related reading:

  • Hugo — the static site generator this is all based on
  • Strapi — the headless CMS, v5 as of this writing
  • Toha theme — the Hugo theme I’m using for this site
  • Strapi’s Hugo integration guide — the canonical “build-time resources.GetRemote” approach I ultimately didn’t use
  • ez-connect/strapi-hugo-webhook — the closest public analog to what I built, in Go, using subprocess hugo instead of file-watcher nudging