---
title: "Docker Compose, direct hosting on a VPS you own | Recipe — StudioMeyer Academy"
description: "Self-host on Hetzner / DigitalOcean / your own server. node:22-slim base, env_file pattern, health check, the --force-recreate gotcha."
canonical: "https://studiomeyer.academy/en/recipes/8.2-docker-compose"
language: en
markdown_versions: ["https://studiomeyer.academy/en/recipes/8.2-docker-compose.md"]
publisher: "StudioMeyer Academy, https://studiomeyer.academy (llms.txt: https://studiomeyer.academy/llms.txt)"
---

[← Alle Recipes](https://studiomeyer.academy/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](https://studiomeyer.academy/login?next=/recipes/8.2-docker-compose) 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.

### Step 1: The Dockerfile

```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.

### Step 2: docker-compose.yml

```yaml
# 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.

### Step 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.

### Step 4: env\_file trap, \`restart\` doesn't reload

```bash
# 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.

### Step 5: The deploy script

```bash
#!/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.

### Step 6: nginx reverse proxy

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

```nginx
# /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.

### Step 7: Verify

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

```bash
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:

```bash
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 · run on your machine

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"

**Expect:** sdk: True, plus either bin or main is True.

**If stuck:** 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, ma](https://studiomeyer.academy/recipes/8.1-mcpize-cloud-run) [Nginx + Let's Encrypt, product →](https://studiomeyer.academy/recipes/8.3-nginx-proxy)

Phase 8

MCPize Cloud Run, Docker Compose, health endpoints, blue-green

[Alle Phasen →](https://studiomeyer.academy/recipes)

Concepts

[MCPize](https://studiomeyer.academy/concepts/mcpize)

## Sitemap

Public pages of this site as Markdown: [sitemap](https://studiomeyer.academy/sitemap.md). Curated entry point: [llms.txt](https://studiomeyer.academy/llms.txt), full text: [llms-full.txt](https://studiomeyer.academy/llms-full.txt).
