Deploying an MCP server to a VPS
Local stdio is fine for desktop clients. The moment you want a shared, always-on MCP server — for a team, a hosted agent, or a remote client — you need it running on a box that survives reboots and that you can observe. A small VPS plus Docker is the pragmatic default. Here's the shape of a deploy that won't page you at 3am.
1. Decide on transport
Remote deployments usually mean an HTTP/SSE transport rather than stdio, because the client isn't spawning your process — it's connecting over the network. Whichever you run, the logging rule still applies: structured logs to stderr, never stdout. Put the server behind a reverse proxy (Caddy or nginx) for TLS.
2. Dockerize it properly
A hardened image is multi-stage (build deps stay out of the final layer), runs as a non-root user, and declares a health check. Sketch for a Python server:
FROM python:3.12-slim AS build
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir .
FROM python:3.12-slim
RUN useradd -m app
USER app
WORKDIR /app
COPY --from=build /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . .
HEALTHCHECK --interval=30s --timeout=3s CMD ["python", "-m", "healthcheck"]
CMD ["python", "-m", "server"]
Don't run as root. Don't bake secrets into layers — pass them as environment variables at runtime. Keep the base image slim to shrink your attack surface.
3. Run it so it survives reboots
The simplest durable setup is a restart policy plus Compose:
services:
mcp:
build: .
restart: unless-stopped
environment:
- AUTH_TOKEN=${AUTH_TOKEN}
ports:
- "127.0.0.1:8080:8080"
Binding to 127.0.0.1 keeps the app off the public internet; your reverse proxy
terminates TLS and forwards to it. restart: unless-stopped brings it back after a
crash or a host reboot. If you prefer no Docker, a systemd unit with
Restart=always does the same job.
4. Wire up health checks
Expose liveness and readiness so your proxy or orchestrator knows the real state. Liveness:
"the process responds." Readiness: "dependencies are reachable and warm-up is done." Point the
Docker HEALTHCHECK and your proxy's upstream check at these. A server that's up but
not ready should fail readiness so traffic waits instead of erroring.
5. Observe it
Because logs are structured JSON on stderr, Docker captures them with
docker logs and you can ship them to wherever you keep logs. Add a metric or two —
request count, error rate, rate-limit trips — so you find out about trouble before your users
do. Make the server fail loudly: a clear error in the logs beats a silent hang every time.
6. Deploy checklist
- ☐ Non-root, multi-stage, slim container
- ☐ Bound to localhost, TLS at the reverse proxy
- ☐ Auth token required on every privileged tool
- ☐
restart: unless-stopped(orsystemd Restart=always) - ☐ Liveness + readiness checks wired to proxy/orchestrator
- ☐ Secrets via env, never in the image
- ☐ Structured stderr logs captured and shipped
- ☐ Graceful shutdown so deploys don't truncate responses
Deploy from a base that's already hardened
The Plinth MCP Server Starter Kit ships multi-stage, non-root Dockerfiles for both the Python and TypeScript templates, with health checks, graceful shutdown, auth and stderr logging already in place — and 42 tests over the real protocol so you deploy with confidence.
Get the kit — $39