Across a large product org you end up with many teams and divisions, each writing their own docs — product engineering, web, QA automation, the mobile native app, and a couple of separate automation groups. Everyone wants their own repo, their own branch, their own release cadence. But the people reading the docs (support, partners, new hires, other engineers) just want one URL and one search box.
This post is about how I wired all of that into a single serving platform using Docusaurus, git submodules, Docker, and nginx path routing. It’s as much a product management story as a technical one, so I’ll cover both.
Table of Contents
- The problem: docs sprawl across teams
- Why this is needed: every team structures docs differently
- The product decision: aggregate, don’t centralize
- Architecture overview
- How submodules keep team ownership intact
- Running everything locally with Docker Compose
- The production build
- Serving it all under one domain with nginx
- Search across everything
- The artifacts explorer
- Product management takeaways
- When to use this pattern
- Ref
The problem: docs sprawl across teams
When a product grows past a few teams, documentation naturally fragments:
- Each team keeps docs in their own code repo (good — docs live next to code)
- Each team ships on its own schedule
- Each team picks its own structure and sidebar
- Nobody wants to migrate into a giant shared repo (and you shouldn’t make them)
The result is 6-7 different doc sites, each on a different URL, with no shared search. A support engineer trying to answer a ticket has to know which site to look in before they can even start searching. That’s a bad experience and a product problem, not just a tooling one.
Why this is needed: every team structures docs differently
This is the real reason a simple “just make one big Docusaurus site” doesn’t work. It’s not only that teams move at different speeds — it’s that each team’s docs are shaped completely differently, because of how close the docs live to their code.
Broadly there are two camps:
1- Teams that keep docs inside the codebase. These teams version docs right next to the code they describe, so the doc structure mirrors the code structure. And code structures differ wildly:
- One backend team organizes docs module-wise and plugin-wise — folders like
module/,plugins/,api/,database/,integrations/. The docs tree literally follows the product’s module layout. - A web app team keeps docs per folder, per module — a
README.mdliving inside each feature folder, right where the code is. Docs are scattered across the source tree on purpose, so the person editing a feature edits its doc in the same place. - An automation team doesn’t even have a
docs/folder — their written material lives underspecs/, because for them the spec is the documentation.
2- Teams (or divisions) that keep docs outside the codebase. Either they don’t have a traditional codebase, or they deliberately separate docs from source:
- The mobile app team puts all its docs in a completely separate folder at the repo root — one dedicated docs area, not sprinkled through the app source.
- Non-engineering divisions that don’t ship code at all still need a home for their material, and they want it structured their way.
Here’s the thing: none of these teams are wrong. Module-wise makes sense for a plugin-heavy backend. Per-module README.md makes sense when you want docs edited in the same PR as the code. A separate root folder makes sense for a mobile app where docs aren’t tied to individual source files.
If you tried to force all of them into one shared repo with one layout, you’d break the thing that makes each approach work — docs would drift away from the code they describe, and the teams would stop updating them. So the platform has to accept each team’s structure as-is and normalize only at build and serve time. That’s exactly what the submodule + per-target build design does: it reaches into each repo at whatever path that team uses (docs/, docs/docs/, specs/, a root docs folder) and publishes it under a clean URL path, without asking anyone to reorganize.
The product decision: aggregate, don’t centralize
The key call here: do not force teams into one repo. Forcing centralization kills ownership and creates merge-conflict hell. Instead, aggregate at build and serve time.
So the design goal became:
- Each team keeps full ownership of their docs, in their own repo, on their own branch
- A central “docs hub” repo pulls them all together
- Readers get one domain, one navigation surface, one search index
- Builds are independent per team, so one team’s broken build doesn’t block others
This is the classic “single serving platform, distributed authoring” split. Authoring stays decentralized. Serving gets centralized.
Architecture overview
Here’s the shape of it:
docs-hub/ # central aggregator repo (Docusaurus)
├── docs/ # hub's own landing content
├── src/ # hub pages + CSS
├── scripts/ # build helpers (artifacts explorer)
├── static/ # hub static assets
├── submodules/ # each team's docs, as git submodules
│ ├── producteng/ # Backend docs in codebase (module/plugin-wise)
│ ├── producteng2/ # a second product-eng division
│ ├── application/ # Web app docs (per-module README, under docs/docs/)
│ ├── qa/ # QA Automation docs
│ ├── native/ # Mobile app docs (separate root docs folder)
│ ├── automation/ # Automation group (content under specs/)
│ └── artifacts/ # raw static artifact files (no site)
├── docker-compose.yml # dev: run all sites at once
├── build-production.sh # prod build, per-target
├── docusaurus.config.js # hub config
├── sidebars.js
└── nginx.conf # one domain, path-routedEach submodule is a complete, standalone Docusaurus site on its own. The hub doesn’t reach inside and rewrite their content. It just checks them out, builds each one, and drops the output into a path under the hub’s build folder.
How submodules keep team ownership intact
Git submodules are the glue. Each team’s docs repo is referenced with a pinned branch in .gitmodules:
[submodule "submodules/producteng"]
path = submodules/producteng
url = https://git.example.com/org/producteng.git
branch = master
[submodule “submodules/application”]
path = submodules/application url = https://git.example.com/org/web.git branch = docs
[submodule “submodules/qa”]
path = submodules/qa url = https://git.example.com/org/qa-automation.git branch = docs
[submodule “submodules/native”]
path = submodules/native url = https://git.example.com/org/mobile-native.git branch = docs
[submodule “submodules/artifacts”]
path = submodules/artifacts url = https://git.example.com/org/artifacts.git branch = main
Notice some teams track master and others track a dedicated docs branch. That’s intentional — some teams keep docs on their main branch, others isolate docs on a separate branch so doc edits don’t trigger their app CI. Both work fine here because the branch is pinned per submodule.
To pull everything down and check out the right branches, one npm script does it:
{
"scripts": {
"submodules": "git submodule update --init --recursive && cd submodules/producteng && git checkout master && cd ../qa && git checkout docs && cd ../application && git checkout docs && cd ../native && git checkout docs && cd ../.."
}
}The important property: a team updating their docs just pushes to their own repo. The hub picks up the new commit on the next build. No shared-repo coordination needed.
Running everything locally with Docker Compose
In dev you want to see every site running side by side. Each one gets its own container and port, all from the same Docusaurus builder image. Here’s a trimmed version:
version: '3.3'
services:
main:
container_name: docs-main
image: org/docs-builder:latest
volumes:
- .:/app
- /app/node_modules
ports:
- "3000:3000"
command: sh -c "npm install && npm start -- --host 0.0.0.0"
producteng:
container_name: docs-producteng
image: org/docs-builder:latest
working_dir: /repo
volumes:
- ./submodules/producteng:/repo
- /repo/node_modules
ports:
- "3001:3000"
command: sh -c "npm install && npm start -- --host 0.0.0.0"
application:
container_name: docs-application
image: org/docs-builder:latest
working_dir: /repo/docs
volumes:
- ./submodules/application:/repo
- /repo/docs/node_modules
ports:
- "3002:3000"
command: sh -c "sh sync-tech.sh && npm install && npm start -- --host 0.0.0.0"A few things worth calling out:
- The named volume trick (
- /repo/node_modules) keeps container-installednode_modulesfrom being clobbered by the host mount. Classic Docker-for-Node pattern. - Some teams put their Docusaurus site under a
docs/subfolder inside their repo, soworking_dirdiffers (/repovs/repo/docs). The aggregator just adapts per service. - A couple of teams run a
sync-tech.shstep first to pull in extra content before building. Teams can have their own pre-build quirks and it doesn’t leak into the others.
Local port map ends up like this:
| Site | URL |
|---|---|
| Hub (main) | http://localhost:3000 |
| Product Engineering | http://localhost:3001 |
| Application | http://localhost:3002 |
| QA Automation | http://localhost:3003 |
| Mobile Native | http://localhost:3004 |
| Automation | http://localhost:3005 |
The production build
For production, each target builds in a throwaway Docker container and the output is copied into a path under build/. The script takes a target argument so you can rebuild just one team’s docs without rebuilding everything:
#!/bin/bash
set -e
cd "$(dirname "$0")"
BUILD_TARGET="${1:-all}"
if [ "$BUILD_TARGET" = "all" ] || [ "$BUILD_TARGET" = "main" ]; then
echo "Building hub site..."
docker run --rm -v "$(pwd):/app" -v /app/node_modules -w /app \
org/docs-builder sh -c "npm install && npm run build"
fi
if [ "$BUILD_TARGET" = "all" ] || [ "$BUILD_TARGET" = "producteng" ]; then
echo "Building Product Engineering..."
rm -rf build/producteng
docker run --rm -v "$(pwd)/submodules/producteng:/repo" -w /repo \
org/docs-builder sh -c "npm install && npm run build -- --config docusaurus.config.prod.js"
mkdir -p build/producteng
cp -r submodules/producteng/build/* build/producteng/
fi
# ...one block per team target...
echo "=== Build completed ==="This is exactly where “every team structures docs differently” gets handled. Each block reaches into whatever path that team actually uses, then copies the built output to a clean path:
- Backend team (docs in codebase, module-wise): source at
submodules/producteng/docs/, build from repo root - Web app team (per-module
README.mdin the codebase): source nested atsubmodules/application/docs/docs/, build from thedocs/subfolder - Mobile app team (docs in a separate root folder): its own docs area, built on its own
- Automation team (no
docs/folder): source pulled fromsubmodules/automation/specs/
The aggregator doesn’t care that the paths are inconsistent. Each per-target block just knows where that team keeps its content and where the built HTML lands. Add a new team? Copy a block, point it at their path, done.
Usage is just:
./build-production.sh # everything
./build-production.sh producteng # only one team
./build-production.sh applicationWhy this matters for product management: independent builds = independent failure domains. If the QA team ships a broken markdown link that fails their build, it doesn’t take down the whole docs platform. You rebuild and redeploy that one target.
Note each team uses a separate docusaurus.config.prod.js for production — different baseUrl, no local dev tweaks, etc. The dev config and prod config are kept apart so local experience doesn’t leak into the deployed paths.
The build also copies the raw .md source per target into a docfiles/ folder. Handy if you want to feed the raw markdown into something else later (search indexing, an LLM, migration tooling) without re-cloning every repo.
Serving it all under one domain with nginx
This is where the “single serving platform” promise is actually delivered. One nginx server block, each team on a path alias:
server {
listen 80;
server_name docs.example.com;
root /var/www/docs-hub/build;
index index.html;
# Hub landing site
location / {
try_files $uri $uri/ /index.html;
}
# Product Engineering
location /producteng {
alias /var/www/docs-hub/build/producteng;
try_files $uri $uri/ /producteng/index.html;
}
# Web
location /application {
alias /var/www/docs-hub/build/application;
try_files $uri $uri/ /application/index.html;
}
# QA
location /qa {
alias /var/www/docs-hub/build/qa;
try_files $uri $uri/ /qa/index.html;
}
# Mobile Native
location /native {
alias /var/www/docs-hub/build/native;
try_files $uri $uri/ /native/index.html;
}
}So the reader sees:
Hub: https://docs.example.com/
ProductEng: https://docs.example.com/producteng
Application: https://docs.example.com/application
QA: https://docs.example.com/qa
Native: https://docs.example.com/native
Automation: https://docs.example.com/automationOne domain, one SSL cert, one place to bookmark. The try_files ... /<path>/index.html fallback is important — Docusaurus produces a SPA-ish static site, so client-side routes need to fall back to that section’s index.html, not the root one. Make sure each team’s production baseUrl matches its path (e.g. /producteng/) or the asset links break.
Search across everything
For search I used the local search plugin (@easyops-cn/docusaurus-search-local) rather than hosted search. It builds the index at build time, so there’s no external dependency and no per-query cost:
themes: [
[
require.resolve('@easyops-cn/docusaurus-search-local'),
{
hashed: true,
indexDocs: true,
indexBlog: false,
},
],
],It’s up to you whether you want per-site search or a single unified index. Per-site is the low-friction default (each build indexes its own content). A truly unified search box across all teams needs a bit more glue — either a shared index built from the docfiles/ markdown dump, or a hosted search service pointed at the whole domain. NOT a full solution out of the box, but good enough that each section is searchable immediately.
The artifacts explorer
One extra nicety: a lot of teams produce static artifacts (PDFs, exported reports, diagrams) that aren’t really “docs pages” but people still need to find them. I added a small build script that scans the artifacts submodule and generates a browsable explorer page, wired into the npm lifecycle hooks:
{
"scripts": {
"artifacts": "node scripts/gen-artifacts-explorer.js",
"prestart": "node scripts/gen-artifacts-explorer.js",
"prebuild": "node scripts/gen-artifacts-explorer.js",
"start": "docusaurus start",
"build": "docusaurus build"
}
}Because it runs on prestart and prebuild, the explorer is always regenerated right before the site runs or builds — nobody has to remember to run it. Dropping a new file in the artifacts repo is enough.
Product management takeaways
This project was as much about org design as code. The lessons that carried the most weight:
- Decentralize authoring, centralize serving. Teams own their content in their own repos. Readers get one front door. Don’t trade one for the other.
- Accept each team’s structure, don’t standardize it. Some keep docs module-wise in the codebase, some keep a
README.mdper feature folder, some use a separate root folder, some usespecs/. Forcing one layout is how you get stale docs. Normalize at build/serve time, not at authoring time. - Pin, don’t merge. Submodules pinned to a branch let each team move at its own speed. You pull their latest commit at build time instead of merging their content into a monorepo.
- Independent failure domains. Per-target builds mean one team’s broken doc never blocks the whole platform. This is the single biggest operational win.
- Meet readers where they are. The success metric isn’t “did we build a docs site.” It’s “can a support engineer find the answer without knowing which team wrote it.” One domain + search is what actually moves that metric.
- Automate the boring glue. Submodule checkout, artifacts generation, and per-team pre-build steps all run from scripts and lifecycle hooks. If a step depends on a human remembering, it will eventually get skipped.
- Keep dev and prod configs separate. A dedicated production config per team avoids the “works locally, broken paths in prod” class of bug.
When to use this pattern
Use this aggregator-of-submodules approach when you have several teams or divisions that each want to own their docs independently, but your readers need a single, searchable destination. It gives you distributed ownership with a unified experience, independent builds, and a dead-simple nginx serving layer — all on plain static output with no runtime server to babysit.
If you only have one or two teams, skip the submodule machinery and just use a single Docusaurus site with multiple sidebar sections. The complexity here only pays off once team independence actually starts costing you.