DEV Community

John
John

Posted on

Private Docusaurus: What Actually Gates the Site, and What Still Leaks Through sitemap.xml, Search and Source Maps

Docusaurus has no login, no user table and no server-side session, because a production build is a folder of static HTML, JavaScript and JSON that any web server can hand out. Every gate you add lives in front of it: HTTP basic auth, a forward-auth proxy, or an access rule on the static host. That gate is genuinely effective, since an unauthenticated request never reaches a single file. The real risk is not the gate failing, it is the build itself, because sitemap.xml, the prebuilt search index and unstripped source maps each contain a readable copy of what you thought you were hiding, so one misrouted path or one cached response leaks the whole set at once.

TL;DR by reader profile:

  • Family running a shared household wiki (passwords, router notes, insurance scans in a Docusaurus site on a home server): use a forward-auth proxy with real accounts, not one shared basic-auth password, because a single credential cannot be revoked for one person only.
  • Solo maintainer with a private notes site (one person, one laptop, one VPS): HTTP basic auth over HTTPS is proportionate, provided you disable the sitemap plugin and ship no source maps.
  • Small team with an internal handbook (8 to 20 people, staff turnover): forward auth against an identity provider, so account removal is one action and not a redeploy of a password file.
  • Consultancy under a client NDA (contract requires access logs and named users): forward auth plus per-request logging, since basic auth gives you no reliable identity in the access log beyond a shared username.
  • Open source project with a private staging site (public docs plus an unreleased version branch): keep the private content in a separate build entirely, because noIndex and unlisted pages hide pages from indexes, not from anyone who requests the URL.

The central tradeoff: basic auth costs one configuration block and no moving parts but gives you one shared secret and no revocation, while forward auth gives you named accounts, logout and audit trails at the cost of running an identity service that must stay up for anyone to read a single page.


Table of contents


What does Docusaurus actually serve, and why is there nothing to log into?

Run npm run build and Docusaurus 3.x writes a build/ directory. That directory is the entire application. There is no PHP process, no database connection string, no session store and no user table, so there is no code path where a password could be checked. The React you wrote is prerendered to HTML at build time and rehydrated in the browser, which means the server's only job is to return files.

What the build directory contains:

  • Prerendered HTML per route: every doc page exists as a complete index.html on disk, readable with curl and no JavaScript engine at all.
  • A client JavaScript bundle: the hashed assets/js/ files that hydrate the page, plus a runtime manifest listing every route the site knows about.
  • Static assets copied verbatim: anything you dropped in static/ is served at the site root, including PDFs and images you may have forgotten.
  • Generated metadata files: sitemap.xml from @docusaurus/plugin-sitemap, robots.txt, RSS and Atom feeds from the blog plugin, and search index JSON if you use a local search plugin.
  • No server-side configuration: docusaurus.config.js runs during the build, not at request time, so it cannot make an access decision about an incoming request.

The practical consequence is blunt. npx docusaurus serve and python3 -m http.server are equally unauthenticated, and so is Nginx, Caddy or an S3 bucket pointed at the same folder. Every access control you get comes from the layer in front of those files, never from Docusaurus itself.


Where can you put the authentication gate in front of a Docusaurus build?

There are four layers where a request can be stopped, and you should pick exactly one as the authoritative gate. Two overlapping gates usually mean one of them is misconfigured and nobody notices.

The web server that serves the files: Nginx auth_basic with an htpasswd file, or a Caddy basic_auth directive, checks credentials before it opens build/index.html.

A reverse proxy in front of that server: Traefik, Caddy or Nginx running auth_request against a forward-auth service such as Authelia or oauth2-proxy, which returns 200 or 401 for every path including /sitemap.xml.

The static hosting platform: Netlify password protection, Cloudflare Access or an S3 bucket policy, decided outside your repository and outside your build.

The network boundary: a WireGuard or Tailscale tunnel, where the site listens on a private address and no gate exists at HTTP level at all.

