Topic: mcp server container image security
MCP server container image security — Dockerfile best practices, distroless base images, and image scanning for MCP servers
An MCP server running in a Docker container has two layers of security: the security of the MCP server code itself, and the security of the container runtime environment. An attacker who exploits a vulnerability in the MCP server code — a path traversal, a command injection, a dependency-chain compromise — next tries to escape the container or escalate privileges within it. A container running as root on a full-OS base image with thousands of installed packages gives that attacker every tool they need. Distroless base images, non-root runtime users, and pre-push image scanning limit the blast radius of a compromised MCP server to the narrowest possible surface.
The common container security mistakes in MCP servers
Auditing public MCP server repositories reveals three recurring Dockerfile patterns that each expand the post-exploitation surface:
- Running as root. The default container user is root unless explicitly changed. A process running as root inside a container that achieves container escape (via a kernel vulnerability, a mounted Docker socket, or a privileged capability) has root on the host. An MCP server has no need for root inside the container — it reads files, makes network calls, and listens on a non-privileged port.
- Full-OS base images.
FROM node:20installs a full Debian or Alpine environment with hundreds of packages — a shell, package managers, curl, wget, compilers, and other tools. These are useful for build stages but provide an attacker with a complete exploitation toolkit if the server is compromised. - No image scanning before deployment. Base images and npm dependencies may contain known CVEs. A server that isn't scanned deploys with known-exploitable vulnerabilities that a free scanner like Trivy would have caught.
The correct Dockerfile pattern for Node.js MCP servers
# syntax=docker/dockerfile:1.7
# Stage 1: build — use full Node image, install everything, compile TypeScript
FROM node:20-alpine AS builder
WORKDIR /build
COPY package*.json tsconfig.json ./
RUN npm ci --include=dev
COPY src/ src/
RUN npm run build
# Stage 2: prune dev dependencies
FROM node:20-alpine AS deps
WORKDIR /deps
COPY package*.json ./
RUN npm ci --omit=dev
# Stage 3: runtime — distroless has no shell, no package manager, no tools
# gcr.io/distroless/nodejs20-debian12 contains only:
# - Node.js runtime
# - glibc / SSL certificates
# - Nothing else
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
# Copy only what is needed at runtime
COPY --from=builder /build/dist/ dist/
COPY --from=deps /deps/node_modules/ node_modules/
COPY package.json .
# :nonroot tag runs as user 65532 (nonroot) — no root privileges
# Do NOT add a USER directive; :nonroot handles it in the base image
EXPOSE 3000
CMD ["dist/server.js"]
The three-stage build pattern is key: the build stage has all dev tools; the deps stage prunes to production dependencies; the runtime stage is distroless and contains only Node.js plus the compiled application code and production node_modules. An attacker who achieves RCE within the distroless container has no shell (/bin/sh does not exist), no curl, no wget, no package manager. They can execute Node.js code, but cannot download additional tools or easily escalate further.
Running as non-root in Alpine-based images
If distroless is not compatible with your deployment pipeline, Alpine-based images can also be secured by creating a non-root runtime user:
FROM node:20-alpine AS runtime
WORKDIR /app
# Create non-root user with no login shell
RUN addgroup -S mcpuser && adduser -S -G mcpuser -s /sbin/nologin mcpuser
COPY --from=builder /build/dist/ dist/
COPY --from=deps /deps/node_modules/ node_modules/
COPY package.json .
# chown to the non-root user before switching
RUN chown -R mcpuser:mcpuser /app
USER mcpuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
With Alpine, the shell still exists in the image — an attacker with RCE can launch it. The non-root user prevents privilege escalation to host root via container escape, but does not remove the toolkit. Distroless provides stronger isolation; Alpine non-root is the minimum acceptable baseline.
Image scanning with Trivy in CI
Trivy scans container images for OS package CVEs, npm dependency CVEs, and Dockerfile misconfigurations in a single command:
# .github/workflows/security.yml
name: Container security scan
on: [push, pull_request]
jobs:
image-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t mcp-server:scan .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: mcp-server:scan
format: table
exit-code: '1' # Fail CI on HIGH or CRITICAL CVEs
severity: HIGH,CRITICAL
ignore-unfixed: true # Skip CVEs with no available fix
- name: Run Trivy Dockerfile check
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: .
exit-code: '1'
severity: HIGH,CRITICAL
ignore-unfixed: true prevents CI failures on CVEs where no patched version exists yet — these are still reported but don't block deployment. Remove this flag if you want zero-tolerance for all known CVEs regardless of fix availability.
Additional container hardening
Beyond base image and user, three more Dockerfile practices reduce the container attack surface:
# Pin base image to a specific digest — prevents tag mutation attacks
FROM gcr.io/distroless/nodejs20-debian12@sha256:abc123... AS runtime
# Read-only root filesystem — prevents writing to container filesystem
# (set in docker run or Kubernetes securityContext)
# docker run --read-only --tmpfs /tmp mcp-server:latest
# Drop all Linux capabilities — MCP servers need none
# docker run --cap-drop=ALL mcp-server:latest
# Equivalent in Kubernetes:
# securityContext:
# readOnlyRootFilesystem: true
# allowPrivilegeEscalation: false
# capabilities:
# drop: [ALL]
Read-only root filesystem is especially effective: an attacker with RCE who cannot write to the container filesystem cannot modify server code, cannot write a cron job, and cannot create files to facilitate persistence. Pair this with a --tmpfs /tmp mount for any tools that need a temporary write location.
SkillAudit detection
- HIGH: Dockerfile runs server as root (no
USERdirective and not distroless nonroot). - HIGH: Docker socket mounted into the MCP server container (
/var/run/docker.sock) — full host access via Docker API. - MEDIUM: Full-OS base image in final runtime stage (not distroless, not Alpine hardened) — includes unnecessary tools.
- MEDIUM: No image scan step in CI — known CVEs in base image or npm dependencies go undetected.
- MEDIUM: Dev dependencies present in runtime image — includes build tools and test libraries not needed at runtime.
- LOW: Base image pinned by tag (
:20) rather than digest — tag can be mutated to point to a different image. - INFO: No
HEALTHCHECKdirective — container orchestrators cannot detect a hung MCP server process.
Container security intersects with the Permissions axis in SkillAudit: a server that requests filesystem access it doesn't need and runs as root doubles the blast radius of any finding in the Security axis. See the MCP server security checklist for the full deployment verification list, or run a SkillAudit scan on your repository to get Dockerfile-specific findings alongside code-level security analysis.