Docker
Build the OpenKnowledge container image and run it on Railway, Fly.io, or any Docker host.
For an always-on knowledge base without managing a box, run the server as a container on a hosting platform (Railway, Fly.io, or your own Docker host). The platform provides the domain and TLS; a volume holds the project; the OK_* environment variables carry the same declarations as every other path. Read How exposure works first if you haven't.
What any host must provide
The recipes on this page are examples, not requirements. The server runs anywhere that provides:
- One always-on instance. The collaboration server is single-writer: exactly one server process per project. Don't scale to multiple replicas, and don't scale to zero between requests.
- A persistent volume with a real filesystem, mounted at
/data. The project is a live git repository the server watches and writes. Container-layer storage is lost on recreate, and network filesystems built for object storage fit it poorly. - HTTP ingress that supports WebSockets and doesn't buffer streaming responses. Live editing runs over a WebSocket, and
/mcpstreams server-sent events (details in the proxy rules below). - TLS at the edge, with
X-Forwarded-Proto: httpspreserved on the way in. Every platform edge does this out of the box; if you insert your own plain-HTTP proxy, set the header yourself. - The two declarations from How exposure works:
OK_EXTERNAL_URLnaming the public origin, andOK_ALLOW_EXTERNAL=1consenting to exposure.
Anything that checks those boxes, whether a hosting platform, an orchestrator, or a box under your desk, will run the image.
Build the image
There is no official OpenKnowledge image on a registry yet, so build your own from two small files:
FROM node:24-slim
# git is a hard boot requirement: the server runs a git preflight at boot
# and the version-history subsystems shell out to the binary.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @inkeep/open-knowledge
# PORT is the platform-injection contract: Railway/Fly/Cloud Run override
# it at run time; 8080 is only the local-run default.
# OK_BIND=0.0.0.0 makes the listener reachable from the container network.
# Consent (OK_ALLOW_EXTERNAL=1) is deliberately NOT baked: a run without it
# refuses to boot and names the fix. That refusal is the secure default.
ENV PORT=8080 \
OK_BIND=0.0.0.0
# No `VOLUME /data`: managed builders (Railway, Fly) reject the
# instruction, and for plain `docker run` it only creates anonymous
# volumes. Persistence is the operator's job: mount a volume at /data,
# or data lives in the container layer and is lost on recreate.
WORKDIR /data
EXPOSE 8080
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]The entrypoint is the container's boot script, run on every start. ok start needs an initialized project, and no one is inside the container to run ok init by hand, so the script does it: the first boot against an empty volume initializes the project, and every boot after finds .ok/ already there and goes straight to the server. It's the same pattern the official database images use for an empty data directory. (--no-mcp --no-skills only skip scaffolding local agent config and skill files the container doesn't need; the server's own /mcp endpoint is always served.)
#!/bin/sh
# First-boot init: scaffold the project exactly once, on an empty volume.
# Any initialized (or restored) volume skips straight to `ok start`.
# stdin is closed so `ok init` takes its non-TTY defaults and can never
# hang on a prompt.
set -eu
if [ ! -d /data/.ok ]; then
echo "[entrypoint] /data not initialized — running: ok init --no-mcp --no-skills"
ok init --no-mcp --no-skills < /dev/null
fi
exec ok startTest it locally:
docker build -t open-knowledge:local .
docker run --rm -p 8080:8080 -e OK_ALLOW_EXTERNAL=1 -v ok-data:/data open-knowledge:local
# → http://localhost:8080The environment surface
| Variable | Container default | Meaning |
|---|---|---|
PORT | 8080 | Listen port. Platform-injected PORT (Railway, Fly, Cloud Run) overrides it at run time; don't set it yourself on those platforms. |
OK_BIND | 0.0.0.0 | Bind address list. On an IPv6-only network set :: instead (dual-stack, serves IPv4 too); don't combine 0.0.0.0 :: on one port, since the dual-stack listener collides. |
OK_ALLOW_EXTERNAL | unset | Run-time exposure consent. Without it the interlock refuses to boot. |
OK_EXTERNAL_URL | unset | The public origin the deployment is reached at. Required behind any real domain, or hostname requests are rejected with 403. |
OK_IDLE_SHUTDOWN | unset | Idle shutdown already defaults to off on a non-loopback bind; set a duration (30m) to opt back in. |
Health endpoints for platform checks: GET /healthz (liveness) and GET /readyz (readiness).
Run exactly one replica per volume
The collaboration server is single-writer: one server process per project volume. Keep the service at 1 replica, because scaling to 2+ silently runs two writers against the same data, and inside a container nothing enforces the exclusion. Multiple projects are fine as multiple services, each with its own container, volume, and domain.
Deploy on Railway
Railway builds the Dockerfile server-side, injects PORT and TLS, and hands out a domain; the whole flow is one deploy. From the directory with the Dockerfile and entrypoint.sh:
railway login
railway init --name my-kb # create the project
railway add --service my-kb --variables "OK_ALLOW_EXTERNAL=1" # create the service + consent
railway volume --service <service-id> add --mount-path /data # persistent volume (see note below)
railway domain --service my-kb --port 8080 # generate the domain (works pre-deploy)
railway variables --service my-kb --set 'OK_EXTERNAL_URL=https://${{RAILWAY_PUBLIC_DOMAIN}}'
railway up --service my-kb --detach # one deploy; externalUrl correct from first boot
railway logs --service my-kb # watch the first boot${{RAILWAY_PUBLIC_DOMAIN}} is a Railway reference variable that resolves to the generated domain at deploy time, so OK_EXTERNAL_URL is right from the first boot, with no deploy-then-set-then-redeploy loop. On first boot the entrypoint initializes the empty volume, then the server comes up; the log shows the exposure banner naming your domain. Set the platform health check to /readyz.
A few Railway-specific caveats:
- Don't set
PORT. Railway injects it, and setting it yourself fights the platform. - Leave replicas at 1 (see the single-writer callout above).
railway volumewants the service ID, not the name. Every other command above accepts the service name, but on current CLIsvolumecan crash on a name. Get the ID fromrailway status --json.- Subcommand flags drift across Railway CLI versions. If a command errors, check
railway <cmd> --helpfor where the--serviceflag goes. - Setting a variable and immediately running
railway redeploycan race. The redeploy can snapshot the environment before the variable applies. Userailway up, or redeploy after the variable shows inrailway variables.
Other platforms
The same image runs anywhere Docker does. On Fly.io, mount a volume at /data, set the same two OK_* variables with the .fly.dev domain, and point a check at /readyz. With Docker Compose (on a platform or your own VPS; the pieces mix and match), you can instead add a cloudflared sidecar as the only ingress, skip the public domain entirely, and set OK_EXTERNAL_URL to the tunnel hostname.
The big clouds (AWS, GCP, Azure) run the image well on a VM with Docker, or you can use the npm method directly. Their serverless container products are a weaker fit: they don't always provide a persistent disk that meets the requirements above, and some scale in ways the single-writer server can't use. Fully serverless platforms (Vercel, Netlify) can't host it: the server is one long-lived stateful process, not request-scoped functions.
Upgrading
The image installs whatever version of @inkeep/open-knowledge is current at build time, so upgrading means rebuilding the image and redeploying onto the same volume. The entrypoint finds the volume already initialized and goes straight to the server; your content, history, and settings all live on the volume and carry over. On Railway, that's railway up again from the Dockerfile directory.
Two practices make this predictable:
- Pin the version in the Dockerfile (
RUN npm install -g @inkeep/open-knowledge@<version>) and bump the line to upgrade. An unpinned install can silently reuse Docker's cached build layer, leaving you on the old version while looking freshly deployed; a pinned bump changes the line, which busts the cache, and it gives you an exact version to rebuild if you ever want to go back. - Snapshot the volume before upgrading: your platform's volume backup, or a plain tar of
/data. A snapshot restored onto a fresh volume boots straight to the server, since the entrypoint treats any initialized volume as ready.
Rules for anything in front of the server
Three rules apply to any proxy you place between clients and the container (an auth proxy, nginx, a CDN layer):
- Preserve the original
Hostheader. The server admits requests by theirHost, matched againstOK_EXTERNAL_URL. Many reverse proxies rewriteHostto the upstream address (ok:8080) by default, which no longer matches and gets rejected with403on every request. Forward the client's host instead: Caddyheader_up Host {host}, nginxproxy_set_header Host $host;. Platform edges preserve it automatically; a proxy you add yourself usually does not. - Never buffer
/mcp. It's a streaming (server-sent events) endpoint; a proxy that buffers or compresses it hangs every agent connection until timeout. nginx needsproxy_buffering off;on that path; Caddy streams by default; platform edges pass SSE through. The rule applies to any proxy you add. - A TLS-terminating proxy that speaks plain HTTP to the server must set
X-Forwarded-Proto: https. The server derives secure WebSocket URLs (wss://) from that header; a proxy that stamps it back tohttpmakes the editor try an insecurews://from anhttps://page, which browsers block, leaving the editor stuck on loading skeletons. Platform edges set it correctly; an auth proxy between the edge and the server often does not.
Next steps
Try the deployment with a real MCP handshake against the public domain, then lock it down: the platform domain has no login of its own, so anyone who finds the URL has full read-write control until you add authentication.