Self-Hosted MCP Server Setup and Security Guide

Self-Hosted MCP Server Setup and Security Guide

Local stdio servers are fine for one developer on one laptop. The moment a team needs shared tool access — or the tool lives inside a private network segment — you need a self-hosted MCP server: a network service with real authentication, containerization, and a security boundary you control. This guide covers the full self-hosted MCP server setup: a Python implementation, Docker packaging, authentication, and the security practices that make the deployment defensible in review.

Target audience: DevOps and platform engineers comfortable with Python, Docker, and reverse proxies. Every section is implementation-first.

Why Self-Host Your MCP Server?

Four forces push teams from local servers to self-hosted deployments:

  • Shared access without shared secrets. With local servers, every developer holds their own copy of the database credentials in their own config. A self-hosted server centralizes credentials server-side; clients authenticate with individual, revocable tokens.
  • Network locality. Internal databases, private APIs, and on-prem systems often aren’t reachable from developer laptops at all. A server deployed inside the network boundary can reach them; clients only need to reach the server.
  • Operational control. Centralized deployment means centralized logging, rate limiting, version pinning, and upgrade cycles — one server to patch instead of N laptops.
  • Compliance and audit. A single chokepoint through which all agent-tool interactions flow is what makes “Claude can query the warehouse” an approvable sentence in a security review. Every invocation is logged in one place, attributable to one authenticated principal.

The trade-off is transport: instead of the stdio subprocess model, a self-hosted deployment uses an HTTP-based transport (Streamable HTTP in current MCP revisions), which brings standard web-service concerns — TLS, authentication, timeouts — into scope. The rest of this guide addresses exactly those concerns.

MCP Server Python Implementation

The fastest path to a production-quality MCP server Python implementation is the official Python SDK with FastMCP, its high-level server API. Install with uv (or pip):

uv init mcp-internal-tools && cd mcp-internal-tools
uv add "mcp[cli]" asyncpg

A minimal but realistic server exposing scoped database tools:

# server.py
import os
import asyncpg
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("internal-tools", stateless_http=True)

DB_URL = os.environ["DATABASE_URL"]  # injected at deploy time, never hardcoded
_pool: asyncpg.Pool | None = None

async def get_pool() -> asyncpg.Pool:
    global _pool
    if _pool is None:
        _pool = await asyncpg.create_pool(
            DB_URL, min_size=1, max_size=5, command_timeout=10
        )
    return _pool

@mcp.tool()
async def list_tables() -> str:
    """List all tables in the public schema with row estimates."""
    pool = await get_pool()
    rows = await pool.fetch(
        """SELECT relname AS table, n_live_tup AS approx_rows
           FROM pg_stat_user_tables ORDER BY n_live_tup DESC"""
    )
    return "\n".join(f"{r['table']}: ~{r['approx_rows']} rows" for r in rows)

@mcp.tool()
async def run_readonly_query(sql: str, limit: int = 100) -> str:
    """Execute a read-only SQL query. SELECT statements only; results capped."""
    statement = sql.strip().rstrip(";")
    if not statement.lower().startswith("select"):
        raise ValueError("Only SELECT statements are permitted.")
    pool = await get_pool()
    async with pool.acquire() as conn:
        # Enforce read-only at the session level as a second layer
        await conn.execute("SET TRANSACTION READ ONLY")
        rows = await conn.fetch(f"{statement} LIMIT {min(limit, 500)}")
    if not rows:
        return "No rows returned."
    header = " | ".join(rows[0].keys())
    body = "\n".join(" | ".join(str(v) for v in r.values()) for r in rows)
    return f"{header}\n{body}"

if __name__ == "__main__":
    # Streamable HTTP transport for network deployment
    mcp.run(transport="streamable-http")

[IMAGE: python script for self-hosted mcp server setup]

Implementation principles worth internalizing:

  • Defense in depth on every tool. run_readonly_query validates the statement prefix, sets a read-only transaction, caps result size, and should still connect as a SELECT-only database role. Any single layer can fail; the stack shouldn’t.
  • Docstrings are prompts. Tool descriptions are what Claude reads when deciding which tool to call and how. Write them with the same care as system prompts — precise, bounded, unambiguous.
  • Connection pooling with tight limits. An agent can issue bursts of calls; a small pool with command_timeout prevents your server from becoming a denial-of-service vector against its own backend.
  • Deferred initialization. The pool is created lazily on first use, keeping startup fast — which matters for health checks and container orchestration.

