Skip to main content

Enterprise Teams Run MCP Servers on Docker and OCI

Calculating read time…

You've got an MCP server running on a VM behind a reverse proxy — but nobody's actually plugged a client into it yet, and every time you ship a code change you're SSH-ing in and manually restarting a process. This post closes both gaps: first wiring a real MCP client to your deployed endpoint, then repackaging the server itself as a Docker image so "deploy" means docker run instead of a hand-built systemd unit you have to remember how to edit. 🔌


Why this matters: a systemd service tied to one VM's Node or Python install is a snowflake — the moment you need a second environment (staging), a rollback, or a teammate who runs a different OS, "it worked on the VM" becomes its own support ticket. Containerizing the server means the exact same image that passed your local test is the one running in OCI Container Instances or on an OKE cluster, with the runtime, dependencies, and startup command baked in once. Every major MCP-adopting platform — Docker's own MCP Catalog, Anthropic's reference servers, Cloudflare's Workers-based servers — ships a container or container-equivalent artifact rather than a bag of install instructions, for exactly this reason. 📦

Diagram: an MCP client reaching a reverse proxy, which forwards to a Docker container on OCI compute; a Git repo feeds a docker build and push into OCI Registry, which the container pulls from

🔀 Quick Comparison: systemd Process vs. Docker Container vs. OCI Container Instances

Step 3 of the previous post got your server running under a systemd unit directly on the VM. That's a completely valid deployment — but it's tied to that one VM's OS, installed runtime version, and file layout. Containerizing decouples "what the server needs to run" from "where it happens to run," and OCI Container Instances removes the VM from the picture entirely for workloads that don't need Kubernetes.

Dimension systemd on a VM Docker container OCI Container Instance
Runtime dependency Whatever Node/Python version is installed on that VM Pinned inside the image, identical everywhere Same as the Docker image you push
Server management You manage the VM's OS, patches, users You still manage the host running the container Fully managed — no VM to patch at all
Portability Low — reinstall steps per new VM High — same image on any Docker host, any cloud High — same image, OCI provisions the compute
Good fit for A quick personal deployment you're still iterating on Any environment: laptop, CI, staging, production host A single server or a small number of servers, no orchestration needed

🎯 Use this when: you're deciding how far to take this — a single-developer server can stay on systemd indefinitely; the moment a second environment, a teammate's machine, or a CI pipeline needs to run the exact same server, containerize it.

6️⃣ Connect an MCP Client to Your Deployed Server

Kid analogy: your server is a vending machine that's finally been bolted to the wall outside (deployed and exposed). A client is the person who walks up to it — but they still need to know which slot to put their coin in and which button unlocks which snack. Connecting a client is teaching that person exactly how to talk to the machine. 🥤

An MCP client only needs three things to talk to a Streamable HTTP server: the URL, the transport type, and — if the server requires it — credentials. How you supply those three things differs by client, and getting this wrong is the single most common "it's deployed but nothing connects" moment.

Claude Desktop: use Settings → Connectors, not the config file

Current Claude Desktop treats remote (URL-based) MCP servers as connectors, added through the app's Settings → Connectors screen rather than by hand-editing claude_desktop_config.json. Paste in your endpoint (for example https://mcp.yourcompany.com), and if the server advertises OAuth, Claude Desktop walks you through the authorization flow directly in the UI.

✅ Practical example: after adding the connector, open a chat, click the tools icon, and confirm your server's tool list shows up with the same names you saw locally in Step 5's Inspector session. That match — same tools, same descriptions — is your confirmation the deployed server and the client are actually talking to each other, not two different things with the same name.

Claude Code CLI: native HTTP entry in settings.json

Claude Code's ~/.claude/settings.json already understands Streamable HTTP natively, no bridge required:

{
  "mcpServers": {
    "my-server": {
      "type": "http",
      "url": "https://mcp.yourcompany.com/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_TOKEN}"
      }
    }
  }
}

