−25%

on annual Windows plans, until 31 Oct. See plans

EQVPS
Get started

VPS for an MCP gateway: several MCP servers behind one address and one token

Your team uses three self-hosted MCP servers and everyone has three URLs and three secrets. Put them behind one HTTPS address on a small VPS: Caddy checks a bearer token, routes by path, and keeps the servers themselves off the internet. A tested config.

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.

Ready to deploy? Pay with crypto, no KYC — live in about a minute.

Deploy now →

FAQ

Why put MCP servers behind a gateway at all?

One address and one token per team instead of one per server, TLS handled in one place, and the servers themselves listening only on localhost. Adding a fourth server becomes a three-line change on the gateway, not a new secret for everyone.

Which plan do I need?

A plan with its own IPv4, because clients have to reach ports 80 and 443. Nano-IP ($8/mo, 2 vCPU, 1 GB) is enough for a gateway and a few lightweight MCP servers. If a server does heavy work itself, size for that instead.

Does streaming work through Caddy?

Yes. Streamable HTTP uses ordinary POST requests plus server-sent events. Set flush_interval -1 on the reverse proxy so events are passed through immediately instead of buffered.

Is one shared token secure enough?

For a small team, it's a reasonable start: the token never leaves HTTPS, and you rotate it by changing one file. When you need per-person access or revocation, put a proper auth layer in front. The shared token is the floor, not the ceiling.

Can I run this on a NAT plan?

Not as a public gateway. A NAT plan has one forwarded port for SSH and nothing else inbound, so clients can't reach 443. It works only if every client sits on the same server.

Comments

No comments yet. Be the first.

Leave a comment

Comments are moderated before they appear.