Where that stack physically runs is a separate decision: a rented VPS, a home server or NAS on your own connection, or a managed box. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. On any of those, the gate still belongs to the proxy or web server, because Docusaurus contributes nothing.

One warning about the development server. npm start binds port 3000 with hot reload, no gate and no build step, and it happily serves your unfinished private pages to anything that can reach that port. Never expose it, even briefly, and never treat it as a preview environment for people outside the household or team.


Is HTTP basic auth enough for a private Docusaurus site?

For one person, yes. For a household or a team, it degrades badly, and the failure is social rather than cryptographic. Over HTTPS the credential is protected in transit, so the weaknesses are all about lifecycle.

The password is shared, so revocation is all or nothing: removing one person's access means changing the single entry in .htpasswd and telling everyone else the new password, which in practice nobody does.

Hashing depends on how you generated the file: run htpasswd -B -c /etc/nginx/.htpasswd alice to force bcrypt, because the default on older htpasswd builds is MD5 based crypt, and Nginx will accept both without warning you.

There is no logout: the browser caches the credential for the origin until the tab or the browser is closed, so a family laptop left open stays authenticated for anyone who picks it up.

Access logs give you no real identity: every request carries the same username, so a log line proves someone had the password, not who.

Automation gets awkward: any script, RSS reader or link checker now needs the credential embedded, and curl -u user:pass https://docs.example.com/sitemap.xml in a shell history file is a common way for it to escape.

Prompts confuse non-technical readers: the native browser dialog offers no branding, no password reset and no explanation, which generates support requests from the exact people you set the site up for.

Use basic auth when the reader count is 1 to 3 and stable. Above that, the shared secret becomes the weakest part of the system.


Basic auth against forward auth against static host rules: what each one costs

The three approaches differ less in strength than in what they demand from you every month after setup. Compare them on operational cost, not on theoretical security.

Approach What it costs to run Where it breaks
Web server basic auth (Nginx auth_basic, Caddy basic_auth) One config block and one .htpasswd file, no extra process, no extra memory Shared secret, no logout, no per-person revocation, credential leaks into scripts
Forward auth (Authelia or oauth2-proxy behind Traefik, Caddy or Nginx auth_request) An extra container plus its config and session storage, and a second thing to upgrade If the auth service is down, every page returns 401 or 502, including your own recovery notes
Identity provider SSO (Cloudflare Access, Google or GitHub OIDC) No local user database, but a hard dependency on a third party for every page load Your reader list lives outside your server, and access decisions are logged elsewhere
Network gate only (WireGuard, Tailscale) Client software on every device, including phones and a television browser No gate once a device is on the network, so a borrowed laptop reads everything
Static platform password (Netlify, Vercel) Zero server administration One password per site, and the build output sits on a shared multi-tenant platform

Your hosting choice cuts across this table rather than replacing it. A rented VPS, a home server, a NAS or a Yundera instance can each run any of the first four rows, since all of them are just containers and a proxy in front of a folder. Pick the row you will still maintain in twelve months.


What ends up in the build folder that you did not expect to publish?

Before you trust the gate, audit what is behind it. The reliable method is to grep the output rather than to reason about the config. Run grep -ri "your-secret-string" build/ after every build, and add it to your deploy script.

Anything in customFields: values you put under customFields in docusaurus.config.js are serialised into the client bundle so useDocusaurusContext() can read them, which means an internal API host or a support email is now a string in assets/js/.

Environment variables you inlined: only variables the build actually references get baked in, but once referenced they are literal text in the bundle, and a .env file loaded at build time is not a secret store.

Author and contributor metadata: authors.yml addresses, plus last update author and timestamp when showLastUpdateAuthor is enabled, expose real names and a work rhythm you may not want visible.

Files you forgot in static/: everything in that directory is copied to the site root untouched, so an old invoice PDF or a screenshot with a token in the URL bar ships with the site.

Broken link and draft residue: pages you unlinked from the sidebar are still built and still reachable at their route, because onBrokenLinks only checks links, not orphan pages.

