Guide · Docker Compose

MCP server Docker Compose setup

Docker Compose is the most common way to run an MCP server locally alongside its dependencies (Redis for session state, Postgres for persistent storage, a mock upstream API). For self-hosted production on a single VPS, Compose also works well — add Traefik for automatic TLS and you have a complete stack without Kubernetes complexity. This guide covers the complete Compose configuration: service health checks, startup ordering with depends_on, named volumes, environment variable management with a .env file, and a production overlay with Traefik.

TL;DR

Define your MCP server, Redis, and Postgres as Compose services. Use depends_on with condition: service_healthy so the MCP server waits for Redis and Postgres to be ready before starting — not just running. Name volumes for all persistent data. Store secrets in .env (gitignored). For production, add a Traefik service with automatic Let's Encrypt TLS as a reverse proxy. Add your server's public URL to AliveMCP — Compose health checks are local; AliveMCP verifies the MCP protocol from outside your host.

Base compose file

The development compose file runs the MCP server with hot reload, Redis for session state, and Postgres for persistent data. Services connect to each other using their service names as hostnames:

services:
  mcp-server:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      PORT: "3000"
      REDIS_URL: redis://redis:6379
      DATABASE_URL: postgres://mcpuser:${POSTGRES_PASSWORD}@postgres:5432/mcpdb
      DB_PATH: /data/mcp.db
    volumes:
      - ./src:/app/src        # hot reload: mount source files
      - mcp-data:/data        # persistent SQLite
    depends_on:
      redis:
        condition: service_healthy
      postgres:
        condition: service_healthy
    command: npm run dev       # ts-node or tsx watch

  redis:
    image: redis:7-alpine
    command: redis-server --save 60 1 --loglevel warning
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: mcpuser
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: mcpdb
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mcpuser -d mcpdb"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  mcp-data:
  redis-data:
  postgres-data:

The condition: service_healthy in depends_on is critical: without it, depends_on only waits for the container to start, not for the database to accept connections. Redis ping and Postgres pg_isready are the standard readiness checks for each.

Environment variable management

Docker Compose automatically reads a .env file in the same directory as compose.yaml. Use it for secrets that vary by environment and should never be committed to version control:

# .env (gitignored)
POSTGRES_PASSWORD=your-local-dev-password
MCP_API_KEY=dev-key-not-real
ALIVEMCP_SECRET=local-test-key
# .env.example (committed — shows required keys without values)
POSTGRES_PASSWORD=
MCP_API_KEY=
ALIVEMCP_SECRET=

Reference .env values in compose.yaml with ${VAR_NAME} interpolation. Compose also supports an env_file directive to load a file directly into a service's environment (useful for secrets that you don't want in compose.yaml at all):

services:
  mcp-server:
    env_file:
      - .env
      - .env.production  # override for prod (loaded in order, later wins)

MCP server Dockerfile for Compose

The Compose build.context points to your project root. The Dockerfile should produce a minimal production image with a non-root user and correct signal handling:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup -S mcp && adduser -S mcp -G mcp
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
RUN mkdir /data && chown mcp:mcp /data
USER mcp
EXPOSE 3000
CMD ["node", "dist/index.js"]

For development (with hot reload), the command: npm run dev override in Compose replaces the Dockerfile CMD and the source mount provides the uncompiled TypeScript. See MCP server Dockerfile for the complete production Dockerfile with SIGTERM handling.

Health check for the MCP server service

Add a health check to the mcp-server service so Compose can report its status and downstream services that depend on it know when it's ready:

services:
  mcp-server:
    # ... other config ...
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://localhost:3000/healthz || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s

The start_period gives the server time to initialize before health checks start counting failures. Without it, a TypeScript server that takes 5 seconds to compile and initialize will fail the first two health checks and be marked unhealthy despite being fine.

The /healthz endpoint should validate the MCP layer, not just return 200. See MCP server health checks for the full initialize-probe implementation.

Production compose with Traefik TLS

For self-hosted production on a VPS, add Traefik as a reverse proxy with automatic Let's Encrypt certificate provisioning. Create a compose.prod.yaml overlay that adds Traefik and updates service labels:

services:
  traefik:
    image: traefik:v3
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.letsencrypt.acme.httpchallenge=true
      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
      - --certificatesresolvers.letsencrypt.acme.email=admin@yourdomain.com
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt

  mcp-server:
    # Disable direct port exposure — Traefik handles routing
    ports: !reset []
    labels:
      - traefik.enable=true
      - traefik.http.routers.mcp.rule=Host(`mcp.yourdomain.com`)
      - traefik.http.routers.mcp.entrypoints=websecure
      - traefik.http.routers.mcp.tls.certresolver=letsencrypt
      - traefik.http.services.mcp.loadbalancer.server.port=3000

volumes:
  letsencrypt:

Run the production stack with: docker compose -f compose.yaml -f compose.prod.yaml up -d. The overlay merges with the base file — Traefik is added, the server's direct port exposure is removed, and routing labels are applied.

Networking between services

Compose creates a default bridge network named <project-name>_default. All services in the same Compose file are on this network and can reach each other by service name. Your MCP server connects to Redis as redis:6379 and Postgres as postgres:5432 — exactly as configured in the environment variables.

For more isolation (e.g., if you want to prevent the MCP server from reaching the Postgres database directly, with all DB access going through an API layer), define explicit networks:

services:
  mcp-server:
    networks: [frontend, backend]
  redis:
    networks: [backend]
  postgres:
    networks: [backend]

networks:
  frontend:
  backend:
    internal: true  # no external routing

The internal: true flag on the backend network blocks outbound internet access for services on that network — Postgres can't make external calls, and neither can Redis. Useful for security-sensitive deployments.

External monitoring

Compose health checks run locally inside the Docker network. They verify that the container is responsive, but they don't verify TLS termination, DNS resolution for your domain, or protocol correctness from outside the host. An expired certificate or a misconfigured Traefik router causes MCP clients to fail while Compose health checks remain green.

Add your production domain (https://mcp.yourdomain.com) to AliveMCP for external protocol probing. AliveMCP runs the full initializetools/list handshake from outside your VPS and alerts on any failure — including TLS issues that your internal health checks can't see. See AliveMCP for setup.

Related questions

Should I use Docker Compose for production or just local dev?

Compose works well for production on a single VPS with Traefik handling TLS. It becomes limiting when you need horizontal scaling across multiple hosts (Compose doesn't orchestrate multi-host clusters — that's Kubernetes or Docker Swarm). For a single-instance MCP server with moderate traffic, Compose is simpler to operate than Kubernetes. For high-availability requirements with multiple replicas across different machines, graduate to a cloud PaaS (Railway, Render, Fly.io) or Kubernetes.

How do I run database migrations before the MCP server starts?

Add a migration service that runs and exits before the MCP server starts. Use depends_on with condition: service_completed_successfully: the MCP server won't start until the migration service exits with code 0. This is more reliable than running migrations inside the application startup code, because migration failures produce a clear exit code rather than a runtime panic.

services:
  migrate:
    build: .
    command: node dist/migrate.js
    depends_on:
      postgres:
        condition: service_healthy
  mcp-server:
    depends_on:
      migrate:
        condition: service_completed_successfully
How do I watch logs for all services at once?

Use docker compose logs -f to tail logs from all services with interleaved output. Add --no-color if piping to a file. For production, configure a log driver in compose.yaml to forward logs to a centralized system: logging: driver: "json-file" (default) writes structured JSON to disk, which tools like Loki + Grafana can ingest. See MCP server logging for structured log format recommendations.

Further reading