Maintaining documentation is often a game of corporate hot potato. Every team thinks it’s another team’s job to keep the docs up to date. Eventually, they get so stale nobody wants to touch them.
At OpsMill, docs aren’t owned by one team because they’re not stored in a single codebase.
We have the core Infrahub project, the Python SDK, the Ansible collection, the Nornir plugin, Infrahub Sync, a VS Code extension, an MCP server, the Infrahub Marketplace, a handful of demo repos, and more arriving roughly every quarter.
Each one of those is owned by the team that built it, and every one of them carries its own documentation in its own repository, right next to the code it describes. Because it all gets pushed to one place, all visitors see is a single Docusaurus site.
Below, we explain more about why we’ve gone in this direction, what the architecture looks like, and how the CI plumbing works (with a few kinks we’re still working out).
3 options for doc maintenance
Documentation should live in the same repo as the code it documents, and ideally change in the same pull request (PR). Easier said than done. Because if you or an AI agent fixes a behavior and don’t touch the docs in the same PR, the docs are already wrong by the time you merge.
At OpsMill, we initially considered 3 options for maintaining our docs:
Option A: A single docs monorepo
All documentation in one repository, written and reviewed in one place.
- Pros: Easy to build, only one sidebar to reason about, no syncing needed.
- Cons: it divorces docs from code. A change to an SDK no longer touches the SDK’s docs, they’re in a different repo (the exact type of failure we were trying to solve for).
Option B: Git submodules
Keep docs in each repo and pull them into the aggregator as submodules.
- Pros: Docs stay with their code, and the aggregator references commits.
- Cons: The submodule UX is famously unpleasant (anyone who has explained
git submodule update --init --recursiveto a new starter knows the look they get back) and the pinned commits go stale the moment nobody remembers to bump them. You don’t have a sync problem, but you now have a pinning problem.
Option C: Push-based sync
Docs live in each repo, and when they change, that repo’s CI pushes a copy into the aggregator, which then builds the combined site.
- Pros: Docs stay next to the code, the aggregator always has the latest content, and no single team owns the docs. Each repo is self-contained.
- Cons: There’s some maintenance, and the sync itself is a direct push to the aggregator’s
mainusing a personal access token. Not the most elegant thing I’ve ever shipped, but it’s predictable, and the review already happened in the source repo where the docs were edited.
We went with C. Option A was never really on the table once we committed to docs-as-code, and Option B’s pinning problem felt worse in the long run.
The system we built to maintain documentation
Once we made our decision, we started building our aggregator repo: opsmill/infrahub-docs.
It’s a single Docusaurus site, but instead of one big docs folder it registers one @docusaurus/plugin-content-docs instance per project. Each instance gets its own content folder, its own sidebar, its own route, and an editUrl that points back at the source repo. That way, when you click Edit this page, you end up in the right place.
A few things sit on top of all of that:
- Algolia search: It spans every instance so one search box covers all dozen-plus projects. We have an Ask AI assistant wired in alongside it too.
@docusaurus/theme-mermaid. Contributors can author diagrams as Mermaid in Markdown and have them render on the site.- A
DOCS_IN_APPtoggle. You can render content in two ways: standalone atdocs.infrahub.appor embedded inside the Infrahub application itself. The config flipsurlandbaseUrldepending on the environment variable, so the docs you read in-product are the docs you read on the web.
Here’s what it looks like:

How we built our docs maintenance system
One plugin instance per project
In docs/docusaurus.config.ts, each project is registered as its own content-docs plugin. Here’s the Python SDK’s entry, verbatim:
[ '@docusaurus/plugin-content-docs', { id: 'docs-python-sdk', path: 'docs-python-sdk/python-sdk', routeBasePath: 'python-sdk', sidebarCollapsed: false, sidebarPath: './sidebars-python-sdk.ts', }, ]
The id namespaces the instance, path points at the folder the sync writes into, routeBasePath gives it a clean URL prefix (/python-sdk), and sidebarPath references a sidebar file that the source repo also owns and syncs. This block repeats 15-plus times, once per project. When you add a new project, you add a new block.
Creating a sync workflow
Every source repo carries a .github/workflows/sync-docs.yml. It only fires when documentation changes, so a normal code-only PR doesn’t churn the docs site:
on: push: branches: - main paths: - 'docs/docs/**' - 'docs/sidebars.ts'
When it runs, it checks out two repositories: the source repo it lives in, and the infrahub-docs aggregator, using a PAT_TOKEN secret for write access to the latter.
- name: Checkout source repo uses: actions/checkout@v6 with: path: source-repo
- name: Checkout infrahub-docs uses: actions/checkout@v6 with: repository: opsmill/infrahub-docs token: ${{ secrets.SOME_TOKEN }} path: target-repo
Then, it clears the project’s folder in the aggregator, copies the freshly built docs and sidebar across, and pushes—but only if something truly changed.
rm -rf target-repo/docs/docs-infrahub-demo-sp/ rm -f target-repo/docs/sidebars-infrahub-demo-sp.ts
cp -r source-repo/docs/docs/. target-repo/docs/docs-infrahub-demo-sp/ cp source-repo/docs/sidebars.ts target-repo/docs/sidebars-infrahub-demo-sp.ts
cd target-repo git add . if ! git diff --cached --quiet; then git commit -m "Sync docs from infrahub-demo-sp repo" git push fi
Note: This is the direct-push-to-main step I flagged earlier. Reviews happen in the source repo’s PR, where a human reads the docs change. Since the sync is mechanical, it ships without its own PR. If we ever want a gate, this is the line we’d change.
The full sequence, from an engineer’s PR to live docs, goes like this:

Onboarding a new repo
Adding a project by hand means touching a workflow, a sidebar, a config, and a folder, in two repos, in the right order. Because we don’t trust ourselves to do that consistently, we built scripts/setup-docs-repo.py, which:
- Scaffolds the documentation framework into the source repo (
docs/docusaurus.config.ts,docs/package.json,docs/sidebars.ts) - Drops in the linting config (
.vale.ini,.markdownlint.yaml,.yamllint.yml) - Writes the
sync-docs.ymlworkflow with the project name substituted in
On the aggregator side, it creates the placeholder content folder and sidebars-.
It can’t do everything though, so there are still some human steps:
- The central config edit is manual. You have to add the new
plugin-content-docsblock and the navbar entry todocusaurus.config.tsyourself. The script generates the snippet, but a navbar belongs somewhere specific and a script guessing the location would do more harm than good. - Merge order matters. You have to merge the
infrahub-docsPR first, then the source repo PR. If you merge the source repo first, its sync workflow fires and pushes docs into a folder the aggregator config doesn’t know about yet. Aggregator first, source second. Get it backwards once and you’ll remember forever.
Keeping a dozen repos consistent
The thing that makes a multi-repo docs site feel like one site (rather than 15 strung together) is that every repo is held to the same standard.
Our onboarding script templates Vale (prose linting), markdownlint, and yamllint into each source repo, and those run in the source repo’s own CI.
By the time a docs change reaches the sync step, it’s already passed the same prose and formatting checks as every other project, enforcing consistency.
The payoff from our docs system
It’s not the flashiest system, but a docs site that’s always current is good for our:
- Customers, who need the most accurate, up-to-date information
- Engineers, who don’t have to context-switch into a separate docs repo or wait on another team to publish their changes
- New joiners, who need to get up to speed on Infrahub quickly
We do a fair amount of sweating the unglamorous details at OpsMill. If that’s your idea of a good time, we want to hear from you. Take a peek at our open roles.