Comments in your MDX: HTML comments in Markdown survive into the prerendered HTML, so a note like <!-- ask legal before publishing --> is served verbatim.

None of this is a Docusaurus defect. It is a static site doing exactly what you told it to do, and the audit is yours to run.


How much does sitemap.xml leak, and should you disable the plugin?

@docusaurus/plugin-sitemap ships inside @docusaurus/preset-classic, so a default site publishes /sitemap.xml whether or not you asked for it. That file is a complete route inventory in plain XML. If your gate ever misfires on one path, an attacker does not have to guess URLs, because you handed over the full list in one request.

It enumerates every route, not every page you linked: orphan pages that no sidebar points at still appear, which is exactly the content people assume is hidden.

Route names carry meaning on their own: /docs/finance/mortgage-2029-refinance tells a reader most of what they wanted before they read a word of the page.

It is the first path any scanner requests: sitemap.xml and robots.txt sit at the top of every wordlist, so this is not an obscure corner of the site.

Blog feeds duplicate the problem: /blog/rss.xml and /blog/atom.xml include post titles and, depending on your feed options, the full body text.

Disabling it is one line: set sitemap: false in the preset-classic options in docusaurus.config.js, or use the plugin's ignorePatterns to exclude specific route globs while keeping a sitemap for a public subset.

For a fully private site, disable the sitemap and the blog feeds outright. You gain nothing from them, since no search engine should be crawling a gated site anyway. For a mixed site with public and private areas, do not rely on ignorePatterns as a security control, because it hides routes from a file, not from anyone who requests them directly.


Does the prebuilt local search index expose the full text of your docs?

Yes, and this is the leak people underestimate. Offline search plugins such as @easyops-cn/docusaurus-search-local build an index at compile time and ship it as static JSON in the build/ output, because the search runs in the reader's browser with no server involved. That index is not a list of keywords. Depending on the plugin's configuration it contains headings, section titles and enough surrounding body text to render a preview snippet.

One request returns the corpus: fetching the index file gives a reader the searchable content of every indexed page without visiting a single page URL.

It survives route level rules: if you blocked /docs/private/ at the proxy but left the index file open, the content of those pages is still in the JSON.

Check it yourself: run ls -la build/search-index*.json or grep the build output for a phrase that only appears on a sensitive page, then confirm which path serves it.

Hosted search moves the copy, it does not remove it: Algolia DocSearch keeps your content on Algolia's infrastructure, and the search-only API key travels in the client bundle, so a gated site with hosted search has published its text to a third party by design.

The safe configurations are narrow. Either gate the whole origin so the index file is behind the same 401 as everything else, or drop search entirely on the private portion and keep it for the public one. Splitting a mixed site into two builds is usually less work than proving that one index contains nothing sensitive, and it stays true after the next twenty pages get written.


Are source maps in a Docusaurus production build a real risk?

Lower than the search index, higher than zero, and the honest answer is that you should check rather than assume. Verify with grep -r sourceMappingURL build/ and find build -name "*.map". If both come back empty, this section costs you nothing. If they do not, decide what those files contain before you ship them.

A docs site leaks source, not documents: your page content is already in the HTML behind the gate, so a map mostly reveals JSX, not text a reader could not otherwise reach once authenticated.

Swizzled components are the exception: ejected theme components under src/theme/ often carry internal hostnames, feature flags and comments like a note about which client the layout was copied from.

Custom React pages carry logic: a src/pages/ component that calls an internal API embeds that endpoint, and the map restores the original variable names that minification had obscured.

Maps enlarge the transfer: .map files are typically several times the size of the minified bundle, which matters on a home connection serving a family from a residential upload link.

Third party maps come along for free: dependencies sometimes publish their own maps, so a clean project can still emit files you never wrote.

The control is the same either way. Configure configureWebpack in a small local plugin to set devtool: false for production, then re-run the two commands above to confirm. Do not rely on the proxy to block *.map by pattern, because a rule that lists extensions is one new file type away from being wrong, and generating nothing is simpler than filtering everything.


