Docker's MCP Toolkit is a great way to expose Model Context Protocol servers to AI clients like Claude, n8n, or Cursor. Out of the box it's designed for Docker Desktop on macOS and Windows — but what if you want to run it on a headless Linux server? A Raspberry Pi, a VPS, a home lab box?
This guide walks through setting it up from scratch on Linux (Debian/Ubuntu, arm64 or amd64), including secrets management, custom MCP server images, and a systemd service that starts automatically on boot.
> This guide is based on getting it actually working on a Raspberry Pi 5 running
> Debian 13. Several things that look like they should work on Linux don't — I'll
> call those out explicitly so you don't waste time on the same dead ends.
---
## What We're Building
A self-hosted MCP gateway that:
- Runs any MCP server as a Docker container
- Exposes them all behind a single HTTP endpoint with Bearer token auth
- Starts automatically on boot via systemd
```plaintext
AI Client → http://your-server:8811/sse → docker-mcp gateway → MCP containers
```
---
## Prerequisites
- Linux host with Docker installed (Docker Engine, not Docker Desktop)
- Your user in the `docker` group (`sudo usermod -aG docker $USER`)
- SSH access if setting up remotely
---
## Step 1 — Install the docker-mcp Binary
The `docker mcp` command is a Docker CLI plugin. On Linux you install it directly from the GitHub releases — no Docker Desktop needed.
```bash
# Create the CLI plugins directory
sudo mkdir -p /usr/local/lib/docker/cli-plugins
# Download the binary for your architecture
# arm64 (Raspberry Pi 4/5, Apple Silicon VMs):
sudo curl -fsSL \
https://github.com/docker/mcp-gateway/releases/download/v0.41.0/docker-mcp-linux-arm64.tar.gz \
| sudo tar -xz -C /usr/local/lib/docker/cli-plugins/
# amd64 (regular x86 server):
sudo curl -fsSL \
https://github.com/docker/mcp-gateway/releases/download/v0.41.0/docker-mcp-linux-amd64.tar.gz \
| sudo tar -xz -C /usr/local/lib/docker/cli-plugins/
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-mcp
# Verify
docker mcp --version
```
Check the [releases page](https://github.com/docker/mcp-gateway/releases) for the latest version.
---
## Step 2 — The docker-pass Workaround
This is the first Linux-specific gotcha. `docker mcp` CLI commands (like `docker mcp server ls`) expect a Docker CLI plugin named `docker-pass`. This binary ships with Docker Desktop on macOS but not on Linux, causing this error:
```plaintext
docker pass has not been installed
```
The fix: a small wrapper script that satisfies the Docker CLI plugin protocol and delegates to `docker-credential-pass`.
First, install `docker-credential-pass`:
```bash
# arm64:
sudo curl -fsSL \
https://github.com/docker/docker-credential-helpers/releases/download/v0.9.5/docker-credential-pass-v0.9.5.linux-arm64 \
-o /usr/local/bin/docker-credential-pass
# amd64:
sudo curl -fsSL \
https://github.com/docker/docker-credential-helpers/releases/download/v0.9.5/docker-credential-pass-v0.9.5.linux-amd64 \
-o /usr/local/bin/docker-credential-pass
sudo chmod +x /usr/local/bin/docker-credential-pass
```
Then create the wrapper plugin:
```bash
sudo tee /usr/local/lib/docker/cli-plugins/docker-pass > /dev/null << 'EOF'
#!/bin/bash
if [[ "$1" == "docker-cli-plugin-metadata" ]]; then
echo '{"SchemaVersion":"0.1.0","Vendor":"Docker","Version":"v1.0.0","ShortDescription":"Docker Pass secrets helper"}'
exit 0
fi
exec docker-credential-pass "$@"
EOF
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-pass
```
Verify Docker recognizes it:
```bash
docker info --format '{{.ClientInfo.Plugins}}' | tr ',' '\n' | grep pass
# Should show: ...pass /usr/local/lib/docker/cli-plugins/docker-pass...
```
> **Note**: This wrapper is only needed for `docker mcp` CLI commands. The gateway
> itself uses a different mechanism for secrets — covered in Step 5.
---
## Step 3 — Pull or Load Your MCP Images
Any Docker image that implements the MCP stdio protocol can be used as an MCP server.
### Option A: Images from the official Docker MCP catalog
```bash
docker pull mcp/playwright:latest
```
### Option B: Custom/private images
Transfer from another machine:
```bash
# On the source machine:
docker save mcp/my-server:latest | ssh your-linux-host "docker load"
```
---
## Step 4 — Configure the Gateway
Create the config directory:
```bash
mkdir -p ~/.docker/mcp/catalogs
```
### registry.yaml — which servers to enable
```bash
cat > ~/.docker/mcp/registry.yaml << 'EOF'
registry:
playwright:
ref: ""
my-server:
ref: ""
EOF
```
### catalog.json — where to find catalog definitions
The gateway needs to know where your catalog files live. By default it only reads the official Docker MCP catalog. Register additional catalogs here:
```bash
cat > ~/.docker/mcp/catalog.json << 'EOF'
{
"catalogs": {
"docker-mcp": {
"displayName": "Docker MCP Catalog",
"url": "https://desktop.docker.com/mcp/catalog/v2/catalog.yaml"
},
"my-catalog": {
"displayName": "My Custom Servers",
"url": "/home/youruser/.docker/mcp/catalogs/my-catalog.yaml"
}
}
}
EOF
```
### A catalog YAML for a custom server
The catalog defines how a server runs and which env vars it needs. List **all** env vars under `secrets:` — including non-sensitive ones like usernames.
> **Linux gotcha**: The `config:` field in catalog YAMLs is not used for env var
> injection on Linux. Everything must be in `secrets:` to be passed to containers.
```yaml
registry:
my-server:
title: My MCP Server
description: Does something useful
image: mcp/my-server:latest
type: server
tools: []
secrets:
- name: my-server.api_key
env: API_KEY
description: API key for the service
- name: my-server.username
env: USERNAME
description: Your username
name: my-catalog
displayName: My Catalog
```
---
## Step 5 — Secrets
This is the second major Linux gotcha. `docker mcp` uses a secrets engine (`se://` URIs) that is Docker Desktop-only and doesn't work on Linux. The `docker mcp secret set` command will fail with `docker pass has not been installed` even after you install the wrapper from Step 2.
The solution is the `--secrets` flag, which points the gateway at a plain env file:
```bash
# Create the secrets file
cat > ~/.docker/mcp/secrets.env << 'EOF'
my-server.api_key=your-api-key-here
my-server.username=your-username
EOF
# Restrict permissions — only your user can read it
chmod 600 ~/.docker/mcp/secrets.env
```
The key names map to the `name` field in the catalog's `secrets:` list.
**Is this secure?** The file is `chmod 600` — readable only by your user, same as `~/.ssh/id_rsa`. Anyone who can read it already has root or is you. If you want GPG encryption at rest, you can store sensitive values in `pass` and populate the file from it — but for an unattended service the threat model is the same either way.
**Are secrets isolated between MCP servers?** Yes, completely. The secrets file is never passed to or mounted into any container. The gateway reads it internally and uses it purely as a lookup table. When spawning each container it passes only the specific `-e VAR=value` flags declared in that server's catalog `secrets:` list. You can verify this with `--dry-run --verbose` — the `docker run` command for each server is logged in full, and you'll see that playwright gets zero secret env vars, while my-server only gets `USERNAME` and `API_KEY`. There is no way for one MCP server to access another's credentials.
---
## Step 6 — Test the Gateway
Do a dry run first:
```bash
docker mcp gateway run \
--dry-run \
--verbose \
--secrets ~/.docker/mcp/secrets.env \
2>&1
```
You should see all your configured servers listed and their tools counted, with no `Warning: Secret '...' not found` lines. If warnings appear, check that the key names in `secrets.env` exactly match the `name` fields in your catalog YAML.
If everything looks good, start it live:
```bash
docker mcp gateway run \
--transport sse \
--port 8811 \
--secrets ~/.docker/mcp/secrets.env
```
---
## Step 7 — systemd Service
Create the service file:
```bash
sudo tee /etc/systemd/system/mcp-gateway.service > /dev/null << EOF
[Unit]
Description=Docker MCP Gateway
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$(whoami)
Environment=HOME=$HOME
ExecStart=/usr/local/lib/docker/cli-plugins/docker-mcp gateway run \\
--transport sse \\
--port 8811 \\
--secrets $HOME/.docker/mcp/secrets.env
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
```
Set a stable Bearer token that survives restarts:
```bash
sudo mkdir -p /etc/systemd/system/mcp-gateway.service.d
TOKEN=$(openssl rand -hex 32)
echo "Save this token: $TOKEN"
sudo tee /etc/systemd/system/mcp-gateway.service.d/token.conf > /dev/null << EOF
[Service]
Environment=MCP_GATEWAY_AUTH_TOKEN=$TOKEN
EOF
```
Without this, the gateway generates a new random token on every start — which means reconfiguring every client after each restart.
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable mcp-gateway.service
sudo systemctl start mcp-gateway.service
```
Check it's running:
```bash
sudo systemctl status mcp-gateway.service
journalctl -u mcp-gateway.service -f
```
---
## Connecting a Client
The gateway runs on port `8811` with SSE transport:
```http
URL: http://your-server:8811/sse
Auth: Authorization: Bearer <your-token>
```
### Claude Desktop
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`
(macOS) or the equivalent on your OS:
```json
{
"mcpServers": {
"my-gateway": {
"command": "npx",
"args": [
"mcp-remote",
"http://your-server:8811/sse",
"--header",
"Authorization: Bearer <your-token>",
"--allow-http",
"--transport",
"sse-only"
]
}
}
}
```
Two flags are required here that aren't obvious:
- `--allow-http` — `mcp-remote` blocks non-HTTPS URLs by default
- `--transport sse-only` — the default `http-first` strategy sends a POST that
the gateway rejects with `sessionid must be provided`
---
## Troubleshooting
### `docker pass has not been installed`
The `docker-pass` CLI plugin wrapper is missing or not executable. Re-check Step 2.
### `Warning: Secret '...' not found`
The key name in `secrets.env` doesn't match the `name` field in the catalog YAML, or you forgot to pass `--secrets` to the gateway. Check with:
```bash
docker mcp gateway run --dry-run --verbose --secrets ~/.docker/mcp/secrets.env 2>&1 | grep Warning
```
### Server shows 0 tools in dry-run but works live
Some servers need the actual secrets present to respond to tool listing. This is normal — the gateway still starts them correctly at runtime.
### `cannot use --port with --transport=stdio`
You must specify `--transport sse` when using `--port`. The default transport is stdio (for direct client connections), not HTTP.
### `sessionid must be provided` in mcp-remote
Add `--transport sse-only` to the `mcp-remote` args. The default transport strategy tries Streamable HTTP first, which the gateway doesn't support.
---
## Keeping Things Updated
### Upgrade docker-mcp
```bash
VERSION=v0.41.0 # replace with latest
ARCH=arm64 # or amd64
sudo curl -fsSL \
https://github.com/docker/mcp-gateway/releases/download/$VERSION/docker-mcp-linux-$ARCH.tar.gz \
| sudo tar -xz -C /usr/local/lib/docker/cli-plugins/
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-mcp
sudo systemctl restart mcp-gateway.service
```
### Update a custom MCP image
```bash
docker save mcp/my-server:latest | ssh your-linux-host "docker load"
ssh your-linux-host "sudo systemctl restart mcp-gateway.service"
```
---
## Summary
The key differences from macOS Docker Desktop:
| Concern | macOS (Docker Desktop) | Linux (headless) |
|---|---|---|
| `docker-mcp` binary | Bundled with Docker Desktop | Downloaded from GitHub releases |
| `docker-pass` plugin | Proprietary binary | Wrapper script → `docker-credential-pass` |
| Secrets injection | `docker mcp secret set` + keychain | `--secrets <env-file>` (chmod 600) |
| Auto-start | Docker Desktop | systemd service |
| Transport | stdio or SSE | SSE with `--transport sse` |
| mcp-remote | Default settings work | Needs `--allow-http --transport sse-only` |
Everything else — catalog YAMLs, registry.yaml, the `docker mcp` CLI — works identically between macOS and Linux once the above pieces are in place.