VPS Tutorials
Self-Hosting an MCP Server on a VPS (2026): A Secure Deployment Guide with Streamable HTTP, OAuth, Docker and Caddy
Want to put an MCP Server on a VPS so Claude, Cursor and other clients can use it remotely? This article deploys a Streamable HTTP service with FastMCP, Docker Compose and Caddy, and explains OAuth, Origin validation, least privilege, logging and common errors.

Self-Hosting an MCP Server on a VPS (2026): A Secure Deployment Guide with Streamable HTTP, OAuth, Docker and Caddy
Most people's first experience with MCP is running stdio locally: Claude Desktop, Claude Code or Cursor spawns a child process, and as long as the tools work, it counts as a success.
But as soon as you run into any of the following situations, local stdio is no longer enough:
- You want to share one set of tools across a laptop, a desktop and a remote dev machine
- You want to give the tools to team members or multiple clients at once
- You need a remote entry point that is always online, auditable and upgradeable
That is when a remote MCP Server on a VPS comes into play.
Let's put the conclusion up front: for new remote MCP deployments in 2026, the main line should be Streamable HTTP, not the old HTTP+SSE. stdio is still the default for local integration; it is only worth putting on a VPS when you genuinely need remote access, HTTPS, authentication, a reverse proxy and multi-client reuse.
A note on scope: this article follows the published MCP
2025-11-25specification, and also reminds you that protocol version negotiation happens during initialization. In other words, the client, SDK and server do not necessarily all sit on the same version number; in HTTP scenarios you should look at the negotiation result andMCP-Protocol-Version, and not hard-code the claim that "all clients only support one particular version".
If your tools are only used on the local machine — reading a local code repository, calling a local database, running scripts only you use — stdio is still the least hassle.
The boundaries of stdio are clear:
- The client spawns the child process itself
- Permissions basically inherit the local execution environment
- No domain, reverse proxy, HTTPS or OAuth needed
- It is also not suitable for direct remote connections from other devices
Remote MCP is better suited to these scenarios:
- You want to expose read-only business tools behind a public HTTPS entry point
- You want Claude, Cursor and an internal tool console to share one service
- You want to centralize logging, quotas, upgrades and downstream API access
If you are still working out how to choose a VPS, start with the VPS configuration selection guide.
| Method | What it suits | How to use it now |
|---|---|---|
stdio | Local clients spawning local tools | Keep using it; the local default |
Old HTTP+SSE | Compatibility with old clients | Keep only when you must support old implementations |
Streamable HTTP | The new main line for remote deployment | VPS, self-hosting, HTTPS and auth all go through it |
There are three key points:
stdiois not a remote deployment protocol. It is the way a "client launches a local child process".- Streamable HTTP uses a single MCP endpoint. The client sends JSON-RPC via POST, and the request can return JSON or an SSE stream; when necessary the client can also send a GET to the same endpoint to establish a server-to-client message stream.
- Old HTTP+SSE has been superseded. The old "dual endpoint" pattern is only suitable for compatibility with legacy clients and should no longer be the default for new projects.
So if you are building a new service on a VPS now, the most reliable approach is:
- For new clients: prefer Streamable HTTP
- For old clients: make an explicit compatibility decision, and keep the old endpoint separately if necessary
- Do not do "automatic fallback to a looser, less secure old mode"
The MCP Server itself is often not the most resource-hungry part; what really consumes resources is usually:
- Downstream databases
- Browser automation
- Vector search
- Model API calls
- File conversion, OCR, crawlers
If the example in this article is just a read-only tool, 1C1G or 2C2G can serve as a starting point for "the scale of this article's example". But this is not an official MCP minimum requirement, nor a general promise. Ultimately you should size according to your own tool workload.
In practice you should pay more attention to:
- Whether your tools hit downstream APIs concurrently
- Whether you need state caching or session storage
- How large the log volume, backup volume and failure retry volume are
- Whether you will share the same VPS with other applications
If you plan to run multiple services with Compose, see also the Docker Compose production health check and resource limits guide.
This article uses a deliberately restrained architecture:
Claude / Cursor / 其他 MCP Client
↓ HTTPS
Caddy
↓ Docker 内网
FastMCP Streamable HTTP
↓ allowlist / 只读访问
固定的数据源或内部 API
Here you need to separate protocol requirements from operational choices:
- At the MCP protocol level: authorization is an optional capability for the MCP ecosystem as a whole; not every implementation must use OAuth.
stdiolocal scenarios usually inherit the local execution environment; but this article's public-facing HTTP production architecture, reachable by remote clients, must have authentication, and once you implement MCP HTTP auth you should follow the MCP authorization specification for resource metadata, Bearer headers, token audience/resource validation and HTTPS. - Operational choices in this article: FastMCP, Docker Compose, Caddy, exposing only 80/443, a separate health endpoint, log rotation, resource limits
In other words, Caddy and Compose are not MCP protocol requirements; they are simply convenient and easy to maintain for this implementation.
In the example I deliberately avoid arbitrary shell, arbitrary paths, arbitrary URL fetching, and any "universal query tool". The biggest fear with remote MCP is not having too few features, but an entry point that is too broad, parameters that are too free, and in the end the model can try out anything.
Below is a complete app.py that lines up with the Dockerfile, Compose and Caddy that follow. It puts the read-only tool, JWTVerifier, the non-sensitive /health route and app = mcp.http_app(path="/mcp") in the same file; Host / Origin protection is still left to the official environment variable configuration that follows:
import os
from typing import Literal
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier
from starlette.requests import Request
from starlette.responses import PlainTextResponse
auth = JWTVerifier(
jwks_uri=os.environ["AUTH_JWKS_URI"],
issuer=os.environ["AUTH_ISSUER"],
audience=os.environ["AUTH_AUDIENCE"],
)
mcp = FastMCP("ops-readonly", auth=auth)
SERVICE_STATE = {
"api": {"status": "ok", "latency_ms": 32},
"worker": {"status": "degraded", "latency_ms": 85},
"billing": {"status": "ok", "latency_ms": 41},
}
@mcp.tool()
def get_service_status(service: Literal["api", "worker", "billing"]) -> dict:
"""Return a tiny, read-only status snapshot for one allowed service."""
item = SERVICE_STATE[service]
return {
"service": service,
"status": item["status"],
"latency_ms": item["latency_ms"],
}
@mcp.custom_route("/health", methods=["GET"])
async def health(_: Request) -> PlainTextResponse:
return PlainTextResponse("OK")
app = mcp.http_app(
path="/mcp",
)
The security boundary of this example is fairly clear:
- Input is not an arbitrary string but a fixed allowlist
- Output is small JSON, with no tokens, passwords or raw logs
- No side effects: no writing files, no deleting data, no external requests
/healthexists separately, so operational liveness checks don't need to actually call a high-privilege tool- Authentication is attached to
FastMCP(..., auth=auth)from the start, so it never becomes a public unauthenticated/mcp app = mcp.http_app(path="/mcp")and the lateruvicorn app:app, Compose and Caddy all point to the same entry point- Host / Origin protection is not "crammed" into
http_app(...)via custom parameters, but provided uniformly by the laterFASTMCP_HTTP_HOST_ORIGIN_PROTECTION,FASTMCP_HTTP_ALLOWED_HOSTSandFASTMCP_HTTP_ALLOWED_ORIGINS
If you want to connect tools to a database, ticketing system or internal API, keep following this idea: one tool does one thing, narrow the parameter range where you can, and return as few fields as possible.
6. Going live with Docker Compose: pin versions, limit privileges, don't expose the app port directly
The most common pitfall in production is, for convenience, exposing ports: ["8000:8000"] directly to the outside and then temporarily running pip install at container startup, so that neither dependency versions nor image contents are reproducible. This works short-term but easily leaves holes long-term.
A more stable approach: install dependencies at image build time, have the app serve 8000 only on the Docker internal network, and give the public entry point only to Caddy.
First pin the dependencies in requirements.txt:
fastmcp==3.2.4
uvicorn==0.35.0
Then use an explicit Dockerfile:
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt \
&& useradd --create-home --uid 10001 appuser
COPY app.py /app/app.py
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
The point of this app.py is not "every MCP must copy it", but: when you put a remote HTTP MCP into a public production environment, at minimum it should have authentication enabled from day one, rather than being a public unauthenticated endpoint. If you later connect a provider such as Keycloak, Scalekit or Azure, follow their respective official provider documentation; don't invent your own OAuth code.
services:
mcp-app:
build:
context: .
dockerfile: Dockerfile
image: mcp-server:2026-07
expose:
- "8000"
environment:
FASTMCP_HTTP_HOST_ORIGIN_PROTECTION: "true"
FASTMCP_HTTP_ALLOWED_HOSTS: '["mcp.example.com"]'
FASTMCP_HTTP_ALLOWED_ORIGINS: '["https://app.example.com"]'
AUTH_JWKS_URI: "https://auth.example.com/.well-known/jwks.json"
AUTH_ISSUER: "https://auth.example.com"
AUTH_AUDIENCE: "https://mcp.example.com/mcp"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
user: "10001:10001"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 512m
cpus: 1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- edge
caddy:
image: caddy:2.10
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
mcp-app:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- edge
networks:
edge:
volumes:
caddy-data:
caddy-config:
Several points in this Compose file are added deliberately:
- Pinned versions: the example pins
python:3.12-slim,fastmcp==3.2.4,uvicorn==0.35.0andcaddy:2.10; at the very least don't uselatest - Dependencies installed at build time: no more temporary
pip installat container startup, so image contents are reproducible - App port not exposed directly: only Caddy exposes 80/443 to the public
- Separate liveness check: the healthcheck goes through
/healthand does not actually execute business tools - Least privilege:
read_only,cap_drop: [ALL], non-root,no-new-privileges - Resource limits: so an abnormal request can't drag down the whole machine
- Host / Origin protection: using the official
FASTMCP_HTTP_ALLOWED_HOSTS,FASTMCP_HTTP_ALLOWED_ORIGINSandFASTMCP_HTTP_HOST_ORIGIN_PROTECTION - Credential injection: settings like
AUTH_JWKS_URIcan go through environment variables; if your provider also needs a client secret, switch to secret file injection — don't write it into the image or Git - Log rotation: at least keep container log size under control
For genuinely long-running deployments, it is advisable to pin the base image digest as well, and run through a staging environment before upgrading.
mcp.example.com {
encode zstd gzip
@health path /health
handle @health {
reverse_proxy mcp-app:8000
}
handle /mcp* {
reverse_proxy mcp-app:8000 {
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
transport http {
read_timeout 30s
write_timeout 30s
}
}
}
request_body {
max_size 2MB
}
log {
output stdout
format json
}
}
The point here is not that "Caddy is more advanced than something else", but that it happens to be well suited to a few things:
- Automatic HTTPS
- A unified public entry point
- Keeping the application container on the internal network only
- Adding timeouts, request body limits and access logs at the proxy layer
Here I chose Caddy outputting to stdout, with log rotation handled by the Docker json-file driver. Be clear about the boundary: Caddy is responsible for producing structured access logs; what actually rotates by 10m * 3 is the container runtime, not Caddy's own file roller.
There is also an easily missed pitfall: don't mix /mcp and /mcp/. If some proxy or client first gets a 301/308 redirect and then re-sends the request, the Authorization header may be dropped. The most reliable approach: write the same exact URL in app.py, Caddy and the client configuration — for example, all write https://mcp.example.com/mcp — and don't rely on redirects to fix it automatically.
If you are not yet familiar with this layer, start with the Caddy reverse proxy and automatic HTTPS guide.
This section is especially easy to get wrong.
In the HTTP Authorization header:
Authorization: Bearer your-access-token
Don't put it in the query string, don't put it in the browser address bar, and don't make a "fixed URL token" for convenience. The specification is explicit that access tokens in HTTP requests should not go into the URI.
At minimum validate:
- Whether the token was issued by the corresponding authorization server
- Whether the audience / resource points to the current MCP resource
- Whether it has expired, and whether the scope is insufficient
This is especially important because token passthrough is an anti-pattern. That is, you must not forward a client's token to a downstream API without audience validation.
It mainly defends against DNS rebinding-type issues. For Streamable HTTP, if Origin is present and invalid, the server should return 403 Forbidden directly. This is not a "bonus point" but one of the basic defenses of remote MCP.
No.
A session only correlates a set of interactions, making it easier for the server to maintain state, resume SSE streams and identify the session lifecycle. It cannot replace authentication. Real authentication still depends on the Bearer token in each HTTP request.
The specification is also clear: if the server ends a session and the client later requests with that session ID, the server should return 404 Not Found, and the client should re-initialize rather than treating the old session as a permanent pass.
One more point directly related to deployment shape: a single-machine, single-process VPS can keep using stateful sessions. But if you later move to multiple workers, multiple containers or horizontal scaling, you should follow the stateless_http=True design in the FastMCP documentation, and handle shared state, session recovery and consistency of external storage in advance.
The FastMCP documentation already gives directions for HTTP auth, StaticTokenVerifier, JWTVerifier and different providers. Look at it on two levels:
- MCP specification level: HTTP auth is an optional capability, but once you implement it, follow the MCP authorization specification
- This article's public deployment: authentication is required by default, so you must not expose
/mcpas a public unauthenticated entry point
If you want an example that already appears in the official documentation with stable syntax, JWTVerifier is more reliable than "me hand-writing OAuth provider wiring code for you". If you haven't fully confirmed the provider API of the current stable branch, don't copy a "looks like it runs" OAuth snippet from a blog into production.
A more reliable approach is to fix the boundaries first:
- The remote service only exposes the HTTP transport
- Authentication goes through a standard OAuth / JWT provider
- Resource metadata and authorization server discovery are exposed per the specification
- Caddy handles HTTPS; the application handles Bearer validation and Origin validation
- Sessions do sessions, not auth
Once a remote MCP is live, "it connects" is not the end; you need all of the following outcomes:
- With a valid Bearer token, complete initialize / list-tools / tool call: success
- No token: 401
- Origin not in the allowlist: 403
- Session expired or unknown: 404, then the client re-initializes
- Server does not support a method: 405
- Wrong Content-Type or unsupported media type: 415
If you also need to support old clients, write the policy down clearly:
- New clients: connect directly to Streamable HTTP
/mcp - Old clients: you decide whether to additionally provide an old HTTP+SSE compatibility entry point
- Don't disable authentication, disable Origin validation, or open a "temporary unauthenticated endpoint" just to support old clients
Being compatible is one thing; keeping the same security boundary while being compatible is another. The latter determines whether you are running a production service or just moved a demo to the public internet.
The biggest misconception about remote MCP is putting all the risk on "public exposure". In fact, even with HTTPS and OAuth enabled, the following problems still get you:
- Prompt injection: the model receives malicious context and is induced to call tools it shouldn't
- Data exfiltration: tools return too many fields and carry internal data out of bounds
- Least privilege failure: you only wanted to read status, but the tool can read the whole database, run shell, scan directories
- Log leakage: tokens, full parameters and raw downstream responses get written into logs
I recommend using this checklist to review your service:
- Is every tool least-privilege?
- Are there high-risk entry points like arbitrary shell, arbitrary files or arbitrary URLs?
- Is output field-trimmed?
- Are downstream API keys injected via environment variables or secret files?
- Is outbound access allowlisted or at least controlled?
- Are logs redacted?
- Before upgrading, did you read the spec, SDK and FastMCP release notes?
- Are you backing up source code, Compose, Caddy configuration and necessary state, rather than expecting "the container itself is the backup"?
Three especially practical operational recommendations here.
First, logs must support troubleshooting, but must not fill in context for attackers. A more reliable approach is to record request ID, tool name, duration, HTTP status and whether downstream calls timed out; don't write Bearer tokens, full user input or raw upstream responses to disk in full.
Second, check three things before upgrading: MCP specification changes, FastMCP/official SDK release notes, and the client version you are using. Many "it connected yesterday, today it's 401 / 415" problems are not because the code suddenly broke, but because the transport, header or auth conventions changed.
Third, maintain recovery steps as a production asset. At minimum, write down domain switching, Caddy data directory recovery, secret file sources, re-issuing downstream allowlists, and old image rollback commands. When remote MCP really fails, the most valuable thing is not how many configuration snippets you have, but whether you can restore /mcp within 15 minutes.
If your MCP also connects to a self-hosted AI gateway, it's best to draw the permission boundary separately; don't casually mash together an MCP Server that can read data and an AI gateway that can switch upstream models. For related ideas, see the AI API gateway self-hosting guide.
Don't skimp on backups either: at minimum rehearse the recovery process for source code, configuration, secret management and necessary state. In a real incident, whether you have rehearsed matters more than "how many tar packages you backed up"; see also the backup and recovery drill guide.
Usually:
- No Bearer token
- Token expired
- Token was not issued for the current MCP resource
- Authorization server discovery chain misconfigured
First check WWW-Authenticate and the resource metadata address, then confirm the resource / audience matches.
The two most common directions:
Originis invalid- Insufficient scope
If it's the latter, the specification allows the server to tell the client in WWW-Authenticate which scopes are missing. Don't grant all scopes at once; escalating gradually per least privilege is more reliable.
In remote MCP, 404 is not necessarily a wrong path; it may also be an invalidated session. If the request carries an old MCP-Session-Id, the server can return 404 directly, and the client should re-initialize.
Commonly seen when:
- The client sends GET to an endpoint that doesn't support SSE streams
- You misconfigured the proxy rules and a method is blocked
- The server doesn't allow DELETE to end a session
Usually the request headers are wrong, e.g. a JSON-RPC POST without the correct Content-Type or Accept as required. These problems are often not server logic errors but the client or proxy layer mangling the headers.
If you are building a new remote service, FastMCP is well suited to a first version, for a simple reason: short examples, complete HTTP deployment material, and easy-to-control read-only tool boundaries.
If you are a Node.js team, or need finer control over middleware, HTTP transport and OAuth helpers, the official TypeScript SDK is more flexible.
If you already have a batch of local stdio tools and don't want to rewrite them, then consider bridging solutions like mcp-proxy. But that is more of a migration path, not the default shape for new projects recommended in this article.
One more easy pitfall: the main branches of the official Python SDK and TypeScript SDK repositories are both pushing v2 beta now. What is genuinely stable in production is still the v1.x line.
- Python SDK: the
mainREADME explicitly states v2 is pre-release, and production should look atv1.x - TypeScript SDK:
mainalso explicitly states v2 beta, and production should keep usingv1.x
So when you go to production today, don't just copy new examples from the main branch. First confirm which stable line, which documentation and which protocol version you are actually following.
If you only use tools locally, keep using stdio; if you really want to put it on a VPS for remote access by multiple clients, Streamable HTTP + HTTPS + standard OAuth/Bearer + Origin validation + least-privilege tools is the more reliable main line.
FastMCP, Docker Compose and Caddy are all good implementation choices, but they solve "how to deploy and how to operate", not "the protocol can ignore security". What really determines whether this remote MCP can run long-term is still those old, practical things:
- Are tool boundaries narrow enough
- Is token audience validation done
- Has a session been mistaken for authentication
- Are logging, backup and upgrades institutionalized
Do these well, and remote MCP will behave like a long-term service rather than a demo that happens to run for now.
- MCP Transports (2025-11-25)
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports - MCP Authorization (2025-11-25)
https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization - MCP Security Best Practices (2025-11-25)
https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices - Build MCP Server
https://modelcontextprotocol.io/docs/develop/build-server - Connect Remote Servers
https://modelcontextprotocol.io/docs/develop/connect-remote-servers - Official Python SDK Repository
https://github.com/modelcontextprotocol/python-sdk - Official TypeScript SDK Repository
https://github.com/modelcontextprotocol/typescript-sdk - FastMCP HTTP Deployment
https://gofastmcp.com/deployment/http - FastMCP Authentication
https://gofastmcp.com/servers/auth/