Recipe-Inhalt ist auf Englisch. Englisches Original lesen →
← Alle Recipes
Phase 8 · Deploy MCP·7 steps

Docker Compose, direct hosting on a VPS you own

Self-host on Hetzner / DigitalOcean / your own server. node:22-slim base, env_file pattern, health check, the --force-recreate gotcha.

7 steps0%
Du liest ohne Account. Mit Login speichern wir Step-Fortschritt + Notes.

Docker Compose, direct hosting on a VPS you own

MCPize is great until you need custom infra: bigger memory, GPU, on-prem requirements, or you just want to own the host. Docker Compose on a Hetzner / DigitalOcean / Vultr VPS gives you full control for €5-10/month. This recipe is the production-tested compose file plus the env-file gotcha that kills 50% of restarts.

Schritt 1: The Dockerfile

# Dockerfile
FROM node:22-slim

WORKDIR /app

# Copy manifests first for layer caching
COPY package*.json ./
RUN npm ci --omit=dev

# Then source
COPY dist ./dist
COPY recipes ./recipes
COPY scripts ./scripts

# Healthcheck, node fetch() because slim doesn't have curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD node -e "fetch('http://localhost:'+process.env.PORT+'/health').then(r => process.exit(r.ok ? 0 : 1))"

EXPOSE 3000

CMD ["node", "dist/server.js"]

Three intentional choices:

  • node:22-slim. Debian Bookworm-based, ~150MB compressed, has glibc (Alpine's musl breaks Chromium / native deps).
  • npm ci --omit=dev, production deps only, ~5x smaller node_modules.
  • node fetch() for healthcheck, node:22-slim doesn't ship curl or wget. node -e fetch(...) is the lightest alternative.

COPY package*.json before COPY dist is the layer-cache trick, package changes are rare, dist changes every build.

Schritt 2: docker-compose.yml

# docker-compose.yml
services:
  my-mcp:
    build: .
    container_name: my-mcp
    restart: unless-stopped
    network_mode: host             # see Step 3 for why
    env_file: .env                 # see Step 4 for the trap
    environment:
      - NODE_ENV=production
      - PORT=3000
      - HOST=127.0.0.1             # bind to loopback only
    healthcheck:
      test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1))\""]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

Key choices explained in the next steps.

Schritt 3: `network_mode: host` vs default bridge

For a single-server setup, network_mode: host is simpler:

  • Container binds directly to the host's localhost, no port mapping.
  • 127.0.0.1:3000 works from both inside and outside the container.
  • Nginx (also on host) reverse-proxies to localhost:3000 without docker port forwarding.