Should unauthenticated visitors get 401, 403 or 404?

The status code decides how much a stranger learns from a single request. It also decides whether your own tooling behaves, so pick deliberately rather than accepting the proxy default.

Response What it tells an unauthenticated visitor When it is the right choice
401 with WWW-Authenticate: Basic A site exists here and it wants a password, and the browser shows the native dialog Basic auth for 1 to 3 known readers who expect the prompt
302 to an identity provider A site exists, plus the name of your login service in the redirect target Forward auth, where readers need a real login form and a logout
403 with a static body Something exists and you are not welcome, with no way to authenticate from the browser Blocking a subset of paths for readers who are already logged in
404 for every gated path Nothing here, which hides the site from casual scanners Sites you want absent from view, at the cost of confusing legitimate readers

One Docusaurus specific detail matters. Your build contains build/404.html, a branded page with your site title and navbar. If the proxy passes unauthenticated requests through and lets Docusaurus answer, a stranger sees your site name and menu structure on what you thought was a blank refusal. Configure the error page at the proxy instead, and test with curl -i https://docs.example.com/nonexistent to see which layer answered.

Prefer 401 or a redirect for a household site. Silent 404s generate the phone call that starts with a family member insisting the link is broken.


Which cache and CDN headers decide whether a private page gets stored publicly?

A gate that returns the right page to the right person still fails if something between you and the reader keeps a copy. Docusaurus produces content-hashed filenames under assets/, which invites aggressive caching, and that default is written for public documentation sites.

Cache-Control: private: permits the browser to store the response but forbids any shared cache from doing so, which is the minimum for every HTML route on a gated site.

Cache-Control: no-store for HTML: prevents the response being written to disk at all, worth using on a shared family laptop where the browser profile is not per person.

Vary: Cookie or Vary: Authorization: tells intermediaries that the response depends on credentials, so a cached copy is not served to the next requester who lacks them.

CDN cache rules override your headers: Cloudflare and similar platforms can cache by file extension regardless of origin headers, so check the rule set rather than trusting Cache-Control alone.

Hashed assets are still content: an assets/js/ chunk contains prerendered strings from your pages, so caching it publicly leaks text even when the HTML route is protected.

Search engine caches are permanent enough: anything crawled while the gate was off can persist in third party copies long after you fix the configuration.

Verify with one command per route class: curl -sI https://docs.example.com/docs/intro | grep -i "cache-control\|vary", then repeat against a hashed asset path. If the two disagree, the asset is usually the one set to a long public lifetime, and that is the copy that outlives your mistake.


How do you keep drafts, unlisted pages and old doc versions out of the build?

The distinction that trips people up is between not built and not listed. Only one of them removes content from the folder a stranger could reach.

draft: true removes the page from production: a Markdown file with that front matter is skipped by npm run build while still appearing in npm start, so the route does not exist in the output at all.

unlisted: true keeps the page and hides the signposts: the page is built and served at its URL, excluded from the sidebar, sitemap and search index, and marked with a banner, which is obscurity rather than access control.

Underscore prefixes exclude by convention: the docs plugin ignores files and folders starting with _ by default, which is how partials stay out, and it works for a _scratch/ directory too.

exclude patterns give you explicit globs: set them in the docs or blog plugin options in docusaurus.config.js when a naming convention is not enough.

onlyIncludeVersions and includeCurrentVersion: false: versioned sites otherwise build every folder under versioned_docs/, so an internal draft of the next version ships alongside the released one.

Deleted content lives on in Git: removing a page from docs/ does not remove it from history, so anyone with repository access can read it even after the site stops serving it.

Audit the result rather than the intent. Run find build -name "*.html" | wc -l and compare it against the pages you expect, then spot check the paths you assumed were gone. A count that exceeds your mental model by twenty files usually means a versioned folder you forgot.


What breaks in Docusaurus once it sits behind an auth proxy?

A static site behind a gate hits problems no public docs site ever sees, and most of them appear only after a session expires rather than on the day you set it up.

