How to Run MCP Servers in Docker Securely (2026 Guide)
This guide is for engineers who want to use MCP servers from Claude Desktop or Claude Code without giving every server the run of their laptop. By the end you will have two working setups: a Docker MCP Toolkit profile behind the Docker MCP Gateway with secrets in the OS keychain and network limited to allowlisted hosts, and a hand-built stdio server container with a read-only root filesystem, no network, no capabilities and one read-only bind mount. I checked commands against Docker MCP Gateway v0.44.3 (tagged 26 October 2026), Docker Desktop 4.62 or later, @modelcontextprotocol/server-filesystem 2.2.0 and github-mcp-server v2.1.0. You need Docker Desktop (or Docker Engine plus the docker mcp plugin) and one MCP client. If you want the protocol background first, read MCP resources, prompts, sampling and elicitation explained.
Step 1: Decide what the container is protecting you from
A local stdio MCP server started with npx runs as you. It can read ~/.ssh, your cloud credentials and every repo you have checked out, and it can talk to any host on the internet. Most servers are well behaved; the problem is that you cannot tell which ones from the README, and a server's tool descriptions are prompt-injection surface either way. MCP security in 2026 covers the incidents.
A container narrows three things: what files the server can see, what it can reach on the network, and which secrets it receives. It does not stop a server from returning malicious text to your model, and it does not make a shared kernel into a VM boundary. Keep those limits in mind and pick a path:
- Docker MCP Toolkit and Gateway if the servers you want are in the Docker MCP Catalog. The gateway applies isolation, secret scoping and signature checks for you.
- Hand-rolled
docker runfor a server that is not in the catalog, or when you want to see every flag yourself.
Step 2: Enable the MCP Toolkit and check the CLI
In Docker Desktop, open MCP Toolkit (the Toolkit docs describe the 4.62 and later UI). The Toolkit is a front end for the docker mcp CLI plugin, whose source is docker/mcp-gateway. Check it from a terminal:
docker mcp version
docker mcp feature list # 'profiles' should be enabled under Docker Desktop
On Docker Engine without Desktop (Linux servers, WSL2, CI), the plugin's README says to bypass the Desktop checks and turn profiles on yourself:
export DOCKER_MCP_IN_CONTAINER=1
docker mcp feature enable profiles
Step 3: Create a profile from the Docker MCP Catalog
A profile is a named set of servers that clients connect to. Pull the catalog, then create a profile with the official GitHub server. The v2 catalog file at desktop.docker.com/mcp/catalog/v2/catalog.yaml had about 270 server entries when I fetched it on 10 November 2026, and every container image reference in it is pinned by digest (remote entries have no image).
# Pull the default catalog (an OCI artifact)
docker mcp catalog pull mcp/docker-mcp-catalog
# Create a profile with one server
docker mcp profile create --name dev-tools \
--server catalog://mcp/docker-mcp-catalog/github-official
docker mcp profile list
docker mcp profile show dev-tools
Now cut the tool list down. The catalog entry for github-official lists 40 tools, including delete_file, create_repository and fork_repository. Enable only what you use:
# Start from nothing, then allow read-mostly tools (dot notation: <server>.<tool>)
docker mcp profile tools dev-tools --disable-all github-official
docker mcp profile tools dev-tools \
--enable github-official.get_file_contents \
--enable github-official.list_commits \
--enable github-official.search_pull_requests \
--enable github-official.issue_read
Fewer tools is a security control and a context-window control at the same time. Every schema you leave enabled is sent to the model.
Step 4: Store secrets in the keychain, not in JSON
The catalog entry for github-official declares one secret, github.personal_access_token, mapped to the GITHUB_PERSONAL_ACCESS_TOKEN environment variable inside the container. It also supports OAuth, which the Toolkit UI handles from the server's Configuration tab. For a token, use docker mcp secret set, which writes to the local OS keychain and reads the value from stdin so it does not land in your shell history:
# Create a fine-grained, read-only PAT first, then paste it at the prompt
read -rs GH_PAT && printf '%s' "$GH_PAT" | docker mcp secret set github.personal_access_token; unset GH_PAT
docker mcp secret ls # names only
Per the gateway's security model, containers do not inherit your host environment, a secret is passed only to the server that declares it, and --block-secrets (on by default) scans tool arguments and text responses for secret-like values.
Step 5: Run the gateway with network blocking and check it
Do a dry run first. It shows which servers start, which images are pulled and how many tools are exposed, without listening for a client:
docker mcp gateway run --profile dev-tools \
--block-network \
--cpus 1 --memory 1Gb \
--verbose --dry-run
The flags, all from the gateway run reference:
| Flag | Default | Why it matters |
|---|---|---|
--block-network |
off | Restricts each server to the allowHosts in its catalog entry; github-official lists api.github.com:443, github.com:443 and raw.githubusercontent.com:443 |
--block-secrets |
true |
Blocks secret-like values in tool traffic |
--verify-signatures |
true |
Verifies Docker mcp/ images, which must be referenced by digest |
--cpus / --memory |
1 / 2Gb |
Per-server container limits |
--log-calls |
true |
Logs tool names and argument shape, not raw values |
--transport |
stdio |
sse or streaming require a bearer token unless you pass --allow-unauthenticated |
Note the first row. Egress is not denied by default, which surprises people: without --block-network, a server can call any host its container can reach. Then exercise a tool without involving a model:
docker mcp tools ls --gateway-arg="--profile=dev-tools"
docker mcp tools call --gateway-arg="--profile=dev-tools" get_file_contents owner=octocat repo=Hello-World path=README
Step 6: Connect Claude Desktop and Claude Code to the gateway
The CLI can write the client config for you. The supported client names in v0.44.3 include claude-desktop, claude-code, cursor, vscode, codex and gemini:
docker mcp client connect claude-desktop --profile dev-tools
docker mcp client connect claude-code --profile dev-tools --global # user-wide; omit --global for the current git repo
docker mcp client ls
If you prefer to edit config by hand, the gateway is just a stdio server. For Claude Desktop, in claude_desktop_config.json (on macOS under ~/Library/Application Support/Claude/):
{
"mcpServers": {
"MCP_DOCKER": {
"command": "docker",
"args": ["mcp", "gateway", "run", "--profile", "dev-tools", "--block-network"]
}
}
}
For Claude Code, everything after -- is the server command, per the Claude Code MCP docs:
claude mcp add MCP_DOCKER --scope user -- docker mcp gateway run --profile dev-tools --block-network
claude mcp list
Restart Claude Desktop after changing its config. In Claude Code, /mcp shows connection status and tool counts.
Step 7: Run a stdio MCP server in your own hardened container
For a server outside the catalog, build the image yourself and pin everything. This uses the reference filesystem server, which since 2.2.0 has a --read-only flag that serves only tools annotated readOnlyHint: true, and requires Node 24 or later:
# Dockerfile: node:24-alpine index digest as of 10 Nov 2026
FROM node:24-alpine@sha256:a34ab6d7f6f4813f4d0c5469d74685604b962fbdbf5793a7bc7b4998ecb867c8
RUN npm install -g @modelcontextprotocol/server-filesystem@2.2.0 \
&& npm cache clean --force
USER node
ENTRYPOINT ["mcp-server-filesystem"]
docker build -t local/mcp-filesystem:2.2.0 .
Run it with every restriction a filesystem server can tolerate:
# -i keep stdin open for JSON-RPC; never add -t (a TTY corrupts the stream)
# --read-only (first one) Docker: read-only root filesystem
# --network none: no network interfaces except loopback
# --user 1000:1000 is the 'node' user in the official image
# --read-only (last one) server flag: only serve read-only tools
docker run -i --rm \
--read-only \
--network none \
--cap-drop ALL \
--security-opt no-new-privileges \
--user 1000:1000 \
--memory 512m --cpus 1 --pids-limit 128 \
--tmpfs /tmp:rw,noexec,nosuid,size=16m \
--mount type=bind,src="$HOME/code/api",dst=/projects/api,readonly \
local/mcp-filesystem:2.2.0 \
--read-only /projects
There are two --read-only flags on purpose: the first is Docker's, the second is the server's. The server flag matters because a writable tool against a read-only mount still shows up in the tool list and wastes a model turn failing.
I ran the same 2.2.0 package outside Docker and sent it tools/list both ways. With --read-only it returned 10 tools; without it, 16, the extra six being write_file, append_file, edit_file, create_directory, move_file and copy_file. Its stderr confirms the mode:
Secure MCP Filesystem Server running on stdio
Read-only mode: only tools that cannot change the filesystem are served
Effective allowed directories (from the command line):
/projects
Wire it into Claude Code the same way as any stdio server:
claude mcp add fs-api --scope user -- docker run -i --rm --read-only --network none \
--cap-drop ALL --security-opt no-new-privileges --user 1000:1000 \
--memory 512m --cpus 1 --pids-limit 128 --tmpfs /tmp:rw,noexec,nosuid,size=16m \
--mount type=bind,src=/Users/you/code/api,dst=/projects/api,readonly \
local/mcp-filesystem:2.2.0 --read-only /projects
Step 8: Pass secrets to a hand-rolled container without baking them in
Servers that call APIs need a token and a network. Pass the token as an environment variable name with no value, so Docker copies it from the client's environment and it never appears on the docker run command line or in your config's args array (it is still visible under Config.Env in docker inspect, and claude mcp add -e stores the value in Claude Code's config). The GitHub MCP server README uses this pattern, and GITHUB_READ_ONLY=1 turns on its read-only mode:
claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN="$GITHUB_PAT" -- \
docker run -i --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
-e GITHUB_PERSONAL_ACCESS_TOKEN -e GITHUB_READ_ONLY=1 \
ghcr.io/github/github-mcp-server:v2.1.0
You cannot use --network none here. If you need egress limited to specific hosts, that is the gateway's --block-network plus allowHosts from Step 5, or your own egress proxy. Plain docker run has no per-host allowlist.
Step 9: Verify the container is as locked down as you think
While the client is connected, find the container and inspect it:
docker ps --format '{{.ID}} {{.Image}}' | grep mcp
docker inspect --format \
'ro={{.HostConfig.ReadonlyRootfs}} net={{.HostConfig.NetworkMode}} caps={{.HostConfig.CapDrop}} sec={{.HostConfig.SecurityOpt}} user={{.Config.User}}' <container-id>
# expected for Step 7: ro=true net=none caps=[ALL] sec=[no-new-privileges] user=1000:1000
Then ask the model to write a file or fetch a URL. Both should fail, and the failure should be visible in the client rather than a hang. For deeper protocol-level checks, point MCP Inspector at the same docker run command.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Client says the server failed to start, no tools listed | -t in docker run args, so a TTY mangles JSON-RPC |
Use -i only; never -it for stdio servers |
Cannot connect to the Docker daemon in client logs |
Docker Desktop not running, or the GUI client cannot find docker on its PATH |
Start Docker; use the absolute path to docker in command |
Docker Desktop is not running while the engine is up |
Running docker mcp on Docker Engine, WSL2 or in a container |
export DOCKER_MCP_IN_CONTAINER=1 |
--profile flag or profile command unknown |
Profiles feature off outside Docker Desktop | docker mcp feature enable profiles |
Gateway refuses to pull an mcp/ image |
Signature verification needs a digest, and a mutable tag was used | Reference the image by @sha256: digest; keep --verify-signatures on |
Error: None of the specified directories are accessible |
Bind mount source missing, or server path argument outside the mount | Check src exists and pass the dst path (or its parent) to the server |
Tool calls time out after adding --block-network |
The host the server needs is not in its allowHosts |
Add the host to the server entry (file:// YAML) or drop the server |
Client rejects every tool: unsupported dialect |
Server declares draft-07 schemas and the client accepts only 2020-12 | Update the gateway, which translates draft-04/06/07; otherwise fix the server |
| GitHub server returns 401 | Secret name does not match the catalog, or token expired | docker mcp secret ls; the name must be github.personal_access_token |
| Write tools missing from filesystem server | Server started with --read-only |
Intended; run a second, writable server for a narrow path if you need writes |
When in doubt, docker mcp gateway run --verbose --dry-run --servers=<name> from the gateway's troubleshooting guide shows the exact container command line it would use.
FAQ
Is running an MCP server in Docker enough to make it safe?
No. It limits files, network and secrets, which covers the most common damage, but the server can still return prompt-injection text to your model, and containers share the host kernel. For untrusted code that runs commands, a microVM or gVisor boundary is stronger; I compare them in container vs microVM vs gVisor. (I build PandaStack, a Firecracker microVM platform, so weigh my bias accordingly.)
Do I need Docker Desktop for the Docker MCP Gateway?
No. The docker mcp plugin is open source and the gateway also ships as the docker/mcp-gateway image for Compose. Without Desktop you set DOCKER_MCP_IN_CONTAINER=1, enable profiles yourself and use --secrets with a .env path instead of the Desktop secrets API.
Does the Docker MCP Gateway block network access by default?
No. Its security model states that egress is not globally denied by default. Pass --block-network to restrict servers to their declared allowHosts, or use --network none for a hand-rolled server that needs no network.
Can I run remote MCP servers through the gateway?
Yes. Remote URLs must be public HTTPS by default; the gateway rejects loopback, private and cloud metadata addresses unless you set DOCKER_MCP_ALLOW_INSECURE_REMOTE_URLS=1 for development.
When I add a new MCP server, the first thing I check is the tool list after --read-only or the profile allowlist, because the smallest tool set is the cheapest control there is. Then I check egress: I want --network none or --block-network, and I want to have read the server's allowHosts. Secrets go in the keychain, never in a JSON args array. The official references are the Docker MCP Toolkit docs, the gateway security model, the filesystem server README and the Claude Code MCP docs. For the protocol side, including why Roots is going away, see the companion explainer.
Related: How to Connect MCP Servers to Claude Code, Cursor and VS Code (2026), Best MCP Servers for DevOps in 2026: 7 Official Servers, Read-Only First and MCP Security in 2026: The Protocol Got Hardened. The Ecosystem Didn't..
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Deploy a Secure Remote MCP Server with OAuth (2026)
Step-by-step: run a remote MCP server over Streamable HTTP as an OAuth 2.1 resource server with Keycloak 26.8.1 and MCP Python SDK 2.3.0, validate audience and scopes per tool, add rate limits and audit logs, deploy to Cloud Run, and fix 401 and discovery errors.
16 minNov 12, 2026MCP Changelog Tracker: Spec Versions, SDKs and Registry Status
A living tracker of the Model Context Protocol for engineers who run MCP servers: every specification revision, the latest release of each official SDK, official registry status and notable security advisories, each row sourced and kept current.
10 minNov 10, 2026How to Block Dangerous Commands With Claude Code Hooks (2026)
Step-by-step: stop Claude Code from running terraform apply, kubectl delete and rm -rf with permission deny rules, a tested PreToolUse hook that fails closed, and a strict sandbox. Exit codes, JSON output and troubleshooting per the current docs.
15 min