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:

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

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.