Self-hosted MCP servers multiply. First there's one for your internal docs, then one for the database, then someone writes a small one for the deploy pipeline. Each gets its own port, its own URL and its own secret, and every person on the team pastes all three into their client config. Rotating a token means messaging everyone.
A gateway turns that into one address and one token. Clients connect to https://mcp.example.com/docs/mcp, .../db/mcp and so on. The gateway checks the token, sends each path to the right server on localhost, and the servers themselves are never exposed. It needs very little: a small VPS with its own IPv4 and Caddy.
We built the setup below on a test server and checked it with real clients before writing this.
What you need
- A VPS with a public IPv4, so clients can reach 443. Nano-IP is enough unless your MCP servers do heavy work themselves.
- A domain with an A record pointing at the server, here
mcp.example.com. - Ubuntu 24.04 or Debian 12/13. Caddy is in the standard repositories of both.
1. Run the MCP servers on localhost
Each server runs as a systemd service. Here we use the reference server-everything twice so the example is self-contained; swap in your own. It needs Node.js 20 or newer:
mkdir -p /opt/mcp && cd /opt/mcp
npm install @modelcontextprotocol/server-everything
# /etc/systemd/system/mcp-everything-1.service
[Unit]
Description=MCP server everything #1
After=network.target
[Service]
WorkingDirectory=/opt/mcp
Environment=PORT=3101
ExecStart=/opt/mcp/node_modules/.bin/mcp-server-everything streamableHttp
Restart=always
User=nobody
[Install]
WantedBy=multi-user.target
A second copy uses port 3102. Then:
systemctl daemon-reload
systemctl enable --now mcp-everything-1 mcp-everything-2
One thing we noticed while testing: this server listens on all interfaces, not just 127.0.0.1, and plenty of others do the same. Step 3 deals with that.
2. Caddy: token check, then route by path
Generate a token and give it to Caddy through an environment file:
apt install -y caddy
echo "MCP_GATEWAY_TOKEN=$(openssl rand -hex 32)" > /etc/caddy/gateway.env
chmod 600 /etc/caddy/gateway.env
mkdir -p /etc/systemd/system/caddy.service.d
printf '[Service]\nEnvironmentFile=/etc/caddy/gateway.env\n' > /etc/systemd/system/caddy.service.d/env.conf
systemctl daemon-reload
The Caddyfile:
mcp.example.com {
route {
@unauthorized not header Authorization "Bearer {$MCP_GATEWAY_TOKEN}"
respond @unauthorized 401
handle_path /everything/* {
reverse_proxy 127.0.0.1:3101 {
flush_interval -1
}
}
handle_path /tools2/* {
reverse_proxy 127.0.0.1:3102 {
flush_interval -1
}
}
respond 404
}
}
caddy validate --config /etc/caddy/Caddyfile && systemctl restart caddy
Caddy gets the certificate for mcp.example.com on its own.
The route block is not decoration. Our first version had the token check and the handle_path blocks side by side at the top level, and requests without a token went straight through with 200. Caddy sorts directives in its own order, and handle_path runs before respond. Inside route they run in the order written. If you adapt this config, test the "no token" case before anything else.
handle_path strips the prefix, so /everything/mcp reaches the server as /mcp. flush_interval -1 passes streamed events through as they arrive.
3. Close everything except SSH and HTTPS
apt install -y ufw
ufw default deny incoming
ufw allow OpenSSH
ufw allow 80,443/tcp
ufw enable
After that, ports 3101 and 3102 were unreachable from outside the server in our test, while 22 and 443 answered. Port 80 stays open only so Caddy can obtain and renew certificates and redirect to HTTPS.
4. Check it
TOKEN=$(cut -d= -f2 /etc/caddy/gateway.env)
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"1"}}}'
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.com/everything/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$INIT"
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.com/everything/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$INIT"
The first request should print 401 and the second 200. In our run, a wrong token and a truncated one both got 401, and an unknown path got 404 even with the right token.
Then from a client, for example Claude Code:
claude mcp add --transport http everything https://mcp.example.com/everything/mcp \
--header "Authorization: Bearer $TOKEN"
claude mcp list
Both servers showed ✔ Connected. The same client with a wrong token showed ✘ Failed to connect. More on client setup in Connect EQVPS MCP to Claude Code and Codex.
Adding the next server
Start it on a new localhost port, add one handle_path block, reload Caddy. Nobody's config changes except a new URL under the same host and the same token.
Limits worth knowing
- One token for everyone is simple and rotating it is one file, but you can't revoke one person without changing it for all. For per-user access, put an auth proxy in front or give each person their own route and token.
- The gateway doesn't make a server safe. An MCP server that runs shell commands is still a shell for whoever holds the token. Keep tokens out of shared configs and git.
- Size for the servers, not the gateway. Caddy itself uses a few tens of megabytes. If one of your servers runs a local model or indexes a large repo, it decides the plan, not the proxy.
Running only one MCP server? VPS for MCP servers covers that simpler case. Want your agents to also manage the server they live on? The EQVPS MCP server does that (or the REST API if you'd rather script it), and plans with their own IPv4 start at $8 a month.
Comments
No comments yet. Be the first.