Test locally before containerizing:

DATABASE_URL=postgresql://claude_ro:pw@localhost:5432/analytics \
  uv run python server.py
# In another terminal, exercise it with the Inspector:
npx @modelcontextprotocol/inspector

Claude MCP Server Docker Setup

Containerization makes the deployment reproducible and portable. The claude mcp server docker setup below follows standard hardening practice.

Writing the Dockerfile

# Dockerfile
FROM python:3.12-slim AS base

# Never run as root inside the container
RUN groupadd -r mcp && useradd -r -g mcp mcp

WORKDIR /app

# Layer-cache dependencies separately from source
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv && uv sync --frozen --no-dev

COPY server.py ./

USER mcp

ENV PYTHONUNBUFFERED=1
EXPOSE 8000

# Basic liveness probe endpoint provided by the HTTP transport
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD python -c "import urllib.request,sys; \
  urllib.request.urlopen('http://127.0.0.1:8000/mcp', timeout=3)" || exit 1

CMD ["uv", "run", "python", "server.py"]

Key decisions: a slim base image to shrink the attack surface, a non-root user so container escape doesn’t inherit root, frozen dependency sync for reproducible builds, and separate dependency/source layers for fast rebuilds.

Docker Compose Configurations

Compose ties the server to its runtime constraints and secrets:

# docker-compose.yml
services:
  mcp-server:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: ${DATABASE_URL}       # from .env or secrets manager
      MCP_AUTH_TOKENS_FILE: /run/secrets/mcp_tokens
    secrets:
      - mcp_tokens
    networks:
      - internal
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    networks:
      - internal

secrets:
  mcp_tokens:
    file: ./secrets/mcp_tokens.txt

networks:
  internal:
    driver: bridge

volumes:
  caddy_data:

[IMAGE: docker compose file for claude mcp server docker setup]

Notice what this configuration enforces:

  • The MCP server has no published ports. Only the reverse proxy (Caddy here; nginx or Traefik work equally well) is exposed, and it terminates TLS. The server is reachable exclusively on the internal Docker network.
  • Read-only root filesystem with a tmpfs for scratch space, plus no-new-privileges — cheap, high-value hardening flags.
  • Resource limits cap the blast radius of runaway tool executions.
  • Secrets via Docker secrets, not environment baked into the image. Never COPY credentials into a layer; layers are forever.

MCP Server Authentication Setup

An unauthenticated MCP server on a network is an open proxy to every system it can reach. Your MCP server authentication setup should follow this progression:

Tier 1 — Bearer tokens at the proxy (minimum viable). Issue each client a distinct token and enforce it at the reverse proxy before traffic reaches the server:

# Caddyfile
mcp.internal.example.com {
    @unauthorized not header Authorization "Bearer {$MCP_TOKEN}"
    respond @unauthorized 401
    reverse_proxy mcp-server:8000
}

Per-client tokens (rather than one shared secret) are what make revocation and attribution possible. Store them hashed server-side if you validate in-app, rotate on a schedule, and treat any token that touched a laptop as leakable.

Tier 2 — OAuth 2.1 (protocol-native). Current MCP specification revisions define an OAuth-based authorization framework for HTTP transports, where the MCP server acts as a resource server and validates access tokens issued by your identity provider. This is the right target state for organizational deployments: tokens carry identity and scopes, expiry is enforced by the IdP, and offboarding an employee automatically revokes their agent access. The Python SDK provides hooks for token verification so you can integrate your existing IdP rather than building auth from scratch.

Tier 3 — Network-level controls (always, in addition). mTLS between clients and proxy where feasible; otherwise VPN or private-network-only exposure. Authentication tiers stack — network controls plus token auth is the sane default.

Two non-negotiables regardless of tier:

  1. Bind to internal interfaces only. The server process listens on the Docker network; only the TLS-terminating proxy is reachable, and ideally only from your VPN CIDR.
  2. Log the principal on every tool call. who + which tool + what arguments + when — this is your audit trail and your incident-response timeline.

MCP Server Security Best Practices

