DEV Community

Scalix World
Scalix World

Posted on • Originally published at scalix.world on

Deploy an MCP Server on Docker

You have an MCP server. It runs on your machine, an agent on that same machine calls its tools, and it works. Now you want it in a container, so it behaves identically on your laptop, in CI, and on a server, with a dependency set that does not drift out from under you.

Docker is the right shape for this. An MCP server that speaks streamable HTTP is an ordinary long-running web service, and a container is the ordinary way to ship one. This is the short version: why to containerize, a Dockerfile that works, the local run, the deploy that gets you an HTTPS URL, and how to point an agent at it.

Why Docker for an MCP server

Two reasons, and neither one is fashion.

Isolation. An MCP server exists to run tool calls on behalf of an agent. That is the whole point of it, and it is also the risk: the code deciding which tool to run is a model, and the arguments arrive from a conversation. You want that executing inside a boundary that is not your user account. A container gives it a filesystem, a process tree, and a network namespace that are not your machine's.

Reproducibility. MCP servers collect the boring kind of dependency. A runtime version, a system library one of your tools shells out to, a CA bundle, a locale. Pin them into an image and the server behaves the same everywhere it runs. Skip it and "works on my laptop" becomes a real support burden the first time a teammate's agent connects.

There is a third reason that only shows up later. A container is a deployable unit, so once the server is an image, hosting it is a deploy rather than a project.

Before the Dockerfile: check your transport

One prerequisite. Your server has to speak streamable HTTP, not stdio.

A stdio server is launched as a subprocess by a client on the same machine and talks over standard input and output. Putting that in a container and exposing a port solves nothing, because nothing is listening on the port. If yours is still stdio, swap the transport first. Most frameworks support both, so it is usually configuration rather than a rewrite, and we walked through the difference in remote vs local MCP servers.

Two lines of your server code matter more than the rest of the Dockerfile:

const port = Number(process.env.PORT ?? 8080);
app.listen(port, "0.0.0.0", () => {
  console.log(`MCP server listening on ${port}`);
});

Enter fullscreen mode Exit fullscreen mode

Bind to 0.0.0.0, not 127.0.0.1. A server bound to localhost inside a container is reachable only from inside that container, and every client outside sees a refused connection. Read the port from the environment so the host can move it without a rebuild.

A minimal Dockerfile

Node example. The same structure applies to Python, Go, or Rust: build in one stage, run in a slim second stage, drop root.

# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
RUN useradd --system --uid 10001 mcp
USER mcp
ENV PORT=8080
EXPOSE 8080
CMD ["node", "dist/server.js"]

Enter fullscreen mode Exit fullscreen mode

The choices worth explaining. The build stage is separate so compilers and dev dependencies never reach the shipped image. The runtime runs as a non-root user, because a process executing model-chosen tool calls should not be root even inside a container. PORT is an environment variable with a default rather than a hardcoded number. Nothing secret is copied in.

That last point is the one people get wrong. Images get pushed to registries and pulled onto machines you do not own. Anything you COPY into an image is not a secret anymore.

Run it locally

docker build -t mcp-server:v1 .

docker run --rm -p 8080:8080 \
  -e MCP_API_KEY=$MCP_API_KEY \
  mcp-server:v1

Enter fullscreen mode Exit fullscreen mode

Then confirm it actually answers on the network, not just in the logs:

curl -i http://localhost:8080/mcp \
  -H "Authorization: Bearer $MCP_API_KEY"

Enter fullscreen mode Exit fullscreen mode

If that hangs or refuses, it is the bind address nine times out of ten. Point a local client at the same URL and check the tool list before you deploy anything. A broken tool schema is much cheaper to find here.

Deploy the container somewhere always-on

Local Docker buys you reproducibility. It does not buy you reachability. Your laptop is still not a server, and an agent cannot call a URL that stops existing when you close the lid.

Scalix Run deploys containers, either from an image you built or straight from source. Tag and push under your project id, which is the registry namespace:

