What this is: how to run a built Publisher server — via
npx, Docker, or Docker Compose — and how to read its lifecycle state. For the full config/env-var reference, see configuration.md. To build from a clone, see development.md.
npx @malloy-publisher/server --port 4000Open http://localhost:4000 to explore the bundled example packages. storefront,
governed-analytics, and html-data-app are cloned from GitHub on first launch — expect a short wait
before operationalState reports serving. No credentials required.
Run one first launch at a time: two npx first runs installing concurrently can race in the shared
npx cache and corrupt the install. If the command stops working after an interrupted or doubled-up
first run, delete the cache under ~/.npm/_npx and run it again.
On startup the server writes a .mcp.json into its working directory (not --server_root) so an AI
agent started there connects with no manual setup. It only ever creates, never edits, and it skips
several cases including git working trees and the home directory; see
configuration.md for the rules.
For a service rather than a terminal, set PUBLISHER_NO_MCP_CONFIG=1, which is what the Docker image
does: nothing starts an agent session in a service, so the file is pure side effect there. Two things
otherwise bite. The file outlives the process and is never corrected, so a stale one can point an agent
at whatever holds that port now. And a process manager that leaves the working directory unset lands in
/, which the server skips on its own.
curl -s http://localhost:4000/api/v0/status | jq .operationalState # → "serving"
curl -s http://localhost:4000/api/v0/environments | jq '.[].name' # → list of environmentsoperationalState reports the current server lifecycle:
serving— ready to handle requests.initializing— loading packages and connections frompublisher.config.json. Normal on boot, and especially noticeable on the first run when sample packages need to be cloned from GitHub. Wait forserving.draining— graceful shutdown in progress: the server is waiting for in-flight requests to finish before closing. Controlled bySHUTDOWN_DRAIN_DURATION_SECONDSandSHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS(see configuration.md).throttled— the memory governor has hit its back-pressure limit and is rejecting new package loads and queries to stay underPUBLISHER_MAX_MEMORY_BYTES. Already-loaded packages remain serviceable; the control plane should treat the worker as unhealthy for new load until memory drops. Only reported when the memory governor is enabled (see configuration.md).
A package or environment that fails to load is skipped rather than fatal, so the server still reports
serving and simply serves whatever did load. Check loadErrors before you treat a serving server
as complete:
curl -s http://localhost:4000/api/v0/status | jq .loadErrorsThe field is absent when everything loaded. Otherwise it lists what is missing and why:
[
{
"environment": "local",
"package": "sales",
"message": "Failed to mount local directory: /home/me/my-data/sales-models"
},
{
"environment": "examples",
"package": "storefront",
"message": "Package manifest for /publisher_data/examples/storefront does not exist."
}
]An entry with a package names a package that failed to load (a location that would not mount, a
missing publisher.json, a compile error) while its sibling packages keep serving. An entry with
no package means the environment itself failed to initialize, and its packages are absent with
it. Either way the fix is usually the location in publisher.config.json, or a missing
publisher.json in the package directory.
Two ways to run Publisher in Docker: build the image from source, or pull the pre-built image from
Docker Hub. Either way, the container's WORKDIR is /publisher (mount your publisher.config.json
there), REST is on :4000, and MCP is on :4040.
Without that mount the server still starts and reports operationalState: serving, with no
environments and an empty catalog. That is a supported way to run, since environments can be created
over the API afterwards, so it is not treated as an error. Three places say so: a line
naming the path that was checked,
Serving with no environments: no publisher.config.json was found at /publisher/publisher.config.json
when the server started. Create one there (in Docker, mount it at that path) and restart, or pass
--config <path>. Environments can also be created at runtime through the API.
and the machine-readable readiness line on stderr, whose counts carry what serving alone does not:
PUBLISHER_READY url=http://localhost:4000 mcp=http://localhost:4040 environments=0 packages=0 load_errors=0
The third is GET /api/v0/status (and the get_status MCP tool), which carries the first line's
text as emptyReason for as long as no environment exists.
If you expected packages and see environments=0, the config did not reach /publisher.
The server runs as uid 1000, not root, so a volume or bind mount it writes to must be writable by
that uid. A new named volume is. One that an earlier image, which ran as root, wrote to needs a
one-time chown; packages/server/README.docker.md
has the command.
docker build -t malloy-publisher .
docker run -d \
-p 4000:4000 -p 4040:4040 \
-v $(pwd)/publisher.config.json:/publisher/publisher.config.json:ro \
malloy-publisherIf you don't have a config yet, copy
packages/server/publisher.config.example.duckdb.json
as a starting point — it loads the same three DuckDB example packages the server ships with
(no credentials needed).
The official pre-built image is published to Docker Hub at
ms2data/malloy-publisher.
docker pull ms2data/malloy-publisher
docker run -d \
-p 4000:4000 -p 4040:4040 \
-v $(pwd)/publisher.config.json:/publisher/publisher.config.json:ro \
ms2data/malloy-publisherTags:
:latest: most recent stable release.:X.Y.Z: pinned to a specific release; recommended for production.
There is no pre-release channel today. The *-dev tags (e.g. :0.0.198-dev) are frozen at
0.0.198-dev6 (May 2026) and the :next tag has been retired. Old *-dev tags still resolve in the
registry, so an existing deployment pinned to one keeps working; don't use them for new deployments.
A ready-to-use Compose file lives at
docker-compose.example.yml — it runs the pre-built image with both
ports mapped, a healthcheck against /api/v0/status, and a named volume for publisher_data/ so
first-boot package clones survive restarts. To use it:
- Copy it into your project:
cp docker-compose.example.yml docker-compose.yml. - Place a
publisher.config.jsonnext to it (or change the volume mount). No config of your own yet? Copypackages/server/publisher.config.example.duckdb.json— DuckDB-only samples, no credentials needed. docker compose up -d
For env-var configuration, persistent publisher_data/ volumes, and advanced options, see
packages/server/README.docker.md.