Production Server (docmd serve)
docmd serve builds your site once and serves it from a Node.js process. It is the production counterpart of docmd dev, like preview in Vite or start in Next.js. Use it behind a reverse proxy, in a container, or anywhere a static host is not enough, for example when a plugin needs to write to the site.
For a purely static site, docmd build and any static host (NGINX, Caddy, Vercel, …) remain the simplest option.
npx @docmd/core serve [--host <address>] [--port <number>] [--config <path>]
How it differs from dev
docmd dev |
docmd serve |
|
|---|---|---|
| Build | Development build, rebuilt on every change | Production build, once at startup |
| File watcher, live reload | Yes | No |
| Opens a browser | Yes | No |
| Port in use | Moves to the next port | Exits with code 1 |
| Default bind address | 127.0.0.1 |
127.0.0.1 |
| Plugin actions | On, for pages served by the dev server | Off unless serve.actions enables them |
| Health endpoint | No | GET /healthz |
On startup serve binds the port first, then clears the output directory and builds into it. Until that build finishes every request gets 503 Service Unavailable with Retry-After: 1. It refuses to start if the output directory contains the project root or the source directory, because it would have to delete them. Workspaces are not supported yet.
SIGTERM and SIGINT shut it down gracefully: open requests get up to 4 seconds to finish, then the process exits with code 0. docmd stop stops serve processes too.
Host and port
The address is resolved in this order, first match wins:
--hostand--porton the command line;- the
DOCMD_HOSTandPORTenvironment variables; serve.hostandserve.portin your config;- the defaults,
127.0.0.1and3000.
To accept connections from other machines or from outside a container, bind 0.0.0.0:
npx @docmd/core serve --host 0.0.0.0 --port 8080
Configuration
All of serve is optional. These are the defaults:
{
"serve": {
"host": "127.0.0.1",
"port": 3000,
"actions": { "enabled": false, "origins": [], "identityHeader": null },
"mcp": { "enabled": false, "origins": [] }
}
}
| Key | Default | Description |
|---|---|---|
host |
127.0.0.1 |
Interface to bind. |
port |
3000 |
Port to listen on. |
actions.enabled |
false |
Accept plugin actions over WebSocket on the same port. Only the value true turns them on. |
actions.origins |
[] |
Full origins allowed to open the action socket, e.g. https://docs.example.com. Required when actions are on. |
actions.identityHeader |
null |
Request header set by your authenticating reverse proxy. Without it, every write from an action is refused. |
mcp.enabled |
false |
Serve the MCP tools over HTTP at POST /mcp. Only the value true turns it on. |
mcp.origins |
[] |
Full origins allowed to call /mcp from a browser. |
build and dev ignore the serve block. serve checks it at startup and exits with code 1, listing every problem, when an origin is not a full http(s) origin (* is not accepted), when actions are on without origins, or when identityHeader is not a valid header name (or is cookie).
The startup banner shows what is on:
Local Access http://127.0.0.1:3000
Serving from ./site
Actions on (origins: https://docs.example.com; writes need x-auth-request-email)
MCP on at POST /mcp (read-only; no browser origins)
Endpoints
| Path | Description |
|---|---|
GET/HEAD /healthz |
200 with {"status":"ok","version":"<docmd version>"} once the site is built; 503 before. Use it for container and load-balancer health checks. |
POST /mcp |
MCP over HTTP, when serve.mcp.enabled is true. Otherwise /mcp is an ordinary path of your site. |
| WebSocket upgrade | Plugin actions, when serve.actions.enabled is true. Otherwise 404. |
| Everything else | Your built site, from the output directory. |
Plugin actions
Plugins that declare requiresLiveServer and register actions (for example Threads) only work while a server hosts them. Under dev that is always the case. Under serve you turn it on:
{
"serve": {
"actions": {
"enabled": true,
"origins": ["https://docs.example.com"],
"identityHeader": "x-auth-request-email"
}
}
}
With actions on:
- The site is built with the client assets of live-server plugins and the
docmd-api.jsbridge, which connects back overwss://when the page is served over HTTPS. - A WebSocket upgrade must carry an
Originlisted inorigins. Any other origin, or none, gets403. List the public origin your readers use, not the internal address of the server. - Reads, worker tasks and rebuild requests are open to every accepted connection.
- Writes through
ctx.writeFileandctx.sourceneed a trusted identity: they are refused unlessidentityHeaderis set and the WebSocket upgrade request carried that header with a non-empty value. The value reaches the plugin asctx.caller.identity. - Files written by an action are rebuilt and announced to every client with a
system:rebuiltevent.servenever reloads the page for you.
If a plugin fails in its onServerReady hook, serve exits with code 1 and listens on nothing.
Identity behind a reverse proxy
docmd has no login of its own. identityHeader names a header that an authenticating reverse proxy adds to each request, such as X-Auth-Request-Email from oauth2-proxy or Cf-Access-Authenticated-User-Email from Cloudflare Access.
docmd cannot tell who set the header. It is only trustworthy when:
- the proxy sets it on every request it forwards, and removes any copy the client sent;
- clients cannot reach the
serveport without going through the proxy (bind127.0.0.1, or keep the port on a private network).
If either is not true, anyone can write as anyone. Leave identityHeader unset and writes stay refused.
MCP over HTTP
serve.mcp.enabled: true exposes the MCP server tools at POST /mcp, so AI agents can search and read the deployed docs without a local checkout.
{
"serve": {
"mcp": { "enabled": true }
}
}
- Read-only. The tools are the same as
docmd mcp, but nothing over HTTP writes to disk:get_llms_contextreturns thellms-full.txtof the site being served instead of rebuilding it. - Same config as the server. The tools read the file
servewas started with (--configincluded), andread_doconly returns Markdown inside its source directory, never pages matched byexclude. - Origin rule. A request that carries an
Originheader, which every browser sends on aPOST, must matchmcp.origins, or it gets403. A request withoutOrigin, from an MCP client that is not a browser, is accepted. This stops other websites (and DNS-rebinding attacks) from calling the tools through a reader’s browser. - Transport. Stateless MCP over HTTP: one JSON response per
POST, batches allowed, no sessions and no event stream. Bodies are limited to 1 MiB; methods other thanPOSTget405.
The endpoint has no authentication of its own. If your docs are private, protect /mcp with the same reverse proxy as the rest of the site.
Connecting a client, for example Claude Code:
claude mcp add --transport http docmd https://docs.example.com/mcp
Docker
The official Docker image runs docmd serve --host 0.0.0.0 by default, and its health check calls /healthz.