How to Run MCP Servers in Docker Securely (2026 Guide)

Nov 10, 2026 · 12 min · Ajay Kumar

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 run for a server that is not in the catalog, or when you want to see every flag yourself.
Docker MCP Gateway: where each control sits MCP client Claude Desktop, Claude Code starts the gateway as a stdio server named MCP_DOCKER stdio docker mcp gateway --block-secrets on by default --verify-signatures on (mcp/ images) tool allowlist per profile call logs: tool names, not values Server containers one container per server 1 CPU, 2 GB by default no-new-privileges no host env, ro binds default Secrets OS keychain or OAuth token scoped to the declaring server Network egress --block-network + allowHosts e.g. api.github.com:443
Controls described in the Docker MCP Gateway security model and the docker mcp gateway run flag reference (defaults for v0.44.3). Network egress is only restricted when you add --block-network.

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