Deploy an MCP Server on Docker

Contents
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}`);
});
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"]
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
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"
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
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
The service comes up at a stable HTTPS address:
https://mcp-server.run.scalix.world
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>
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"
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 50 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"
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.
FAQ
How do I deploy an MCP server with Docker?
Make sure the server speaks streamable HTTP rather than stdio, then write a Dockerfile that installs dependencies in a build stage, copies the built output into a slim runtime image, runs as a non-root user, and starts the server on the port given by the PORT environment variable. Build it with docker build, test it with docker run and a curl against the endpoint, then push the image to a registry and deploy it to a service that gives you an always-on HTTPS URL.
Can you run an MCP server in a Docker container?
Yes, and for anything beyond personal use it is the normal way to run one. A container gives the server its own filesystem, process tree, and network namespace, which matters because an MCP server exists to execute tool calls chosen by a model. It also pins the runtime and system libraries so the server behaves the same on your laptop, in CI, and in production.
What port should an MCP server listen on in Docker?
Any port you expose, but read it from a PORT environment variable with a sensible default like 8080 so the host can change it without a rebuild. The critical detail is the bind address: listen on 0.0.0.0, not 127.0.0.1. A server bound to localhost inside a container is only reachable from inside that container, which is the most common reason a freshly containerized MCP server refuses connections.
How do I authenticate a containerized MCP server?
Once the server is reachable over a network it has to prove who is calling before it runs a tool. The practical default is a bearer token: the server checks the Authorization header on every request against a key you issued, and rejects anything else. Pass the key in as an environment variable rather than baking it into the image, since images get pushed to registries and copied around. The Model Context Protocol also defines an OAuth 2.1 based authorization model for HTTP transports if you have real end users rather than your own agents.
Should I build my own MCP server or use a hosted one?
Build your own when the tools are yours: internal APIs, your own data, domain logic nobody else has. Use a hosted one when the tools already exist. If you want an agent to operate cloud infrastructure, Scalix runs a hosted MCP server at api.scalix.world/v1/mcp exposing the platform as 50 tools behind a single API key, so there is nothing to build or host for that case.