I'm a freelancer and solopreneur, and I run two products: a SaaS for CrossFit competitions, and Kerflow, an open-source tool to find local leads.

Two products, two documentation sites to maintain. On my own.

And every time, I ended up with the same setup.
A Node project, a framework, a build step, a CI pipeline… just to show some Markdown.

So I built tidydoc.

A folder of Markdown files, one Docker container, and you are done.

Today, I edit my docs with Claude Code (or by hand), commit, and push to GitHub.

A few seconds later, the site is up to date. No rebuild, no container restart.

A few developers asked me about it.

So today, I will tell you why I built it, and how it works under the hood.

TidyDoc

Docs should not need a build pipeline

Let me explain.

You have two main options to ship docs for your product today.

  1. A static site generator. You install Node, add a framework, write a config file, run a build, and deploy the output.
  2. A hosted docs platform. Great experience, but you pay per seat and your docs live outside your infrastructure.

Both are perfect when you have a docs team and thousands of pages.

But in my case, I just wanted three things:

  • My docs running next to my other services, in the same docker-compose.yml.
  • Editing a .md file and pushing it to GitHub as the only workflow. No build, no redeploy, no restart.
  • The same tool for every product, with a different logo and different colors.

I could not find that, so I built it.

Your folder is the config

tidydoc turns a folder of Markdown files into a clean documentation website.

No build step, no database, no config file.

The navigation is built from your folder tree:

docs/ ├── index.md → / (home page) ├── 01-getting-started/ → "Getting started" section │ ├── 01-installation.md → /getting-started/installation │ └── 02-configuration.md → /getting-started/configuration └── 02-guides/ ├── _meta.json → section options └── webhooks/ → collapsible group └── index.md → /guides/webhooks 
  • Numeric prefixes (01-) set the order. They are removed from the URL and the title.
  • Top-level folders become sections, deeper folders become collapsible groups.
  • The page title comes from the frontmatter, then the first # Heading, then the file name.
  • Links between pages are relative paths, so they work on GitHub too.

Your brand (logo, colors, font, sidebar links) is set with environment variables. That's it.

Running it takes less than a minute

Add this to your docker-compose.yml:

services: docs: image: ghcr.io/tidydoc/tidydoc:latest restart: unless-stopped ports: - "8080:80" volumes: - ./docs:/content:ro environment: SITE_TITLE: Acme COLOR_PRIMARY: "#6d28d9" SIDEBAR_LINKS: "Website|https://acme.com|mdi-web;GitHub|https://github.com/acme|mdi-github" 

Then:

docker compose up -d open http://localhost:8080 

The volume is mounted read-only (:ro). tidydoc never writes into your folder.

Edit a Markdown file, refresh the page, and the change is there.

TidyDoc screenshot

My workflow: git push, and the docs are live

This is the part I love the most.

My docs live in a GitHub repository, as plain .md files. Here is the whole loop:

  1. I edit the Markdown with Claude Code, another AI agent, or by hand.
  2. I commit and push to GitHub.
  3. A GitHub Action syncs the docs/ folder to my server, into the folder mounted as /content.
  4. tidydoc sees the new files and re-indexes them.

That's it. I never restart the container. I never rebuild an image.

Here is a simplified version of the action:

name: Sync docs on: push: branches: [main] paths: ["docs/**"] jobs: sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Sync docs to the server run: | mkdir -p ~/.ssh echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts rsync -az --delete docs/ "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/srv/acme/docs/" 

Why does it work without a restart?

Because tidydoc watches the mounted folder. When rsync writes a file, the watcher re-runs the indexing script, and the next page load gets the new content. I'll show you how in the next section.

And since everything is plain Markdown in a repo, AI agents are first-class editors.

I can ask Claude Code to "document the new webhook events", review the diff in a pull request, and merge. The docs are live a few seconds later.

No backend, no build: how it works

The core of tidydoc is a small image: nginx and a few shell scripts.

Here is everything that happens when the container starts:

