Docker Deployment (Python)
Time to read: 10 minutes
Package your Python MCP server as a container for any platform.
What You'll Learn
- A basic Dockerfile
- Multi-stage builds
- Environment variables and secrets
- Docker Compose
- Health checks, non-root user, volumes
- Troubleshooting
Basic Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir nitrostack && pip install --no-cache-dir -r requirements.txt
ENV HOST=0.0.0.0 MCP_TRANSPORT_TYPE=http PORT=3000
EXPOSE 3000
CMD ["nitrostack-py", "start", "--port", "3000"]
The container must use the HTTP transport. stdio has no network port to expose.
Build & Run
Build Image
docker build -t my-mcp-server .
Run Container
docker run --rm -p 3000:3000 --env-file .env my-mcp-server
Check it:
curl http://localhost:3000/mcp/health
Multi-Stage Build
Build dependencies in one stage and copy only the virtualenv into a slim runtime image:
# Build stage
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir nitrostack -r requirements.txt
# Production stage
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
COPY . .
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
HOST=0.0.0.0 \
MCP_TRANSPORT_TYPE=http \
PORT=3000
EXPOSE 3000
CMD ["nitrostack-py", "start", "--port", "3000"]
With uv
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock* ./
RUN uv sync --no-dev
COPY . .
ENV PATH="/app/.venv/bin:$PATH" HOST=0.0.0.0 MCP_TRANSPORT_TYPE=http PORT=3000
EXPOSE 3000
CMD ["nitrostack-py", "start", "--port", "3000"]
Environment Variables
Do not copy .env into the image. Pass secrets at runtime.
# From a file
docker run --rm -p 3000:3000 --env-file .env my-mcp-server
# Individually
docker run --rm -p 3000:3000 \
-e API_KEY=$API_KEY \
-e JWT_SECRET=$JWT_SECRET \
my-mcp-server
Common variables: HOST, PORT, MCP_TRANSPORT_TYPE, MCP_MAX_SESSIONS, API_KEY, JWT_SECRET, OAUTH_REQUIRED.
Docker Compose
# docker-compose.yml
services:
mcp-server:
build: .
ports:
- "3000:3000"
env_file:
- .env
environment:
HOST: 0.0.0.0
MCP_TRANSPORT_TYPE: http
PORT: "3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/mcp/health')"]
interval: 30s
timeout: 5s
retries: 3
docker compose up -d
docker compose logs -f mcp-server
Production Optimizations
Health Checks
python:3-slim has no curl, so use Python itself:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:3000/mcp/health')"
Non-Root User
RUN useradd --create-home --uid 1001 mcp
USER mcp
Security
# Patch the base image
RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
Also pin the base image tag, and keep .env and .git out of the build context.
Persistent Storage
docker run --rm -p 3000:3000 -v mcp-data:/app/data my-mcp-server
Containers are disposable. Keep any state your server writes on a volume or in an external database.
Networking
Run your server next to its dependencies on one Compose network and use service names as hostnames:
services:
mcp-server:
build: .
environment:
DATABASE_URL: postgresql://app:secret@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
Best Practices
- Use
HOST=0.0.0.0, otherwise the port is unreachable from outside the container - Use the
httptransport in containers - Never bake secrets into the image
- Pin Python and dependency versions
- Run as a non-root user
- Add a health check on
/mcp/health - Log to stderr/stdout of the container, not files, unless you mount a volume
Example .dockerignore
.git
.venv
__pycache__
*.pyc
.env
.env.*
dist
tests
.pytest_cache
Troubleshooting
Container Crashes
docker logs <container>
docker ps -a
Common causes: missing env vars, a Python version below 3.10, or the app still on stdio transport (set MCP_TRANSPORT_TYPE=http).
Port Already in Use
docker run --rm -p 8080:3000 my-mcp-server
Server Starts but Is Unreachable
HOST is probably 127.0.0.1. Set HOST=0.0.0.0.
Client Gets a Redirect on /mcp
Call /mcp without a trailing slash. The MCP Inspector requires that form.
Permission Issues
# Fix volume permissions for the non-root user
docker run --rm -v mcp-data:/data alpine chown -R 1001:1001 /data