The default way to use our MCP server is npx -y ldm-inbox-check-mcp in each developer's Claude Desktop config, each with their own API key pasted into their own local JSON file. That works great for a three-person start-up. It breaks the moment a security team gets involved.
This article shows how to run the MCP server as a self-hosted service — one container, one shared credential store, one audit log — so that developers connect to your instance instead of shipping keys on their laptops.
Centralised API keys, uniform audit logging, SSO in front of the server, one upgrade path for everyone, no keys sitting on developer laptops. If any of that matters to you, read on.
Why self-host at all
- Compliance. SOC2, ISO 27001, HIPAA adjacent controls generally forbid long-lived API keys on endpoint devices. Self-hosting moves the key off the laptop.
- Centralised rotation. Rotate one key in one place, not fifty laptops.
- Audit logging. See who ran which placement test, from which client, at what time. Harder to do when each developer is their own tenant.
- Air-gapping. Some environments cannot execute arbitrary npm packages on developer machines. Running the server inside your network sidesteps that.
When you do NOT need to self-host
Solo developers, small teams, and anyone using a personal API key should stick with npx. Self-hosting adds ops overhead — do not take it on unless you have one of the reasons above.
The shape of the deployment
The MCP server speaks stdio or HTTP. For a self-hosted deployment we use HTTP (with SSE for streaming). The container listens on port 3000, behind a reverse proxy that handles TLS, auth, and rate limiting.
+-----------------+
| developer tool | (Claude Desktop, Cursor, custom agent)
+--------+--------+
|
| HTTPS + bearer token (your internal SSO-issued)
v
+--------+--------+
| Nginx / Traefik | (TLS, auth, rate limit, audit log)
+--------+--------+
|
| HTTP (localhost / cluster-internal)
v
+--------+--------+
| ldm-inbox-check | (MCP server container)
| -mcp (HTTP mode)|
+--------+--------+
|
| HTTPS
v
+--------+--------+
| Inbox Check API | (our hosted service)
+-----------------+
The Dockerfile
Start from a slim Node image. The package is published to npm, so the Dockerfile is mostly one line.
# Dockerfile
FROM node:22-alpine
# Security: non-root user.
RUN addgroup -S mcp && adduser -S mcp -G mcp
WORKDIR /app
# Pin a specific version in production; use "latest" only for dev.
ARG MCP_VERSION=0.7.2
RUN npm install --omit=dev -g ldm-inbox-check-mcp@$MCP_VERSION
USER mcp
ENV MCP_TRANSPORT=http
ENV MCP_PORT=3000
ENV MCP_HOST=0.0.0.0
EXPOSE 3000
# Healthcheck hits the MCP server's /health endpoint.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
ENTRYPOINT ["ldm-inbox-check-mcp"]Notes:
- Pin the version.
latestis for development. In production, lockMCP_VERSIONand bump it in a PR so changes go through review. - Run as non-root. No MCP server needs root. The
mcpuser has no shell beyond what the base image provides. - Healthcheck is essential. Without it, a hung server looks healthy to your orchestrator.
docker-compose.yml
For a single-node deployment, Compose is the simplest path.
# docker-compose.yml
services:
mcp:
build: .
image: internal-registry.example.com/ldm-inbox-check-mcp:0.7.2
restart: unless-stopped
env_file: ./secrets/mcp.env
environment:
- MCP_TRANSPORT=http
- MCP_PORT=3000
- LOG_LEVEL=info
- LOG_FORMAT=json
ports:
- "127.0.0.1:3000:3000" # bind to loopback, expose via nginx
volumes:
- ./config/mcp.yaml:/etc/mcp/config.yaml:ro
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"The secrets/mcp.env file holds the API key and anything else sensitive. Mount read-only.
# secrets/mcp.env — chmod 600, NOT in git
INBOX_CHECK_API_KEY=ic_live_...
INBOX_CHECK_API_BASE=https://check.live-direct-marketing.online
AUDIT_LOG_PATH=/var/log/mcp/audit.jsonlNever bake the API key into the Dockerfile with ENVor a hard-coded value. Pass it at runtime via env_file, a Kubernetes Secret, or your cloud secret manager. A leaked image is a leaked key.
Required environment variables
The MCP server reads these on startup:
INBOX_CHECK_API_KEY(required) — your org's API key.INBOX_CHECK_API_BASE(optional) — override for private/beta endpoints. Defaults to the public hosted URL.MCP_TRANSPORT—stdio(default) orhttp. Usehttpfor self-hosting.MCP_PORT,MCP_HOST— listener config when transport is HTTP.LOG_LEVEL—trace|debug|info|warn|error. Useinfoin production.LOG_FORMAT—prettyorjson. Pickjsonfor log shipping.ALLOWED_TOOLS(optional) — comma-separated allow-list. Set to e.g.start_test,get_testif you want to hide list-endpoints.
Reverse-proxying with nginx
TLS, auth and rate limiting belong in nginx, not in the MCP server. Minimal config:
# /etc/nginx/sites-enabled/mcp
upstream mcp_backend {
server 127.0.0.1:3000;
keepalive 16;
}
# Rate limit: 5 tool calls per second per IP.
limit_req_zone $binary_remote_addr zone=mcp:10m rate=5r/s;
server {
listen 443 ssl http2;
server_name mcp.internal.example.com;
ssl_certificate /etc/ssl/certs/mcp.crt;
ssl_certificate_key /etc/ssl/private/mcp.key;
# Validate the caller's bearer token via auth_request.
# Your auth microservice returns 200 for valid, 401 otherwise.
location / {
auth_request /_auth;
limit_req zone=mcp burst=20 nodelay;
proxy_pass http://mcp_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-User-Id $upstream_http_x_user_id;
# Server-sent events need these.
proxy_buffering off;
proxy_read_timeout 1h;
}
location = /_auth {
internal;
proxy_pass http://auth-service.internal/validate;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header Authorization $http_authorization;
}
}The key bits: auth_request offloads token validation to your auth service (could be a tiny Go binary that checks a JWT from your SSO). proxy_buffering off is required for SSE. Rate limits live at the proxy so you can change them without rebuilding the image.
The auth layer on top
Two patterns work:
- JWT from your SSO. Developers log in to your SSO, get a short-lived JWT, include it as
Authorization: Bearer ...in MCP calls. Auth service validates the signature and TTL. This is the right answer for enterprise. - Static per-developer tokens. Each developer gets a personal token that maps to a user id. Simpler to set up, but you own rotation and revocation.
Either way, the MCP server itself stays unauthenticated — the proxy does all the work. That keeps the server small, upgradable, and free of tenant logic.
Observability
What to collect, at minimum:
- Access log. Every tool call, with user id, tool name, argument hash, result status. Ship to your SIEM.
- Metrics. Requests per second, error rate, upstream latency to the Inbox Check API, queue depth. Prometheus scrape endpoint on
/metrics. - Traces. OpenTelemetry traces linking the developer's agent call to the MCP tool call to the upstream REST call. Invaluable for debugging why a particular call was slow.
CI for the Docker image
Automate the image build so new MCP server versions get reviewed, tested and deployed through your normal change-control process.
# .github/workflows/mcp-image.yml
name: Build MCP image
on:
push:
paths:
- Dockerfile
- .github/workflows/mcp-image.yml
schedule:
- cron: "0 6 * * 1" # Monday 06:00 — pick up MCP server updates
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # for OIDC to your registry
steps:
- uses: actions/checkout@v4
- name: Resolve latest MCP version
id: v
run: |
V=$(npm view ldm-inbox-check-mcp version)
echo "ver=$V" >> "$GITHUB_OUTPUT"
- name: Build
run: |
docker build \
--build-arg MCP_VERSION=${{ steps.v.outputs.ver }} \
-t internal-registry.example.com/ldm-inbox-check-mcp:${{ steps.v.outputs.ver }} \
.
- name: Smoke test
run: |
docker run --rm -d --name mcp-test \
-e INBOX_CHECK_API_KEY=${{ secrets.MCP_TEST_KEY }} \
-p 3000:3000 \
internal-registry.example.com/ldm-inbox-check-mcp:${{ steps.v.outputs.ver }}
sleep 3
curl -f http://127.0.0.1:3000/health
docker stop mcp-testThe scheduled run picks up new MCP server versions weekly so you do not lag behind security fixes.
Frequently asked questions
Does Claude Desktop support connecting to a remote MCP server?
Can I air-gap this entirely?
How do I rotate the upstream API key?
INBOX_CHECK_API_KEY in your secret store and restart the MCP container. In-flight requests complete with the old key. Zero downtime with a rolling deployment on Kubernetes or two containers behind the proxy.