Self-hosting
Run your docs site on your own infrastructure with the papervine CLI — Docker, a Node process, or a container platform, behind your own reverse proxy.
Run your docs site on your own infrastructure with the papervine CLI — Docker, a Node process, or a container platform, behind your own reverse proxy.
The papervine CLI is a production server, not only a previewer. It runs a prebuilt Next.js
app with NODE_ENV=production, so serving a finished site is a first-class command rather than a
repurposed one:
papervine serve ./docs
serve is the same server dev runs — same prebuilt app, same rendering — with two defaults
changed for production: it binds every interface rather than loopback, and it refuses to
scaffold a starter site when it finds no docs.json, because a server that invents content hides
the real problem. Self-hosting is that command plus a reverse proxy in front.
This is an alternative to hosted Papervine, not a lesser version of it. You get the same renderer and the same pages. What you don’t get is the control plane — see What you give up before choosing.
One long-running Node process that reads MDX from a directory and serves HTML.
The renderer ships prebuilt inside the package. There is nothing to compile on your server and no build toolchain to install.
Content is read from disk. There is no Postgres, no object storage, and no migration to run.
This is a server, not a bundle of files. papervine build (static export) is on the
roadmap and not shipped, so a static host is not an option yet.
There is no multi-tenancy here. Two sites means two processes on two ports.
Requires Node 20.9 or newer.
The CLI is a Next.js app, so it deploys to Vercel like any other — the same source, with
output: "standalone" skipped (that mode exists to make the npm tarball relocatable, and Vercel
builds its own output).
Forks papervine/cli and deploys apps/cli, serving the starter site. Replace
examples/starter with your own docs, or repoint PAPERVINE_CONTENT.
Pages render per request, so each view is a function invocation with no caching. That is fine
for a docs site of ordinary traffic and it is not how a static host works — static export
(papervine build) is on the roadmap and is the better fit at scale.
The rest of this page is for running it yourself, which gives you caching, a single long-lived process, and no per-request billing.
The shortest complete deployment. This installs the CLI, copies your docs in, and serves them:
FROM node:22-slim
RUN npm i -g papervine
COPY docs /docs
EXPOSE 3000
CMD ["papervine", "serve", "/docs", "--port", "3000"]
docker build -t my-docs .
docker run -d -p 3000:3000 my-docs
Use a glibc base image (node:22-slim, node:22) rather than Alpine unless you have a
reason to prefer musl. Image optimization comes from sharp,
which ships a compiled binary per platform; npm picks the right one automatically on Debian-based
images. If it can’t be installed the CLI still runs and says so at startup, serving images at
their original size.
serve binds 0.0.0.0 on its own, so a container needs no environment variable to be
reachable. Override it with --host or PAPERVINE_HOST — not HOSTNAME. Docker sets
HOSTNAME to the container id, so a server that read it would bind the container’s own
hostname, and curl localhost:3000 inside the container would be refused while the logs
claimed everything was fine.
Copying content into the image means an image rebuild per docs change. Mounting it means the files are the deploy:
docker run -d -p 3000:3000 -v /srv/docs:/docs:ro my-docs
Pages are read from disk per request, so writing new MDX into that directory publishes it —
no rebuild and no restart. Read-only (:ro) is deliberate: the server never writes to your
content.
No container required. Install the CLI and run it:
npm i -g papervine
papervine serve /srv/docs --port 3000
For a machine that should keep it running, a systemd unit is enough:
[Unit]
Description=Docs site
After=network.target
[Service]
ExecStart=/usr/bin/papervine serve /srv/docs --port 3000
Restart=always
User=www-data
[Install]
WantedBy=multi-user.target
systemctl enable --now docs
The CLI forwards SIGTERM to the server and exits promptly, so systemctl stop and
docker stop shut down cleanly rather than waiting out a kill timeout.
Anything that runs a container and routes to a port works without special configuration — Fly.io, Railway, Render, Cloud Run, ECS, a Kubernetes Deployment. The requirements are the same in each:
serve binds every interface, so a container is reachable with no extra configuration.
dev’s loopback default is correct on a laptop and makes a container unreachable.
Pass --port to match whatever the platform routes to.
There is no dedicated health endpoint. The home page returns 200 once the server is ready, which is what a check should look at.
Platforms that host static files — GitHub Pages, Netlify, Cloudflare Pages, Vercel’s static output — cannot run this. There is no static export yet, so those need a container or a Node runtime instead.
The CLI serves plain HTTP on one port and does no Host routing. Put a proxy in front of it
for certificates, compression, and any host-based routing you need.
server {
listen 443 ssl;
server_name docs.example.com;
ssl_certificate /etc/letsencrypt/live/docs.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/docs.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Caddy is two lines and gets certificates itself:
docs.example.com {
reverse_proxy 127.0.0.1:3000
}
With a proxy handling TLS, pin the server back to loopback — papervine serve ./docs --host 127.0.0.1 — which is the tighter arrangement: only the proxy can reach it.
There is no deploy step. Replace the files:
rsync -a --delete ./docs/ /srv/docs/
The next request renders the new content. Compilation and syntax highlighting are cached, so unchanged pages don’t pay to re-render, and the search index is fingerprinted by your content and rebuilt only when files actually change.
A git pull in the served directory works the same way, which makes a cron job or a webhook
into a complete continuous-deployment setup.
The assistant appears when a model is configured and is absent when one isn’t — so it’s opt-in on a self-hosted site. Pass the same variables you’d use locally:
docker run -d -p 3000:3000 \
-e ANTHROPIC_API_KEY=sk-ant-... \
-e AI_ROUTING=direct \
my-docs
Point PAPERVINE_AI_MODEL at an ollama/ or local/ model to run against your own hardware
instead, paying nobody. Usage on a hosted provider is billed by whoever provides the model — the
CLI has no metering and reports nothing anywhere.
Recipes for Claude, ChatGPT, Gemini, the AI Gateway, Ollama, LM Studio and any OpenAI-compatible server.
Self-hosting gives you the renderer. The rest of Papervine is the control plane, and none of it is in the CLI package:
| Not included | What that means |
|---|---|
| Reader authentication | Every page is public. There is no per-page gating and no group check — groups: frontmatter is not enforced, see the warning below. |
| The browser editor | No visual editing, no collaboration, no draft workflow. |
| Analytics | No page views, no search analytics, no assistant transcripts — including for MCP tool calls, which a hosted site attributes to an agent. |
| Automations | No scheduled or content-triggered AI runs. |
| Managed TLS and CDN | Yours to provide, via the proxy above. |
| Multi-site hosting | One process per site. |
The assistant and the
read MCP server are not on that list — a self-hosted site serves
/mcp with the same four tools a hosted one does, so an editor’s AI can search and read your docs
either way.
A repo carrying groups: frontmatter is fully public when self-hosted. Reader auth is the
gate those keys are checked against, and it does not exist here — so a page marked
groups: ["admin"] renders for anyone who asks for its URL. Nothing warns you, because from the
renderer’s side there is no reader to deny.
The MCP server sharpens this: list_pages returns every page in one
call, so an agent does not have to guess a URL to find the internal ones. That is the same
allow-all predicate the site itself uses, applied consistently — but it turns “reachable if you
know the path” into “enumerable in one request”.
If your repo has gated pages, either keep them out of the deployment or use hosted Papervine, where the gate is real.
If your docs are public, your team writes in Git, and you already run a reverse proxy, none of that is a loss. If you need gated docs or non-technical authors editing in a browser, that’s hosted Papervine.
The most common reason a deployed site is unreachable is dev’s loopback default.
The CLI does not serve HTTPS.
The directory must contain docs.json at its root. The CLI fails with a clear message
if it doesn’t.
Request a nested page directly (/guides/authoring, not just /) to confirm the proxy
passes the full path through.