Trade-off: container can see all host ports (security implication if you're running multi-tenant containers). For single-app servers, fine. For shared servers, use bridge mode + explicit port mapping.

HOST=127.0.0.1 keeps you off 0.0.0.0 so no accidental public exposure even on host network.

Schritt 4: env_file trap, `restart` doesn't reload

# Edit .env
echo "STRIPE_SECRET_KEY=sk_live_new" >> .env

# WRONG, restart doesn't reload env_file
docker compose restart my-mcp
# Container restarts but still uses old STRIPE_SECRET_KEY

# RIGHT, recreate from compose-time env
docker compose up -d --force-recreate my-mcp

docker compose restart reloads the process but keeps the existing container with its existing environment. up -d --force-recreate rebuilds the container from the compose file, which re-reads .env.

This took us out for 25 hours once. The fix is muscle memory: env change → --force-recreate. Always.

Schritt 5: The deploy script

#!/bin/bash
# deploy.sh, run from your local dev box
set -e

REMOTE=my-vps          # your SSH alias
IMAGE=my-mcp:latest

# 1. Build the image locally — catches errors early and keeps the VPS CPU free
docker build -t $IMAGE .

# 2. Ship the built image over SSH and load it on the server.
#    No rsync, no --delete: this only ADDS an image, it never overwrites
#    server state. .env, .ssh, logs and volumes on the server stay untouched.
docker save $IMAGE | gzip | ssh $REMOTE 'gunzip -c | docker load'

# 3. One SSH call: restart from the new image + health check.
#    docker-compose.yml and .env live on the server (you edit them there).
ssh $REMOTE "set -e
  cd /opt/my-mcp
  docker compose up -d --force-recreate my-mcp
  sleep 8
  docker inspect my-mcp --format '{{.State.Health.Status}}'
  curl -s http://localhost:3000/health
"

SSH batching matters. One SSH call with && chains is cheap; ten separate SSH calls (ssh ... cd, ssh ... build, ssh ... up, ...) trigger fail2ban after 10 minutes (we got banned from our own server once, couldn't SSH back in for hours). One SSH = no ban.

Schritt 6: nginx reverse proxy

(Full setup in 8.3, just the relevant bit here):

# /etc/nginx/sites-enabled/your-mcp.conf
server {
    listen 443 ssl http2;
    server_name your-mcp.io;

    ssl_certificate     /etc/letsencrypt/live/your-mcp.io/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-mcp.io/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600s;            # SSE streams
    }
}

proxy_read_timeout 3600s is the one non-default that matters. MCP Streamable HTTP holds connections open for SSE responses, default 60s timeout would cut them off mid-stream.

Schritt 7: Verify

Run academy_validate_step. The validator checks package.json is wired. For the deploy itself:

ssh $REMOTE 'docker ps --filter name=my-mcp'
# → my-mcp   Up 2 minutes (healthy)

curl -s https://your-mcp.io/health
# → {"status":"ok","version":"1.0.0",...}

(healthy) in the docker ps output means the healthcheck passed. If it says (unhealthy) or (starting) for more than 30 seconds, check logs:

ssh $REMOTE 'docker compose -f /opt/my-mcp/docker-compose.yml logs my-mcp --tail=50'

Common traps

  • restart doesn't reload .env, always use --force-recreate after env changes.
  • No healthcheck. Docker can't restart a hung container. Always add one.
  • Healthcheck uses curl, node:22-slim doesn't have curl. Use node -e fetch(...).
  • Multiple SSH calls, fail2ban will ban you. Batch with one SSH + &&.
  • network_mode: host + HOST=0.0.0.0, accidental public exposure. Set HOST=127.0.0.1.
  • Forgetting proxy_read_timeout on nginx. SSE responses cut off at 60s.
  • No log rotation. JSON-file logs eat disk. Set max-size: 10m, max-file: 3.
  • Building on the production server, slow + uses prod CPU. Build the image locally and ship it with docker save | ssh | docker load (see deploy.sh above), never build on the VPS.

What good looks like

One docker-compose.yml, one Dockerfile, one deploy.sh. Deploy is ./deploy.sh from your laptop, ~30 seconds total, container (healthy) in 10 seconds, external health URL returns 200. nginx reverse-proxy with HTTPS. No env-file confusion because the deploy script always uses --force-recreate.

If you find yourself SSH-ing into the production server to debug a deploy, something in the script is wrong, fix the script, not the server.

Client-Check · auf Deinem Rechner ausführen
cat package.json 2>/dev/null | python3 -c "import json,sys; p=json.load(sys.stdin); deps=list((p.get(\"dependencies\") or {}).keys()); print(\"sdk:\", \"@modelcontextprotocol/sdk\" in deps); print(\"bin:\", bool(p.get(\"bin\"))); print(\"main:\", bool(p.get(\"main\")))" 2>/dev/null || echo "no package.json in cwd"
Erwartet: sdk: True, plus either bin or main is True.
Falls hängen geblieben: Run `npm init -y && npm install @modelcontextprotocol/sdk zod`, then add `"bin": { "your-server": "./dist/server.js" }` to package.json.
Deploy to MCPize Cloud Run, maNginx + Let's Encrypt, product