echo $SCALIX_API_KEY | docker login api.scalix.world -u scalix --password-stdin

docker tag mcp-server:v1 api.scalix.world/<project-id>/mcp-server:v1
docker push api.scalix.world/<project-id>/mcp-server:v1

Enter fullscreen mode Exit fullscreen mode

Then deploy it:

scalix-cloud run deploy --name mcp-server \
  --image api.scalix.world/<project-id>/mcp-server:v1 \
  --port 8080 \
  --min-instances 0 --max-instances 5

Enter fullscreen mode Exit fullscreen mode

The service comes up at a stable HTTPS address:

https://mcp-server.run.scalix.world

Enter fullscreen mode Exit fullscreen mode

If you would rather not build the image at all, deploy from source and let the platform detect the runtime from your project. It recognizes Node, Python, Go, Rust, or a Dockerfile. Keep the Dockerfile anyway. It is the version of the build you control.

One note on --min-instances 0. That is scale to zero: no instances running, nothing to pay for, while no agent is calling. The usual objection is cold starts, and it is a fair one for a server an agent hits interactively. The microVM itself boots in roughly 76ms; add your server's own startup on top and the first call after an idle period lands in a couple of seconds, not the long pause that phrase usually implies. If your server holds warm state between calls, set the floor to 1 instead and skip the question.

Every deploy is a revision, so a bad image is one command away from undone:

scalix-cloud run rollback <service-id>

Enter fullscreen mode Exit fullscreen mode

Point an agent at it

It is a URL now, so any MCP client can connect. In Claude Code:

claude mcp add --transport http my-mcp https://mcp-server.run.scalix.world/mcp \
  --header "Authorization: Bearer $MCP_API_KEY"

Enter fullscreen mode Exit fullscreen mode

Use whatever path you mounted the transport on. Confirm with claude mcp list, or /mcp inside a session, which shows the connection status and the tools it picked up. Add --scope project to check the connection into a shared .mcp.json so teammates get it too. Cursor and other MCP clients take the same two inputs: a URL and a header.

Auth is not optional now

The moment your server has a public URL, every tool it exposes is public too. A stdio server needed no network auth because the trust boundary was your machine. An HTTP one has no such luck.

The workable default is a bearer token. Your server reads the Authorization header on every request, compares it against a key you issued, and rejects anything else. Keep the key in the environment, rotate by issuing a new one and retiring the old. The Model Context Protocol also defines an OAuth 2.1 based authorization model for HTTP transports, which is where you go when the callers are real end users rather than your own agents.

Whichever you pick, run the check before dispatching the tool call, not after it.

When not to self-host at all

Worth saying plainly, because a container tutorial has an obvious bias. Sometimes the correct amount of MCP server to run is none.

If what you want is tools for your cloud, so an agent can deploy a service or work a database, that server already exists and building it is wasted work. Scalix runs a hosted MCP server at api.scalix.world/v1/mcp that exposes the platform as 55 tools, with one API key authorizing all of them:

claude mcp add --transport http scalix https://api.scalix.world/v1/mcp \
  --header "Authorization: Bearer $SCALIX_API_KEY"

Enter fullscreen mode Exit fullscreen mode

Nothing to build, nothing to host, no Dockerfile. Write and containerize your own when the tools are genuinely yours: internal APIs, your own data, domain logic nobody else has. That is when everything above earns its place. The longer version of that decision is in how to host an MCP server, and what an agent can actually do once it has cloud tools is in deploy with Claude Code.

Wrapping up

Containerizing an MCP server is not an MCP problem. It is the same job as containerizing any long-running service: pin the runtime, bind 0.0.0.0, read PORT, drop root, keep secrets out of the image. What is specific to MCP comes after the build, because the process in that container executes tool calls a model chose, which is why the isolation is doing real work and the auth is not a nice-to-have.

Build the image, run it locally, push it, deploy it, hand your agent the URL. You can start free at scalix.world with no card, and if you get stuck on the deploy, come find us on Discord.

Top comments (0)