Plinth / guides / deploy-mcp-server-vps

Deploying an MCP server to a VPS

~6 min read · Deployment · Docker · systemd

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.

Don't skip Put auth in front of a network-exposed MCP server before anything else. A remote transport with no auth is an open RPC endpoint to whatever your tools can do. Gate every privileged tool behind a token checked before the handler.

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

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