Hosting
Self-host documentation with Docker and Node.js
A Docker image that serves your docs and MCP endpoint from Node.js, run with Compose behind Caddy, with a health check and tests for pages and discovery files.
By Hayden Bleasel9 min read

To self-host your docs in Docker, build them with Blume's node() deployment adapter, which turns the site into a standalone Node.js server. Then ship that server in an image together with the project's installed node_modules, and start it with HOST and PORT set. One process serves every page, the MCP endpoint at /mcp, and the discovery files agents read to find it.
By the end, you have a two-stage Dockerfile, a Compose file that runs the docs behind Caddy for HTTPS, a health check, and a few commands that prove a page, the MCP endpoint, and the discovery files all work from the container. The example is an Acme docs site at docs.acme.example.
You only need a server for request-time features: the MCP server, the built-in assistant, the API playground proxy, and Mixedbread search. Without them, a static build is plain files that any web server can serve, and the last section shows that container instead. If you'd rather not run a server at all, see Deploy documentation and an MCP endpoint to Cloudflare Workers.
Static container or server build
Both kinds of container start from the same project. What changes is what the build produces, and so what the image has to hold:
| Static-file container | Node server container | |
|---|---|---|
| Config | deployment: { site } | deployment: node({ site }) |
| Image holds | dist/ only | dist/ and node_modules/ |
| Runs | Any web server, like nginx | node dist/server/entry.mjs |
Pages, search, llms.txt, .md copies | Yes | Yes |
| MCP server and assistant | No: the build fails | Yes |
| Redirects | Redirect pages, or rules you write | Answered with the configured status |
| Discovery file headers | Yours to configure | Set by Blume |
The rest of this guide builds the server container. The static or server-rendered guide covers the choice in more depth.
Build with the Node adapter
Import node from blume/deploy, give it your public URL, and turn on the MCP server:
import { defineConfig } from "blume";
import { node } from "blume/deploy";
export default defineConfig({
title: "Acme Docs",
deployment: node({ site: "https://docs.acme.example" }),
agents: {
mcp: { enabled: true },
},
});Set site here, because a Node server has no platform environment for Blume to read it from. It's baked in at build time: the MCP discovery files, the Connect to MCP menu, the sitemap, and canonical URLs all use it. Moving to another domain means building a new image.
Build it and start the server on your machine before you containerize:
npm run build
PORT=8080 node dist/server/entry.mjsThe build summary names the node adapter, your site URL, and the MCP server. Open http://localhost:8080 to check a page, then stop the server. npx blume preview serves the same build if you prefer.
What the server needs at runtime
A Node server build writes two halves into dist/:
dist/client/holds the prerendered pages, assets, search index,llms.txt, and the.well-knowndiscovery files.dist/server/holds the server. With the MCP server on,entry.mjsis a small Blume wrapper that sets the discovery files' media types and CORS headers and answers redirects, with Astro's own entry moved beside it asastro-entry.mjs.
The server doesn't bundle every package it uses. Blume gives dist/server its own node_modules of relative links, one per package the server imports, each pointing at the copy in your project's node_modules. So dist/ alone won't start: ship it with the node_modules it was built against, in the same layout. Keep dist/client and dist/server side by side, too, since the server finds its static files relative to itself.
Your Markdown, blume.config.ts, and .blume/ can stay out of the final image. The MCP server answers from a snapshot of your pages bundled into the server at build time.
Write the Dockerfile
First, keep your local build output, dependencies, and secrets out of the build context. Dependencies installed on macOS or Windows carry native binaries for that system, like sharp's, so they must never reach a Linux image:
node_modules
.blume
dist
.git
.env*
npm-debug.log*The Dockerfile installs and builds in one stage, then copies the dependencies and the build into a clean runtime stage. Both stages use the same base image, so the native binaries npm ci installs match the system that runs them:
# syntax=docker/dockerfile:1
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8080
COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + '/').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
CMD ["node", "dist/server/entry.mjs"]A few lines matter more than they look:
HOST=0.0.0.0. The server listens onlocalhost:4321unlessHOSTandPORTare set when it starts. Inside a container,localhostcan't be reached from outside, andhostorportpassed tonode()have no effect.- The same
/apppath in both stages. The links indist/server/node_modulesare relative, so they resolve as long asdistandnode_moduleskeep their places beside each other. NODE_ENVonly in the runtime stage. Set in the build stage, it would makenpm ciskip dev dependencies the build might need.CMDrunsnodedirectly. The Node.js image's best practices recommend this overnpm start, which can swallow the stop signal.- The health check uses
fetch. Slim Node images don't includecurl, and Node 24 hasfetchbuilt in. It requests the home page, which is enough to show the process is up and serving.
The example uses npm. With another package manager, install from its lockfile in the build stage instead; the runtime stage stays the same, since it copies node_modules exactly as installed.
Run it with Compose and HTTPS
The Node server speaks plain HTTP, so put a reverse proxy in front for TLS. Caddy gets and renews certificates on its own, which keeps the setup to one short file:
docs.acme.example {
reverse_proxy docs:8080
}services:
docs:
build: .
image: acme-docs
init: true
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
env_file:
- path: .env.production
required: false
caddy:
image: caddy:2.11
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
docs:
condition: service_healthy
volumes:
caddy_data:
caddy_config:init: true runs a small init process as PID 1, as the Node.js image's best practices recommend, since Node wasn't designed to run as PID 1. The docs port is published on 127.0.0.1 only, so you can run the checks below from the host while the public goes through Caddy. The caddy_data volume keeps certificates across restarts. Caddy waits for the docs container's health check to pass before it starts.
Pass secrets at runtime
The MCP server needs no secrets. If you also turn on the assistant, its key is read when a request arrives, not at build time, so it belongs in the container's environment, never in the image. Put it in .env.production beside compose.yaml, and keep that file out of Git:
AI_GATEWAY_API_KEY=replace-with-your-keyThe Docker build then warns with BLUME_MISSING_SECRET, because the key isn't there while the image builds. That's expected: the warning exists for keys that only live in the deploy environment. The environment variables table lists the variable each adapter reads.
Test a page, the MCP endpoint, and discovery
Build the image and start both services:
docker compose up -d --build
docker compose psOnce the first health check passes, the docs service's status shows (healthy). Run the rest of these on the server itself, where port 8080 is published. First, a page:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/That should print 200. Next, ask the MCP endpoint for its tools. The endpoint takes JSON-RPC over POST, and the request must accept both JSON and event streams:
curl -s http://127.0.0.1:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'The response is JSON whose result.tools lists search_docs, get_page, list_pages, and get_navigation. This request loads the server-rendered route and the MCP SDK, so it also proves the server's dependencies resolve.
Then check the discovery files agents use to find the endpoint:
curl -s http://127.0.0.1:8080/.well-known/mcp.json
curl -sI http://127.0.0.1:8080/.well-known/api-catalogIn mcp.json, the server's url should be https://docs.acme.example/mcp, your public address, never localhost. The API catalog's headers should include application/linkset+json as its content type and Access-Control-Allow-Origin: *. The standalone server's static file handler sets neither on its own, so seeing them confirms Blume's entry wrapper is in front of it.
Last, check the links the build generated inside the image:
docker compose exec docs ls dist/server
docker compose exec docs find dist/server/node_modules -xtype lThe first should list both entry.mjs and astro-entry.mjs. The second lists broken links, so it should print nothing. When DNS for your domain points at the server, run the discovery check against the public URL too, then connect an agent:
claude mcp add --transport http acme-docs https://docs.acme.example/mcpRun it in production
- Rebuild to publish. Pages, the search index, and the MCP snapshot are all baked into the image. After a content change, run
git pullanddocker compose up -d --buildagain. - Read the logs.
docker compose logs -f docsshows the server's output, including provider errors from the assistant. - Keep it private at the proxy. Blume has no sign-in of its own, so add authentication in front, as Private docs describes, or publish the container through a Cloudflare Tunnel behind Cloudflare Access. Agents outside it can't reach the MCP endpoint either.
- Limit the assistant at the proxy. Blume's rate limit counts requests per reader address, but behind Caddy the Node server sees Caddy's address on every request, so all readers share one count. If you turn on the assistant, limit requests in front of the server and set
rateLimit: false. The MCP endpoint isn't rate limited.
Serve a static build instead
If you don't need the MCP server or the assistant, drop them, and use the plain deployment object instead of node():
deployment: {
site: "https://docs.acme.example",
},dist/ is then the whole site, and the runtime stage is a web server with no Node and no node_modules:
# syntax=docker/dockerfile:1
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.30-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/htmlserver {
listen 80;
root /usr/share/nginx/html;
charset utf-8;
error_page 404 /404.html;
location / {
try_files $uri $uri/index.html $uri.html =404;
}
location ~ \.mdx?$ {
types { }
default_type "text/markdown; charset=utf-8";
}
}try_files serves /guide from guide/index.html without redirecting to a trailing slash, which matches Blume's slashless URLs. nginx has no type for .md files, so the second block serves the Markdown copies agents read as text/markdown. Redirects work as redirect pages. For real HTTP redirects, turn dist/blume-redirects.json into nginx rules; dist/_headers lists the other headers Blume sets on hosts that read it.
Troubleshooting
The container is up, but nothing answers on the port
The server is listening on localhost inside the container. Check that HOST=0.0.0.0 is in the runtime stage's environment, and that the published port matches PORT. The server logs the address it listens on when it starts.
The server can't find a package
An ERR_MODULE_NOT_FOUND error at startup, or a 500 on the first /mcp request, means the server's dependencies aren't where its links point. Ship node_modules with dist/, keep both under the same directory, and run the find check above to list any broken link.
sharp or another native module fails to load
The image is running binaries built for a different system. Make sure .dockerignore excludes node_modules, so the dependencies are installed inside the build stage, and that both stages use the same base image.
The MCP endpoint answers 406 or 405
A 406 means the request's Accept header doesn't list both application/json and text/event-stream. A 405 means it was a GET: the endpoint takes POST only, so a browser visit to /mcp gets a 405 even when everything works.
The discovery files point at the wrong address
They're written from site at build time. Without it, mcp.json holds a relative /mcp, the server card has no remote address, and the Connect to MCP menu doesn't appear. Set site in node() and rebuild the image.
The assistant answers 503
The response reads "The assistant is not configured", naming the variable it needs. The key isn't in the container's environment: check that .env.production sets it, then recreate the container with docker compose up -d.
Next step
Connect an agent to your docs
Once your domain reaches the container, add its MCP endpoint to Claude Code, Cursor, or VS Code, and see what else the server offers agents.
Read the MCP server guideA step here not working for you? Report a broken step.