Client side navigation fails silently: after the first page load, Docusaurus fetches JSON and JavaScript chunks for the next route, so an expired session returns a login redirect where a chunk was expected and the reader gets a blank pane or a chunk load error instead of a prompt.

baseUrl and proxy paths must agree: if the proxy mounts the site at /docs/ but docusaurus.config.js still sets baseUrl: '/', every asset resolves one level wrong and the page renders unstyled.

trailingSlash causes redirect loops: a proxy rule that appends a slash while the config strips it, or the reverse, produces a cycle that ends in a browser error rather than a 401.

Hosted search stops working: the Algolia crawler cannot reach a gated site, so DocSearch returns nothing and you must move to a local index or drop search.

Link previews disappear: Slack, Discord and Signal unfurl by fetching the URL unauthenticated, so a shared link shows the login page title or nothing.

The PWA plugin can cache the wrong response: a service worker that stores a 302 or a login page will keep serving it after you fix the session.

External CI link checkers fail: any pipeline step that crawls the published site needs credentials, and embedding them there recreates the leak you closed.

Test the expiry path deliberately: log in, wait for the session to lapse, then click an internal link.


Which gate should each profile choose?

Profile Recommendation Main reason
Family of 4 sharing household records Forward auth with one account per adult Revoke one person without changing everyone's password
Solo maintainer, private notes Basic auth with a bcrypt .htpasswd One reader, no lifecycle problem to solve
Team of 8 to 20 with staff turnover Forward auth against an existing identity provider Offboarding is a directory change, not a redeploy
Consultancy under an NDA Forward auth plus per-request access logs The contract needs named users, not a shared secret
Mixed public and private docs Two separate builds on two hostnames One index and one sitemap per audience, no path rules to get wrong
Technical household, all devices managed Network gate via WireGuard or Tailscale No HTTP credential to leak, at the cost of client setup on every device
Site with an existing CDN in front Gate at the CDN, then lock the origin Two gates on different layers drift apart and one goes stale

Next steps by branch:

  1. Choosing basic auth: generate the file with htpasswd -B, set Cache-Control: private on HTML, disable the sitemap, confirm with curl -i that an unauthenticated request returns 401.
  2. Choosing forward auth: stand up the auth service first, protect a throwaway path, verify the expired session path in a browser, only then point it at the docs host.
  3. Choosing two builds: split the content directories, give each its own docusaurus.config.js, and grep the private build for a phrase that must never appear in the public one.
  4. Choosing a network gate: install clients on every device that will read the site, including phones, before you remove any HTTP level gate.

Self-hosting, hosting location and data sovereignty for a private Docusaurus site

A private wiki is a pile of plain files, so the question of who else can read them is decided by where the disk sits and who administers it. A static build makes this unusually clear: there is no vendor database holding your content, only a directory.

Advantages of keeping the build on your own server:

  • No third party copy of the text: the content never leaves your disk, unlike hosted search or a platform preview environment.
  • The access log belongs to you: you can see who requested /docs/finance/ and keep or delete that record on your own schedule.
  • Jurisdiction follows the hardware: a box in the country you live in avoids questions about cross border data transfer for family documents.
  • No platform build step: nothing needs read access to your Git repository, which is where the unreleased and deleted pages live.
  • The gate is yours to configure: proxy rules, session lifetime and status codes are all under your control rather than a provider's defaults.

You can run this on a rented VPS, a home server, a NAS, or a managed box. Yundera is a managed Personal Cloud Server, built on CasaOS, where each app is reachable on a public HTTPS subdomain via NSL.SH mesh routing, so no static IP, no port forwarding and no manual TLS certificate setup are required. A public hostname makes the gate more important, not less, since anyone can reach the door.

Pre-publication checklist:

  • Confirm the origin is not reachable directly, bypassing the proxy.
  • Verify TLS covers every hostname the site answers on.
  • Back up build/ and the content repository separately, and test one restore.
  • Record who holds administrative access to the server and to the auth service.

Top comments (0)