Authentication controls who gets in. These MCP server security best practices control what the blast radius is once someone — or something — is in.

How to secure an MCP server from external access?

Work outside-in through the layers:

  • Never expose the server directly to the public internet. Place it behind a VPN or, at minimum, a TLS-terminating reverse proxy with IP allowlisting restricted to your office/VPN ranges. Firewall the host so only ports 443 (proxy) and your management port are open.
  • Terminate TLS at the proxy with real certificates. Self-signed certs train users to ignore warnings and break some clients silently.
  • Isolate egress, not just ingress. The server container should reach only the backends it wraps. Use Docker network segmentation or host firewall egress rules so a compromised server can’t pivot laterally through your network.
  • Rate-limit at the proxy. Agents can loop; bugs can hammer. A per-token rate limit protects both your server and its downstream systems.
  • Patch cadence. Rebuild images on a schedule to pick up base-image CVE fixes; pin dependencies with a lockfile so upgrades are deliberate, not accidental.
  • Monitor the audit log for anomalies. Novel tools being invoked, off-hours bursts, or arguments probing beyond expected schemas are your early warning signals.

Limiting Agent Permissions & Scopes

The most under-appreciated security layer is what the agent is allowed to want:

  • Least privilege at the credential layer. The server’s database role is SELECT-only with a statement timeout; its API tokens carry minimal scopes. Even a fully compromised server — or a fully manipulated model — cannot exceed the credential’s ceiling.
  • Curated tools over generic execution. get_order_status(order_id) is auditable and bounded; run_sql(anything) is a liability outside read-only replicas. Design the tool surface as a permissions policy.
  • Validate inputs as if they were hostile. They may be: prompt injection means tool arguments can be attacker-influenced through content the model reads. Schema-validate every argument, bound every limit, and never string-interpolate arguments into shell commands or SQL.
  • Separate read and write servers. Deploy read-heavy tools and write-capable tools as separate server instances with separate credentials and separate client authorization — so most users’ tokens simply cannot reach mutating tools.
  • Keep human approval on destructive paths. Client-side tool allowlisting plus per-call confirmation for writes gives you a human circuit breaker where it matters, at zero infrastructure cost.

Conclusion & Next Steps

A production-grade self-hosted MCP server setup comes down to four disciplined layers: a Python implementation whose tools enforce their own limits, a hardened Docker deployment with no direct exposure, token- or OAuth-based authentication with per-principal audit logging, and least-privilege credentials that cap the blast radius beneath everything else. None of the individual pieces are exotic — the discipline is in stacking all of them.

From here:

  • Register your deployed server with your team’s clients and connect Claude to your internal tools using the HTTP transport endpoint.
  • When clients report failures, troubleshoot server authentication logs first — expired tokens and proxy timeouts account for most “server down” reports that aren’t.
  • Ship one server wrapping one system, run it for two weeks with real usage, and let the audit log tell you what to build next.

Frequently Asked Questions

Do I need Docker to self-host an MCP server?

No — a systemd service running the Python process behind a reverse proxy works fine. Docker earns its place through reproducibility, dependency isolation, and the cheap hardening flags (read-only filesystem, non-root user, resource limits) shown above. For teams, containerization is strongly recommended.

Which transport should a self-hosted server use?

HTTP-based transport (Streamable HTTP in current MCP revisions) for anything network-accessible. Stdio is for local, single-user subprocess servers. If you’re migrating a stdio server, the Python SDK lets you switch transports with minimal code change — the tool implementations stay identical.

How do teams authenticate to a shared MCP server?

Start with per-client bearer tokens enforced at the reverse proxy, then graduate to the protocol’s OAuth-based authorization with your identity provider so agent access follows your existing joiner/leaver process. Never ship a single shared secret to every laptop.

Can a self-hosted MCP server serve multiple different tools?

Yes — one server can expose tools for several backends. But security argues for separation: group tools by sensitivity, and split read-only and write-capable capabilities into separate server instances with separate credentials and client authorization.

What’s the biggest security mistake in self-hosted MCP deployments?

Exposing the server to the internet with a single shared token and admin-level backend credentials. Each of those three choices is individually recoverable; together they turn any leak into full internal-system compromise. Invert all three: private network exposure, per-principal auth, least-privilege credentials.

Leave a Comment