Security·Docker·Container Hardening

Docker hardening for MCP servers: non-root user, read-only filesystem, and capability drops

Docker provides isolation by default, but "isolated" is not the same as "hardened." A containerized MCP server running as root with a writable filesystem and full Linux capabilities gives an attacker who exploits an RCE vulnerability nearly everything they need to escape the container or pivot to other workloads. Five specific hardening steps close these doors: non-root user, read-only root filesystem, Linux capability drops, seccomp profile, and a distroless base image. Each one is a one-to-five line change in your Dockerfile or Compose config.

Step 1: run as a non-root user

The default Docker container runs as UID 0 (root). An attacker who achieves code execution inside the container inherits root-level access inside that container — and many container escape techniques require root. Running as a non-root user limits post-exploitation blast radius to what that user can access.

# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:22-alpine
WORKDIR /app

# Create a dedicated non-root user and group
RUN addgroup -g 1001 -S mcp && \
    adduser -u 1001 -S mcp -G mcp

# Copy built artifacts with correct ownership
COPY --from=builder --chown=mcp:mcp /app/node_modules ./node_modules
COPY --chown=mcp:mcp src/ ./src/

# Drop to non-root before starting the server
USER mcp

EXPOSE 3000
CMD ["node", "src/index.js"]

The --chown flag on COPY sets file ownership at copy time. Without it, copied files are owned by root even when the container runs as a non-root user, which creates unnecessary permission complexity.

Step 2: read-only root filesystem with targeted tmpfs

A read-only root filesystem prevents an attacker from writing malware to the container's filesystem — a common persistence technique after initial exploitation. Most MCP servers don't need a writable root filesystem; they only need specific writable directories (temporary files, log buffers). Use --read-only with targeted --tmpfs mounts for those directories:

# docker-compose.yml
services:
  mcp-server:
    image: your-org/github-mcp-server:latest
    read_only: true
    tmpfs:
      - /tmp:mode=1777,size=50m      # temporary files only
      - /run:mode=0755,size=5m       # pid files if needed
    volumes:
      - type: bind
        source: ./logs
        target: /app/logs
        read_only: false  # only the log directory is writable

Test your server with read-only mode before deploying — Node.js sometimes writes to unexpected locations (native addon builds, npm cache). These should be pre-built in the Docker layer, not at runtime.

Step 3: drop all capabilities, add back only what's needed

Linux capabilities are fine-grained root privileges. Docker containers run with a default set of ~14 capabilities. An MCP server running in Node.js almost certainly needs none of them. Drop everything and add back only what's documented as required:

# docker-compose.yml — capability hardening
services:
  mcp-server:
    image: your-org/github-mcp-server:latest
    cap_drop:
      - ALL              # drop every capability
    cap_add: []          # add back nothing — Node.js HTTP server needs none
    security_opt:
      - no-new-privileges:true  # prevent setuid binaries from gaining capabilities

If your server binds to port 80 or 443, it needs CAP_NET_BIND_SERVICE. Prefer using a non-privileged port (>1024) and putting a reverse proxy in front — this avoids needing any capabilities at all.

Step 4: seccomp profile

Seccomp restricts which Linux system calls the container process can make. Docker's default seccomp profile blocks ~44 dangerous syscalls. A custom profile for a Node.js HTTP server can be significantly more restrictive — blocking ptrace, mount, clone with flags that create new namespaces, and other syscalls that container escape techniques rely on:

# docker-compose.yml — seccomp profile
services:
  mcp-server:
    image: your-org/github-mcp-server:latest
    security_opt:
      - no-new-privileges:true
      - seccomp:./seccomp/node-mcp-server.json

For most MCP servers, Docker's runtime default seccomp profile (seccomp:runtime/default) is adequate. A custom profile is a hardening measure for high-sensitivity deployments where defense-in-depth is critical.

Step 5: distroless base image

Alpine-based images include a shell (/bin/sh), a package manager (apk), and standard Unix utilities. An attacker with code execution can use these to explore the container, download tools, or run commands. Distroless images contain only the runtime and your application — no shell, no package manager, nothing an attacker can use interactively:

# Dockerfile — distroless final stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Distroless Node.js base — no shell, no package manager
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/

# Distroless USER directive — nonroot = UID 65532
USER nonroot
EXPOSE 3000
CMD ["src/index.js"]

The tradeoff: distroless images are harder to debug interactively. Use the :debug tag variant during development — it includes a shell — and switch to the production variant for deployed images.

Hardening checklist

SkillAudit's Dockerfile analysis checks for each of these. A missing USER directive is a HIGH finding; ENV directives containing credential-shaped values are CRITICAL. Running the audit on your CI image build catches container hardening regressions the same way it catches code-level security regressions.

Audit your MCP server's container configuration

Run a free audit →