Docker deployment
One ordered path from nothing to a running, credentialed router: checkout → wizard → start → verify. Everything else on this page is a variation on those four steps. Containerizing is an alternative to the local uv run / stdio workflow in getting-started.md, not a later stage of it.
Nothing here is required. The router runs perfectly well with a plain uv run hpe-mcp-router on a laptop. Containerizing it only matters once you’re running the router unattended (a shared dev box, a small VM, a Kubernetes pod) where the same non-root, no-secrets-in-the-image, no-silent network fetch expectations from local development should still hold.
1. Get a checkout
git clone https://github.com/secure-ssid/hpe-networking-mcp
cd hpe-networking-mcp
The image builds from here. The spec index (2,700+ endpoints across 31 pinned OpenAPI documents) is baked in at build time, so there is nothing to populate first.
2. Run the wizard
python3 scripts/setup_wizard.py --docker
It asks everything the deployment needs in one pass: which toolsets to load, which optional products and their credentials, read-only or read/write per platform, whether to use the RAG image and which vector backend, and whether to publish beyond loopback. Accepting the recommended defaults at the first question skips to credential capture — loopback-only, Central + GreenLake + API lookup, read-only, no optional products.
Every answer is also a flag, for scripted or repeatable runs:
python3 scripts/setup_wizard.py --docker --yes \
--toolsets central,glp,rag,mist \
--products mist \
--access-profile custom --product-access read-only
| Flag | Effect |
|---|---|
--yes | take every default, prompt for nothing |
--toolsets a,b,c | backend families to load (HPE_MCP_TOOLSETS); default central,glp,rag |
--products a,b | optional products to enable, or all; unioned onto the toolsets |
--access-profile | safe-read-only, custom (per-platform, the default), or full-read-write |
--product-access | read-only or read-write for optional products under custom |
--router-mode | minimal discovery, default wrappers, or direct registration |
--expose IP --expose IP | publish beyond loopback; must be passed twice with the same value to acknowledge it |
--force | rotate secrets and regenerate the overlay instead of keeping existing files |
It writes the following, all git-ignored. A rerun keeps existing files unless --force is passed:
secrets/mcp_http_bearer_token— a fresh 64-hex token, mode 0600, whose value is never printed;secrets/credentials.yaml— Central/GreenLake identity: base URLs, client ids, workspace ids. Noclient_secretkeys;secrets/central_client_secret,secrets/glp_client_secretand one file per selected product credential (secrets/mist_api_token, …), each 0600;docker-compose.router.local.yml— a generated overlay layering overdocker-compose.yml: a literal127.0.0.1:<port>:<port>publish line, hostname-derived allowlists, your toolset and product selection, one Compose secret per credential file, and every write gate defaulted to refused;.env— non-secret knobs only: router mode, toolsets, products, access profile, the nine per-platform write gates, andHPE_MCP_RAG_BACKENDwhen the redis backend was chosen. Secret values never land here; if the file already holds secret-shaped or credential-affecting keys the wizard warns listing them and leaves them byte-for-byte alone.
3. Start it
docker compose -f docker-compose.yml -f docker-compose.router.local.yml \
--profile router up -d --build mcp-router
Naming mcp-router matters: redis and ollama sit in docker-compose.yml’s default profile, so omitting the service name would also start two containers the default image has no client for. If you chose the redis RAG backend, start both of them — ... up -d mcp-router redis ollama — because that path keeps its vectors in redis and embeds each query through ollama; the generated overlay declares depends_on and points REDIS_URL/OLLAMA_URL at those services.
A plain docker compose up (no -f docker-compose.router.local.yml, no --profile router) still starts only redis/ollama, exactly as before this overlay existed.
4. Verify
curl http://127.0.0.1:8010/livez
# {"status":"ok"}
To confirm your selection actually reached the router rather than just the host, ask the container what it loaded:
docker compose -f docker-compose.yml -f docker-compose.router.local.yml \
exec -T mcp-router python -c \
"from hpe_networking_mcp.mcp_servers.tool_router import _build_backends; print(sorted(_build_backends()))"
Each selected product appears as its own <name>-core backend. The entrypoint also logs one line per credential it bridged (entrypoint: filled MIST_API_TOKEN from MIST_API_TOKEN_FILE ...), visible with docker compose ... logs mcp-router.
Secrets: one value, one file
Every credential is its own 0600 file under secrets/, mounted as its own Compose secret and read through the <VAR>_FILE → <VAR> bridge in docker/entrypoint.sh. Nothing credential-shaped is ever passed as a plaintext environment: value or written to .env.
That layout exists for rotation. Revoking one product’s key is:
printf '%s' "$NEW_TOKEN" > secrets/mist_api_token
docker compose -f docker-compose.yml -f docker-compose.router.local.yml \
restart mcp-router
No other credential is read, rewritten, or re-exposed — which is exactly what a single shared .env cannot give you. A plain restart is enough here: the Compose secret is a live mount of the host file, and the entrypoint re-reads it every time the container starts.
Changing a non-secret knob in .env is the other case, and it needs ... --profile router up -d mcp-router rather than restart — Compose bakes interpolated values into the container when it is created, so a restarted container keeps the values it was built with.
Two paths, one for each kind of value:
| Value | Where it lives | How the container reads it |
|---|---|---|
| Central/GreenLake identity (base URLs, client ids, workspace ids) | secrets/credentials.yaml | mounted at /run/secrets/credentials_yaml, named by CREDS_PATH |
| Any single secret (client secrets, product API tokens, the bearer token) | secrets/<name>, one file each | <VAR>_FILE=/run/secrets/<name>, bridged by the entrypoint |
config/credentials.yaml is the host path used by the local uv run workflow and keeps carrying secrets inline; secrets/credentials.yaml is the container path and holds identity only. They are separate files on purpose: secrets/ is git-ignored wholesale and is what Compose mounts.
secrets/README.md has the copyable Compose snippet for wiring a secret by hand.
Without the wizard
The tracked docker-compose.router.yml is the same stack with the same knobs, minus the generated per-product secret wiring (Compose refuses to start when a declared secret’s file is missing, so it declares only the two every deployment creates):
cp config/credentials.yaml.example secrets/credentials.yaml
# edit it with real Central/GLP client id/secret values
openssl rand -hex 32 > secrets/mcp_http_bearer_token
chmod 600 secrets/credentials.yaml secrets/mcp_http_bearer_token
docker compose -f docker-compose.yml -f docker-compose.router.yml \
--profile router up -d --build mcp-router
curl http://127.0.0.1:8010/livez
Set HPE_MCP_TOOLSETS, HPE_MCP_PRODUCTS, HPE_MCP_PRODUCT_ACCESS and the HPE_MCP_*_WRITES gates in .env to change what it loads, then re-run up -d to apply them; every one of those variables is interpolated by that file and defaults to the refusing value. To add an optional product’s credential, follow the snippet in secrets/README.md.
Kicking the tyres: the published image
No checkout, no credentials, no persistence — a look at the tool surface only. It is not the deployment path above.
CI publishes every build to GHCR under a sha-<short-sha> tag (first seven characters of the commit SHA) and promotes that exact digest to latest (builds from main) or the matching semver tags (v* releases) only after the Trivy policy passes — so latest always points at scan-approved bytes, and sha-<short-sha> pins one build exactly:
docker run -d --name hpe-networking-mcp \
-p 127.0.0.1:8010:8010 \
-e MCP_HOST=0.0.0.0 \
-e MCP_ALLOWED_HOSTS='127.0.0.1:*,localhost:*' \
-e MCP_ALLOWED_ORIGINS='http://127.0.0.1:*,http://localhost:*' \
ghcr.io/secure-ssid/hpe-networking-mcp:latest
curl http://127.0.0.1:8010/livez answers {"status":"ok"} within seconds. The baked spec index makes credential-free exact-API lookup (lookup_api) work with no provisioning. It does no prose retrieval: that needs the INSTALL_EXTRAS=ingestion rebuild and a corpus, per Building a RAG-capable image below. The host:* allowlist form is required whenever MCP_HOST is not loopback (see Loopback-only exposure by default).
Files
| File | Purpose |
|---|---|
Dockerfile | Multi-stage production image for the router (hpe-mcp-router) |
.dockerignore | Keeps secrets, .env, local state, and built indexes out of the build context |
docker/entrypoint.sh | Expands *_FILE Docker-secret conventions into plain env vars, then execs the requested command |
docker-compose.yml | Unchanged: optional localhost-only Redis/Ollama server backend |
docker-compose.router.yml | Additive overlay: the containerized router, behind a Compose router profile |
scripts/setup_wizard.py | --docker generates the secrets, .env and docker-compose.router.local.yml above |
secrets/README.md | The CREDS_PATH and <VAR>_FILE secret conventions, with copyable Compose wiring |
Security choices
Loopback-only exposure by default
The router’s own code refuses to bind beyond loopback (MCP_HOST other than 127.0.0.1/localhost/::1) unless MCP_ALLOWED_HOSTS and MCP_ALLOWED_ORIGINS are both set explicitly, with every wildcard entry limited to the SDK-supported <host>:* port-wildcard form — a bare * or a subdomain glob silently matches nothing and is refused — see UnsafeHttpBindingError in src/hpe_networking_mcp/mcp_servers/shared.py. That check runs inside the container exactly as it does locally; this packaging doesn’t touch it.
docker-compose.router.yml sets MCP_HOST=0.0.0.0 because Docker’s port-publish proxy connects to the container’s own network-namespace address, not to 127.0.0.1 inside it — a process bound strictly to 127.0.0.1 inside a container is unreachable from the host even with a port published. The actual “loopback only from the host’s point of view” guarantee instead comes from the publish side:
ports:
- "127.0.0.1:8010:8010"
This is the same pattern docker-compose.yml already uses for redis and ollama — the container-internal bind address and the host-published address are two different security boundaries, and only the second one is what actually decides whether something outside the machine can reach the port. MCP_ALLOWED_HOSTS/MCP_ALLOWED_ORIGINS are set to 127.0.0.1:*/ localhost:* port wildcards so the router’s own DNS-rebinding protection still applies once MCP_HOST=0.0.0.0 is in effect.
If you need the router reachable from other machines (not just localhost on the Docker host), change the publish spec to "0.0.0.0:8010:8010" (or a specific interface) and put a real reverse proxy / TLS terminator / firewall in front of it, and set MCP_HTTP_BEARER_TOKEN (see below). This packaging deliberately does not make that the default.
No credentials baked into the image
.dockerignoreexcludes.env,config/credentials.yaml, and everything undersecrets/except the tracked*.exampletemplates andsecrets/README.md— the Docker build daemon never even receives these files as build context, so they can’t end up in an image layer by accident.- The
DockerfileneverCOPYs any of those paths. - Real secrets are supplied at container start, two ways:
CREDS_PATH=/run/secrets/credentials_yaml—config/credentials.yaml’s equivalent content is already loaded from whatever pathCREDS_PATHnames (seehpe_networking_mcp.pipeline.config.load_credentials), and Docker secrets are already ordinary files mounted read-only at/run/secrets/<name>, so this needs no extra glue.MCP_HTTP_BEARER_TOKEN_FILE=/run/secrets/mcp_http_bearer_token—docker/entrypoint.shreads this file’s contents intoMCP_HTTP_BEARER_TOKENbefore starting the router, the common<VAR>_FILEconvention used by many official images. A non-empty<VAR>wins over its<VAR>_FILEcounterpart; a set-but-empty<VAR>beside its counterpart is a misconfiguration and refuses startup.
- See
secrets/README.mdfor the full setup steps and how to add more*_FILEsecrets (optional-product API tokens, etc.).
This image is the exact-API-lookup deployment
The image contains /app/data/specs.sqlite — 2,734 endpoints, 6,363 schemas and 31,432 fields, built during the image build in a throwaway stage from the 31 digest-pinned OpenAPI documents committed under vendor/openapi/. lookup_api therefore answers on a bare docker run with no credentials, no network, and no provisioning step. The corpus itself never reaches the runtime image.
The default image does no prose retrieval by any backend, and that is the whole statement — switching backends is not a way around it. ask_docs and search_docs read a corpus built from scraped vendor documentation this project has no licence to redistribute, so it can never ship in an image; you build it yourself. On top of that the default image installs neither of the two clients that could read one: the embedded LanceDB/ONNX stack (~700 MB) is in the ingestion extra, and the HPE_MCP_RAG_BACKEND=redis alternative needs the redis extra. Starting docker-compose.yml’s redis and ollama services changes nothing on its own. What you actually get, verified in the built image rather than inferred:
lookup_api— full answers from the baked spec index.ask_docs— answers, but from the spec index, not the prose corpus. It falls back to structured evidence excerpts sourcedopenapi_specs, so the provenance in the reply says where the answer came from.search_docs— the degraded shape (error+degraded+hint), never a confident empty list:
The document corpus needs the optional `lancedb` package, which is not
installed — rebuild the image with `docker build --build-arg
INSTALL_EXTRAS=ingestion` — see docs/production-deployment.md
Building a RAG-capable image
End-to-end checklist: from a bare checkout to prose answers served over Docker. Do the steps in order — steps 1–2 run on the host from a source checkout, steps 3–6 need only Docker.
# 1. Fetch the declared vendor sources. A fresh checkout has no
# ingestion/sources/ tree (it is git-ignored and never committed), and
# ingest_docs.py refuses to replace an index built from an empty one.
# This crawls vendor sites for hours and accepts each vendor's document
# terms -- nobody can accept those terms on your behalf:
uv run --extra ingestion python scripts/refresh_rag_sources.py --refresh-sources
# 2. Build the LanceDB corpus from what step 1 fetched:
uv run --extra ingestion python ingestion/ingest_docs.py
# 3. Build an image that can read it. `ingestion` gets the embedded LanceDB
# backend, which needs no services. For the Redis backend instead, use
# INSTALL_EXTRAS=redis and set HPE_MCP_RAG_BACKEND=redis; `all` gets both.
docker build --build-arg INSTALL_EXTRAS=ingestion \
-t hpe-networking-mcp-router:rag .
# 4. Provision the two secrets the overlay mounts, exactly as in "Without the
# wizard" above: secrets/credentials.yaml and secrets/mcp_http_bearer_token,
# chmod 600, never committed. Compose cannot start the service without
# them. (`setup_wizard.py --docker` does steps 4-5 for you, and its
# generated overlay carries the INSTALL_EXTRAS build arg, so `--build` is
# safe there.)
# 5. In docker-compose.router.yml set `image:` to that tag and add the
# mounts, read-only, individually — never `./data:/app/data`, which
# would shadow the baked spec index. Start WITHOUT `--build`: the service
# also has a `build:` section, so `--build` would rebuild your :rag tag
# straight from the Dockerfile, whose INSTALL_EXTRAS default is empty --
# silently replacing the RAG-capable image with a bare one:
# - ./data/docs.lance:/app/data/docs.lance:ro
# - ./data/tools.lance:/app/data/tools.lance:ro
docker compose -f docker-compose.yml -f docker-compose.router.yml \
--profile router up -d mcp-router
# 6. Verify prose retrieval end to end:
curl http://127.0.0.1:8010/livez # router is up
# then ask_docs a prose question from your MCP client: a cited,
# corpus-backed answer means the mount landed; the degraded hint shown
# under "This image is the exact-API-lookup deployment" means it did not.
No CI job publishes a RAG tag; this is a supported local build. The default overlay declares no host mounts at all, because the default image has no code that could open one. The redis/ollama services in docker-compose.yml are infrastructure for this build and for running from source — start them deliberately (drop mcp-router from the command above, or up -d redis), not because the router needs them.
Nothing is ever downloaded for you
Neither the Dockerfile build, nor docker/entrypoint.sh, nor docker-compose.router.yml fetches an index at build or at container start. If you host your own archive internally, package it with scripts/package_indexes.py — which emits the checksum manifest — and restore it with the manifest you generated, or with the spec-index-manifest.json published alongside a release:
# Optional, and run on the host: /app/data inside the container is read-only
# (see below), so a restore has nowhere to land there. The pinned digest is
# verified before anything is unpacked, and there is no default URL --
# --manifest or --url is required.
uv run python scripts/download_indexes.py --manifest your-bundle.json
# Then restart the router so a RAG-enabled image picks up the new files:
docker compose -f docker-compose.yml -f docker-compose.router.yml \
--profile router restart mcp-router
scripts/download_indexes.py refuses non-HTTPS URLs, verifies a SHA-256 digest independently of any downloaded .sha256 sidecar when a pinned manifest is used, rejects path-traversal/symlink members inside the archive, and swaps the new index into place atomically — see tests/unit/test_download_indexes.py. This packaging relies on those existing guarantees rather than re-implementing them; it only decides when that script runs (explicitly, never automatically).
Non-root, minimal runtime image
- The container runs as a dedicated, non-root
mcpuser (uid/gid10001), not a shared “system” uid range. /app(application code and theuv-managed virtualenv) is owned byrootat the top level; only/app/state,/app/outputsand themcpuser’s own home directory (/home/mcp, used for theuvcache) are writable by the running process./app/datais not writable, and that is deliberate. Unlink and rename are governed by the directory’s permissions, not the file’s, so a writable/app/datawould let anything running asmcpdelete the bakedspecs.sqliteand drop a different index in its place — precisely the substitution a read-only file mode looks like it prevents and does not. The directory is root-owned0555and the index inside it root-owned0444. The query layer opens it withfile:…?mode=ro, so nothing legitimate needs write access there; verified withlookup_apiand with LanceDB reads through read-only bind mounts under that locked directory. Rebuilding the index inside the container consequently needs a writable mount over/app/dataor a different user — build it on the host instead.- Dependencies are resolved once, at build time, from the committed
uv.lock(uv sync --frozen) — the runtime image setsUV_NO_SYNC=1souv run hpe-mcp-routerat container start never attempts network dependency resolution. - The image is multi-stage: the
builderstage (fulluvcache,aptpackage lists) never reaches theruntimestage.
Host / runtime limitations
- Docker Compose v2.20.2+ / Compose Spec is assumed for the
routerCompose profile and file-basedsecrets:block used here; older Compose plugin versions may not support one or both. Validated locally against Docker Compose v5.3.0. - BuildKit (
# syntax=docker/dockerfile:1,--mount=type=cache,COPY --chmod=) is required to build the image; this is the default for any reasonably current Docker Engine/Desktop, but very old Docker installs without BuildKit enabled cannot build thisDockerfileas-is. - No GPU passthrough is configured for the router image (it doesn’t need one). If you also run the optional
ollamaservice fromdocker-compose.ymlwith GPU acceleration, that remains a separate, already-documenteddeploy.resources.reservations.devicesblock indocker-compose.yml— unaffected by this overlay. fastembed’s ONNX Runtime backend needslibgomp1; the image installs it so that a--build-arg INSTALL_EXTRAS=ingestionbuild works without changing the runtime stage. The default image has nofastembedto use it. If you change the base image, keep an equivalent OpenMP runtime library available or embeddings-backed RAG tools will fail to import in a RAG-enabled build.- Non-loopback exposure needs a real proxy. Setting
ports: ["0.0.0.0:8010:8010"](or binding to a LAN interface) without putting TLS termination, auth, and network policy in front of the container is explicitly out of scope for this packaging and is not the default; see “Loopback-only exposure by default” above. - Prose retrieval needs both a corpus and a RAG-enabled build, and is not a one-command
docker run.lookup_apiworks out of the box because the image shipsdata/specs.sqlite;ask_docsandsearch_docsneeddata/docs.lance, which you build yourself (uv run --extra ingestion python ingestion/ingest_docs.py) and mount, plus an image built with--build-arg INSTALL_EXTRAS=ingestion. SettingHPE_MCP_RAG_BACKEND=redisand starting theredisservice is not an alternative route: that client is in theredisextra and is likewise absent from the default image. Without one of them the router still starts and serves every non-RAG tool (Central/GLP/monitoring/config/ops/NAC) pluslookup_apiagainst the baked spec index, and the prose tools report their remedy. - Firmware upgrade caveat carries over unchanged:
set_firmware_complianceremains the supported path;/firmware/v1/upgradestill 404s on this Central instance regardless of how the router is deployed.
Validating this packaging
# Structural/static checks (YAML parse, non-root user, no baked secrets,
# no silent index download, loopback-only publish, profile gating):
uv run pytest tests/unit/test_docker_router_packaging.py tests/unit/test_docker_compose.py -q
# Compose merge/validation (no daemon required):
docker compose -f docker-compose.router.yml --profile router config
docker compose -f docker-compose.yml -f docker-compose.router.yml --profile router config
# Full build + start + healthcheck (requires a local Docker daemon):
docker build -t hpe-networking-mcp-router:local .
docker compose -f docker-compose.yml -f docker-compose.router.yml \
--profile router up -d --build mcp-router
curl http://127.0.0.1:8010/livez
docker compose -f docker-compose.yml -f docker-compose.router.yml \
--profile router down