container start ├─ entrypoint.sh → config.js (whitelisted env vars) ├─ generate.sh → content.json, /_md/<route>.md symlinks, │ one HTML per page, sitemap.xml, robots.txt, llms.txt ├─ inotifywait → re-runs generate.sh when /content changes └─ nginx → static Vue 3 + Vuetify app, /content/* assets, raw /<route>.md 

entrypoint.sh — Reads your environment variables and writes them into a config.js file for the browser. Only whitelisted variables are copied, so nothing else from your environment leaks to the client.

generate.sh — Walks the /content folder and packs every .md file and every _meta.json into one content.json. It also creates the symlinks that serve the raw Markdown of each page.

inotifywait — Watches the folder and re-runs generate.sh when a file changes. That's the whole "live reload", and it's what makes the git push workflow possible: the container never needs to know where the files come from. On Docker Desktop (macOS / Windows), file events don't always cross bind mounts, so you can switch to polling with WATCH_POLL_INTERVAL.

nginx — Serves a pre-built Vue 3 + Vuetify app, your assets, and the raw Markdown files.

Everything else happens in the browser

The browser loads content.json once. Then it:

  1. Builds the navigation from the folder tree.
  2. Renders Markdown with markdown-it and highlights code with Shiki.
  3. Indexes the full-text search with MiniSearch.

You have many options for search: hosted search services, or a search engine running next to your app.

They are perfect when you have millions of documents.

But in my case, I don't need a "real" search engine.

A docs site fits in one JSON file, so the search index is built on the client and opens instantly with ⌘K.

The SEO problem with single-page apps

This was the tricky part.

A single-page app has one index.html. Crawlers and link previews (Slack, X, LinkedIn) don't run JavaScript, so every page looks the same to them.

So generate.sh uses a jq script to write one HTML file per page, with its own <title>, description, canonical URL, Open Graph and Twitter tags.

It also generates sitemap.xml and robots.txt, and unknown URLs return a real 404.

Still no server code. Just files that nginx serves.

A few things I'm proud of

Components with plain Markdown

No MDX, no JSX. Callouts, cards and tabs use a simple container syntax:

::: tip Pro tip A `tip` for best practices. ::: ::: tabs @tab npm npm install @acme/sdk @tab pnpm pnpm add @acme/sdk ::: 

Code blocks are highlighted for 200+ languages, and you can add a title after the language.

LLM friendly

Every page is also available as raw Markdown: just add .md to its URL.

tidydoc also serves an /llms.txt file that lists every page with a link to its Markdown.

Each page has "Copy Markdown" and "Open" buttons, so your users can send it straight to ChatGPT or Claude.

One image, many products

tidydoc is stateless. Run one container per product, each with its own folder and colors:

services: docs-acme: image: ghcr.io/tidydoc/tidydoc:latest volumes: ["./acme-docs:/content:ro"] environment: SITE_TITLE: Acme COLOR_PRIMARY: "#6d28d9" docs-globex: image: ghcr.io/tidydoc/tidydoc:latest volumes: ["./globex-docs:/content:ro"] environment: SITE_TITLE: Globex COLOR_PRIMARY: "#059669" 

No volumes on your platform? Bake the content into your own image:

FROM ghcr.io/tidydoc/tidydoc:latest COPY docs /content 

Analytics, off by default

Set POSTHOG_KEY and tidydoc sends page views, search queries and "Copy Markdown" clicks to PostHog.

Without it, the PostHog library is not even downloaded and nothing leaves the browser.

What it doesn't do (yet)

I want to be honest about the trade-offs:

  • It's a single-page app. Titles and meta tags are real HTML, but the page content is rendered with JavaScript. Google indexes it, some crawlers don't.
  • It must be served at the root of a domain (docs.acme.com), not under a sub-path (acme.com/docs).
  • There is no versioning and no i18n yet.
  • Raw HTML in Markdown is rendered as-is, so only serve content you trust.

For HTTPS, put it behind your usual reverse proxy (Caddy, Traefik, nginx). The docs have a ready-to-copy snippet for each.

Help me out 🤗

tidydoc is 100% free and open-source (MIT).

It's a new project, and I'm building it on my own.

Versioning and i18n are next on my list. Tell me in the comments what you need, it will shape the roadmap.

And if you like the idea, a star would help me more than I can describe ❤️

⭐ Star tidydoc on GitHub