💡 Key warning: stdio-only clients — including older Claude Desktop builds and some third-party agent hosts — can't dial a URL directly. For those, a bridge like mcp-remote runs as a small local process that speaks stdio to the client on one side and Streamable HTTP to your real server on the other. It works, but it spawns an extra Node process per client session, so treat it as a compatibility shim, not the long-term shape — prefer a client's native HTTP support (like Claude Code's "type": "http") whenever it's available.

Verifying the connection actually works

  1. Confirm the client shows a "connected" state and lists tools, not just an accepted URL.
  2. Trigger one real tool call from the client and check your server's logs to see the request land — a client-side green checkmark alone can hide a silent 401 on the actual tool invocation.
  3. Test from a network that isn't your deployment VM, exactly as Step 5's Inspector check did — a client on the same box can accidentally succeed via a stale localhost route that would fail for everyone else.

🎯 Use this when: right after Step 5's Inspector check passes — treat the Inspector as your protocol-level release gate and the real client connection as your user-experience gate; they catch different classes of problems.

7️⃣ Productionize: Containerize with Docker

Kid analogy: right now your server is like a recipe cooked directly in someone's home kitchen — it depends on that kitchen's exact stove, that specific set of pans, spices already in the cupboard. A Docker image is packing the entire kitchen — stove, pans, spices, recipe card — into one sealed box, so the same meal comes out identical no matter whose counter the box sits on. 📦🍱

A Dockerfile for an MCP server built to speak Streamable HTTP is short, because the hard problems — process supervision, TLS, auth — stay outside the container, handled by the orchestrator and reverse proxy exactly as in Step 3 and Step 4. The container's only job is: install dependencies, copy code, expose the port, run the process.

# Dockerfile — multi-stage build for a Node-based MCP server
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY package*.json ./

# Run as a non-root user, not the container default root
RUN useradd --uid 1001 --shell /usr/sbin/nologin mcpuser
USER mcpuser

EXPOSE 8787
CMD ["node", "build/index.js", "--transport", "http", "--port", "8787"]
# .dockerignore
node_modules
.env
.git
*.log

✅ Practical example: build and run it locally before touching OCI at all — docker build -t mcp-server:local . then docker run --rm -p 8787:8787 --env-file .env mcp-server:local. Point your Step 5 Inspector at http://localhost:8787; a green connection here means the container is production-ready before it ever leaves your laptop.

💡 Key warning: the multi-stage build above copies node_modules from the build stage rather than reinstalling in the runtime stage — that's deliberate, to keep the final image from also carrying devDependencies and build tools. Just as important: never COPY .env into the image. Secrets belong in the orchestrator's environment injection (an OCI Container Instance's environment variables, or a Kubernetes Secret on OKE) — anyone who can pull the image should not thereby get your API keys, and a baked-in secret is exposed to anyone with registry read access even if the image itself is never made public.

🎯 Use this when: the Dockerfile builds cleanly and the container passes its own local Inspector check — that's the point to stop iterating on the image and move to getting it into a registry.

8️⃣ Push the Image to OCI Registry (OCIR)

Kid analogy: your sealed kitchen-in-a-box needs a warehouse it can be picked up from — not left sitting on your own kitchen table where only you can grab it. OCI Registry is that warehouse: a labeled shelf any authorized truck (or Container Instance, or OKE node) can pull the exact same box from. 🏬

# One-time: create an auth token in the OCI Console
# Profile icon -> User Settings -> Auth Tokens -> Generate Token

# Log in to OCIR (region code varies, e.g. iad, fra, lhr)
docker login .ocir.io -u '/'

# Tag and push
docker tag mcp-server:local .ocir.io//mcp-server:v1.0.0
docker push .ocir.io//mcp-server:v1.0.0

✅ Practical example: tag the image with the same Git tag you cut in Step 1 (v1.0.0, not latest). When Step 9's Container Instance or OKE deployment references that exact tag, "what's running in production" and "what's in the repo" stay provably in sync — the same discipline Step 1 established for source code now extends to the built artifact.

9️⃣ Run the Container on OCI

With the image sitting in OCIR, OCI gives you two realistic ways to actually run it — pick based on whether you need orchestration or just a box that stays on.

Option A — OCI Container Instances (serverless, no VM to manage)

Container Instances is OCI's serverless container runtime: you hand it an image reference and a compute shape, and OCI runs it on isolated, container-optimized infrastructure without you ever provisioning or patching a VM. For a single MCP server that doesn't need autoscaling or multi-container orchestration, this replaces the entire "SSH in, install Node, write a systemd unit" sequence from Step 3.

  1. In the OCI Console, go to Developer Services → Containers → Container Instances → Create Container Instance.
  2. Choose a shape (a small flex shape is plenty for one MCP server), then point the container image field at your OCIR path — <region-code>.ocir.io/<tenancy-namespace>/mcp-server:v1.0.0.
  3. Set environment variables for anything the container needs at runtime (API keys, database URLs) — this is the injection point that replaces the EnvironmentFile line from Step 3's systemd unit.
  4. Attach it to the same VCN and public subnet from Step 2, expose port 8787 internally, and put the Step 4 reverse proxy (or an OCI Load Balancer doing the same TLS-termination job) in front of it — the container's port still should not face the internet directly.
  5. Create the instance, then re-run Step 5's Inspector check against the same public endpoint to confirm nothing broke in the move from systemd to a container.

Option B — OKE (when you outgrow a single server)

Container Engine for Kubernetes is Oracle's managed Kubernetes service. It's the right move once you have more than one MCP server to run, need rolling zero-downtime deploys, or want autoscaling based on load — a Deployment plus a Service in front of the same OCIR image gives you that, at the cost of learning and operating Kubernetes concepts a single Container Instance doesn't require.

💡 Key warning: don't reach for OKE by default just because it's the "serious" option. A single MCP server serving one team is squarely in Container Instances' sweet spot; standing up a Kubernetes cluster for one container adds real operational surface — node pools, cluster upgrades, RBAC — that buys you nothing until a second or third workload actually needs to share it.

🎯 Use this when: Container Instances for one server or a handful of independent ones; OKE once you're running enough MCP servers, or tying them to enough other services, that shared orchestration starts paying for itself.

🏢 Enterprise Rollout at Scale

  • Image provenance. Require every deployed image to be traceable back to a signed Git tag and a specific OCIR digest — not a floating latest tag anyone could have overwritten — so an incident review can answer "what code was actually running" with certainty.
  • Registry access control. Scope OCIR repository access with IAM policies per team, and treat push access to a production repository the same as production deploy access — anyone who can push an image can run arbitrary code on whatever pulls it.
  • CI-built, never hand-built, production images. Have OCI DevOps (or your existing CI) run the docker build and push step on every merge to a release branch, so the image that reaches Container Instances or OKE was never assembled on someone's laptop.
  • Centralized client connector governance. Maintain the same allow-listed registry of approved MCP endpoints referenced in the deployment post's enterprise section, and require Claude Desktop connectors and Claude Code settings.json entries across the org to point only at registry-listed URLs.
  • Per-image vulnerability scanning. Scan images in OCIR (or in the CI pipeline before push) for known CVEs in base images and dependencies — a container that's easy to redeploy is also easy to keep patched, but only if scanning is actually wired into the pipeline rather than left to manual review.

🎯 Use this when: more than one team is pushing images to the same registry, or a second MCP server joins the first — the same threshold as the deployment post's enterprise section, now applied to the build artifact instead of just the running process.

⚠️ Common Mistakes

Running the container as root. The Docker default is root inside the container unless you explicitly create and switch to another user, as the Dockerfile above does. Reasoning: a container escape or a compromised dependency running as root inside the container has a meaningfully larger blast radius than one running as an unprivileged user.

Baking secrets into the image layer. A COPY .env . line, or an ARG passed at build time for an API key, leaves that value in the image's layer history even if a later layer deletes the file. Reasoning: anyone with pull access to the image can extract earlier layers directly — secrets belong in runtime environment injection, never in a layer.

Deploying latest instead of a pinned tag. Pointing a Container Instance or a Kubernetes Deployment at mcp-server:latest means a routine docker push from someone's laptop can silently change what's running in production. Reasoning: this defeats the entire point of the Step 1 Git-tagging discipline — a versioned image reference is what lets "what's deployed" match "what's in the repo."

Exposing the container's port directly instead of through the proxy. Moving to Container Instances doesn't remove the need for the TLS-terminating reverse proxy from Step 4 — it's tempting to skip it because the container "just works" when the port is opened directly. Reasoning: the container process still has no TLS certificate and no Origin validation of its own; the proxy layer is still the only thing standing between the raw internet and your MCP process.

Treating a passing local docker run as proof the deployment will work. A container that behaves correctly on a developer's laptop can still fail once network policies, environment variable injection, or IAM-scoped registry pulls differ in the OCI environment. Reasoning: exactly like Step 5's remote-testing rule, the only real confirmation is an Inspector or client connection from outside the deployment target itself.

❓ FAQ

Do I have to containerize before I can connect a client?

No — Step 6's client connection works against the plain systemd deployment from the previous post just as well as against a containerized one. Containerizing changes how the server runs, not the URL or transport a client talks to.

Can I skip OCIR and pull straight from Docker Hub?

Technically yes, if the image is public — but a private OCIR repository scoped by IAM policy keeps a company's MCP server images from being pullable by anyone who guesses the name, which a public Docker Hub repository can't guarantee on its own.

Does Claude Desktop still support the config-file method for remote servers?

It still works as a legacy path in some builds, but Settings → Connectors is the currently supported, documented method for URL-based remote servers — the config file remains reliable mainly for locally-spawned stdio servers.

Do I need Kubernetes to run more than one MCP server?

Not necessarily — you can run several independent OCI Container Instances side by side, each behind its own reverse-proxy path or subdomain. OKE earns its complexity when those servers need shared scaling policy, service discovery, or coordinated rollouts, not merely because there's more than one of them.

What's the fastest way to check a container image is actually safe to run?

Run it locally with a non-root user already set in the Dockerfile, confirm it starts without needing extra capabilities, and check that docker history on the built image shows no secret values in any layer before it ever reaches OCIR.

🔗 References & Further Reading

Product and company names (Anthropic, Claude, Claude Desktop, Claude Code, Docker, Oracle Cloud Infrastructure, Kubernetes, and others) are trademarks of their respective owners, referenced here for identification purposes only. All explanations above are original synthesis based on publicly documented behavior, not reproductions of any vendor's text.

📝 Summary

  • Connect a client: use Claude Desktop's Settings → Connectors for remote servers, Claude Code's native "type": "http" entry, and reserve stdio bridges like mcp-remote for clients that need one.
  • Containerize: a small multi-stage Dockerfile running as a non-root user turns "runs on my VM" into "runs anywhere," with secrets injected at runtime, never baked into a layer.
  • Push to OCIR: tag images to match your Git release tags so what's deployed and what's in the repo stay provably in sync.
  • Run on OCI: Container Instances for a single serverless, no-VM deployment; OKE once multiple servers need shared orchestration.
  • At scale: image provenance, scoped registry access, CI-built images, connector allow-listing, and vulnerability scanning turn this into a repeatable enterprise pattern rather than a one-off exercise.

That's the client-connection and containerization half of taking an MCP server the rest of the way to a dependable, team-shareable service. Happy shipping! 🚀

Comments