MCP Server Deploy Kit

Describe your MCP server and get the files to run it on a VPS behind a TLS proxy that streams and checks a bearer token: Compose, Dockerfile, proxy configuration, .env.example and a runbook. Written in your browser; no token is ever written.

The Dockerfile is written for this runtime. With an existing image it is only noted.
The port the server listens on inside its container, on 0.0.0.0.
Where the server answers MCP requests, usually /mcp.
The public host name clients connect to, for example mcp.example.org.
Every request needs Authorization: Bearer <token>; the token is read from .env.
Per client address. Empty means no limit. nginx only: the standard Caddy build has none.
For the Let's Encrypt account.

Your kit

docker-compose.yml
# Generated by the ANAROQ MCP Server Deploy Kit: https://anaroq.com/agents/mcp-server-deploy-kit
# Kit 1.0.0: node, build, caddy, bearer.
# Only the proxy publishes ports; the MCP server is reached on the Docker network.
# The bearer token comes from .env (MCP_BEARER_TOKEN); Compose stops if it is empty.

name: "mcp-server"
services:
  mcp:
    build:
      context: "./server"
    restart: "unless-stopped"
    expose:
      - "8000"
    security_opt:
      - "no-new-privileges:true"
    cap_drop:
      - "ALL"
    networks:
      - "mcp"
  proxy:
    image: "caddy:2.11.7-alpine"
    restart: "unless-stopped"
    depends_on:
      - "mcp"
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      MCP_BEARER_TOKEN: "${MCP_BEARER_TOKEN:?set MCP_BEARER_TOKEN in .env}"
    volumes:
      - "./Caddyfile:/etc/caddy/Caddyfile:ro"
      - "caddy_data:/data"
      - "caddy_config:/config"
    networks:
      - "mcp"
networks:
  mcp: {}
volumes:
  caddy_data: {}
  caddy_config: {}
server/Dockerfile
# syntax=docker/dockerfile:1
# The line above must stay first: it makes BuildKit read this file with the
# current Dockerfile syntax, which the cache mounts below need.

# Stage 1 installs the dependencies in node:24-alpine, a version tag rather than
# latest, so a rebuild uses the same Node.js line.
FROM node:24-alpine AS deps
# Work in /app in this stage.
WORKDIR /app
# Only package.json and package-lock.json first: this layer, and the install
# after it, are rebuilt only when the dependencies change.
COPY package.json package-lock.json ./
# Installs exactly what the lockfile says, without devDependencies. The npm
# cache is a BuildKit cache mount, so it speeds up the next build without
# staying in the image.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

# The final image starts again from node:24-alpine, without the build stage's
# caches and tools.
FROM node:24-alpine
# Production mode for Node.js libraries that read NODE_ENV (Express, for
# example, then caches views and hides stack traces).
ENV NODE_ENV=production
# The port the application should listen on. Many frameworks read PORT; if yours
# does not, set the port in its configuration to 8000.
ENV PORT=8000
# The application lives in /app.
WORKDIR /app
# The installed dependencies from stage 1. The package manager and its cache
# stay behind.
COPY --from=deps /app ./
# The source, after the dependencies so a code change does not reinstall them.
# The files stay owned by root, so the application cannot change its own code;
# .dockerignore keeps .git, .env and local builds out.
COPY . .
# The official Node.js image has an unprivileged user, node (uid 1000); the
# process runs as it, not as root.
USER node
# Documents that the application listens on port 8000. It publishes nothing:
# docker run -p or Compose ports: does.
EXPOSE 8000
# No health check in the image: health is checked elsewhere (an orchestrator
# probe or the Compose file). This states it as a decision.
HEALTHCHECK NONE
# Starts server.js in exec form: the process is PID 1 and receives SIGTERM on
# docker stop.
CMD ["node", "server.js"]
server/.dockerignore
# Git history: large, and it can hold secrets that were removed from the code.
.git

# Environment files and keys: COPY . . would otherwise put them in the image,
# where anyone who pulls it can read them.
.env
.env.*
*.pem
*.key

# The Docker files themselves: the application does not need them, and a change
# to them would invalidate the source layer.
Dockerfile
.dockerignore
compose*.y*ml
docker-compose*.y*ml

# Logs and editor files.
*.log
.DS_Store
.idea
.vscode

# Dependencies installed on your machine: the image installs its own, for its
# own platform.
node_modules
npm-debug.log*
coverage
Caddyfile
# Generated by the ANAROQ Reverse Proxy Generator: https://anaroq.com/tools/reverse-proxy-generator
# Engine @anaroq/compose, reverse proxy 1.1.0, caddy.
# Caddy gets and renews each site's certificate on its own (automatic HTTPS).

mcp.example.org {
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		X-Frame-Options "SAMEORIGIN"
		-Server
	}
	request_body {
		max_size 10MiB
	}
	@no-token not header Authorization "Bearer {$MCP_BEARER_TOKEN}"
	respond @no-token 401
	reverse_proxy mcp:8000 {
		flush_interval -1
	}
}
.env.example
# Generated by the ANAROQ MCP Server Deploy Kit 1.0.0: https://anaroq.com/agents/mcp-server-deploy-kit
# Copy to .env on the server (cp .env.example .env; chmod 600 .env). Never commit .env.

# The token every MCP client sends as `Authorization: Bearer <token>`. Make one with:
#   openssl rand -hex 32
# Letters and digits only: a space or a quote breaks the proxy's check.
MCP_BEARER_TOKEN=

# The MCP server's own settings (API keys, OAuth issuer…) go below, and into the
# `environment` of the `mcp` service in docker-compose.yml.
RUNBOOK.md
# MCP server on mcp.example.org: runbook

Written by the ANAROQ MCP Server Deploy Kit 1.0.0 (https://anaroq.com/agents/mcp-server-deploy-kit). Read every step before you run it.

## What runs

- `mcp`: your MCP server (Dockerfile, node), reachable only from the proxy on the Docker network, port 8000.
- `proxy`: Caddy, the only container with public ports (80 and 443). It terminates TLS and forwards /mcp to the server with streaming.
- Access: the proxy refuses every request without `Authorization: Bearer <token>` (401). The token lives only in `.env` on the server.

## Before you start

- A server with Docker Engine and the Compose plugin, ports 80 and 443 open to the internet.
- DNS: an A (and AAAA, if the server has IPv6) record for mcp.example.org that points to the server.
- A new server can get Docker and a firewall at first boot: https://anaroq.com/tools/cloud-init-generator

## Steps

1. Copy the kit's files to one directory on the server, as they are, and work from it:

   - `docker-compose.yml`
   - `server/Dockerfile`
   - `server/.dockerignore`
   - `Caddyfile`
   - `.env.example`
   - `RUNBOOK.md`

Put your server's code in `server/`, next to its Dockerfile: `package.json`, `package-lock.json`, `server.js`. Only `server/` is sent to the build, never `.env` or the certificates.

2. Create `.env` from the example, put a long random token in it and make it readable by you only:

```sh
cp .env.example .env
chmod 600 .env
sed -i "s/^MCP_BEARER_TOKEN=.*/MCP_BEARER_TOKEN=$(openssl rand -hex 32)/" .env
```

3. Build (when the kit has a Dockerfile) and start, then check that both containers run:

```sh
docker compose up -d --build
docker compose ps
```

## Connection check

With the token, `initialize` must answer HTTP 200 with a `result` that holds `protocolVersion` (as JSON or as an SSE `data:` line):

```sh
set -a; . ./.env; set +a
curl -sS -i https://mcp.example.org/mcp \
  -H "Authorization: Bearer $MCP_BEARER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
```

Without the token, the same request must get HTTP 401:

```sh
curl -sS -i https://mcp.example.org/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
```

## Connect an MCP client

Claude Code:

```sh
claude mcp add --transport http mcp-server https://mcp.example.org/mcp \
  --header "Authorization: Bearer $MCP_BEARER_TOKEN"
```

A client that reads JSON configuration (the key names differ between clients; check your client's documentation):

```json
{
  "mcpServers": {
    "mcp-server": {
      "type": "http",
      "url": "https://mcp.example.org/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_BEARER_TOKEN}"
      }
    }
  }
}
```

## Rotate the token

Put a new token in `.env`, recreate the proxy (it reads the variable at start), then update every client:

```sh
sed -i "s/^MCP_BEARER_TOKEN=.*/MCP_BEARER_TOKEN=$(openssl rand -hex 32)/" .env
docker compose up -d --force-recreate proxy
```

## Certificate renewal

Caddy renews certificates on its own. Keep the `caddy_data` volume: it holds them.

## When it fails

- 502 or 504: the server is not listening on 0.0.0.0:8000 inside its container. `docker compose logs mcp`.
- 401 with the token: `.env` changed after the proxy started; recreate it. A token with spaces or quotes breaks the check: use `openssl rand -hex 32`.
- The stream stops: a stream that sends nothing closes after the proxy's read timeout. Send a keep-alive from the server.
- No certificate: DNS does not point to the server yet, or port 80 is closed. `docker compose logs proxy`.

## Limits

- The kit does not host or run anything for you, and it does not implement OAuth.
- The bearer token is one shared secret: anyone who has it is a full client. Use one per client team and rotate it when someone leaves.
- The server must validate the Origin header and its own input; the proxy does not.

Checked in your browser

  • Compose Analyzer on docker-compose.yml: 0 errors, 0 high, 0 medium, 8 low.
  • Dockerfile Linter on server/Dockerfile: 0 findings.
  • Kit 1.0.0, reverse proxy 1.1.0, Dockerfile Generator 1.0.0.

What to do with the files

  • Copy .env.example to .env on the server and fill it in there. The kit writes no secret: MCP_BEARER_TOKEN is empty, and Compose refuses to start the proxy until it is set.
  • The server must listen on 0.0.0.0:8000 inside its container (not 127.0.0.1), or the proxy cannot reach it.
  • The project needs package.json and package-lock.json committed next to the Dockerfile, and server.js at its root.
  • The application must listen on 0.0.0.0, port 8000 (the PORT variable), not on 127.0.0.1, or nothing outside the container reaches it.
  • The tags follow security updates of their version line. To freeze the exact image, add its digest (image:tag@sha256:…) and update it on purpose.
  • Point the A/AAAA records of mcp.example.org to this server before the proxy starts: the certificate authority checks them over HTTP.
  • Ports 80 and 443 must reach the proxy from the internet: 80 for the ACME HTTP challenge and the redirect, 443 for HTTPS.
  • Requests without Authorization: Bearer <token> get 401. The proxy reads the token from MCP_BEARER_TOKEN in its environment, so the files never hold it: set it in .env, keep .env out of the repository, and make it long and random (openssl rand -hex 32).
  • Streamed responses (Server-Sent Events, MCP Streamable HTTP) pass through without buffering. nginx closes a stream that sends nothing for 1h, Caddy keeps it open; a server that holds a stream open should send a keep-alive more often than that.
  • RUNBOOK.md has the steps in order, the connection check from an MCP client, token rotation and what to do when it fails.

What it does

A remote MCP server is an HTTP service: clients such as Claude Code connect to it over Streamable HTTP. Putting one on a VPS takes the same pieces every time: the server in a container, a proxy in front with a certificate, responses that stream without buffering, and something that keeps strangers out.

This kit writes those pieces from the engines the site already uses: the Reverse Proxy Generator writes the proxy, the Compose engine writes docker-compose.yml and its Analyzer checks it, and the Dockerfile Generator writes the Dockerfile, checked by the Dockerfile Linter.

How it works

  1. The files are written in your browser. The same choices always give the same bytes; nothing you type is sent, stored or put in the link.
  2. Only the proxy publishes ports (80 and 443). The server is reached by its service name on a private Docker network, with no port of its own on the internet.
  3. With a bearer token, the proxy refuses every request without Authorization: Bearer <token> with 401. The token is read from the proxy's environment, from .env: the kit writes an empty variable, and Compose refuses to start until you fill it in on the server.
  4. Streaming: the proxy passes Server-Sent Events as they arrive (Caddy flush_interval -1, nginx proxy_buffering off) with long read and send timeouts.

The files

docker-compose.yml
The server and the proxy on one private network; only the proxy has ports.
server/Dockerfile, server/.dockerignore
When you build from code: multi-stage, a non-root user, no HEALTHCHECK (an MCP endpoint does not answer a plain GET).
Caddyfile, templates/anaroq-proxy.conf.template
TLS, HTTP to HTTPS, security headers, the token check, streaming and the rate limit. nginx reads the token through the official image's templates.
.env.example
The token variable, empty, and how to make one: openssl rand -hex 32.
RUNBOOK.md
The steps in order, the first certificate for nginx, the connection check with curl and an MCP client, token rotation, renewal and what to do when it fails.

Example

A Node.js server built from its code, behind Caddy with a bearer token, written by the same code at build time:

docker-compose.yml
# Generated by the ANAROQ MCP Server Deploy Kit: https://anaroq.com/agents/mcp-server-deploy-kit
# Kit 1.0.0: node, build, caddy, bearer.
# Only the proxy publishes ports; the MCP server is reached on the Docker network.
# The bearer token comes from .env (MCP_BEARER_TOKEN); Compose stops if it is empty.

name: "mcp-server"
services:
  mcp:
    build:
      context: "./server"
    restart: "unless-stopped"
    expose:
      - "8000"
    security_opt:
      - "no-new-privileges:true"
    cap_drop:
      - "ALL"
    networks:
      - "mcp"
  proxy:
    image: "caddy:2.11.7-alpine"
    restart: "unless-stopped"
    depends_on:
      - "mcp"
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      MCP_BEARER_TOKEN: "${MCP_BEARER_TOKEN:?set MCP_BEARER_TOKEN in .env}"
    volumes:
      - "./Caddyfile:/etc/caddy/Caddyfile:ro"
      - "caddy_data:/data"
      - "caddy_config:/config"
    networks:
      - "mcp"
networks:
  mcp: {}
volumes:
  caddy_data: {}
  caddy_config: {}
Caddyfile
# Generated by the ANAROQ Reverse Proxy Generator: https://anaroq.com/tools/reverse-proxy-generator
# Engine @anaroq/compose, reverse proxy 1.1.0, caddy.
# Caddy gets and renews each site's certificate on its own (automatic HTTPS).

mcp.example.org {
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		X-Frame-Options "SAMEORIGIN"
		-Server
	}
	request_body {
		max_size 10MiB
	}
	@no-token not header Authorization "Bearer {$MCP_BEARER_TOKEN}"
	respond @no-token 401
	reverse_proxy mcp:8000 {
		flush_interval -1
	}
}

Limits

  • ANAROQ does not host or run your server. The kit writes files; you run them on your own server.
  • OAuth is not implemented: choosing it only documents what the server must do and leaves the proxy without a check. A server without working authorization is open to anyone who finds it.
  • One bearer token is one shared secret: anyone who has it is a full client. Rotate it when someone leaves, and keep .env out of every repository.
  • Rate limiting is nginx's (per client address). The standard Caddy build has none; behind a CDN every request comes from the CDN's address.
  • The kit is checked by starting a sample MCP server behind each proxy (initialize with and without the token, streaming, rate limit). Your server's own behaviour is yours to test.

Before the kit

Methodology: how the numbers are worked out

Sources

  1. Model Context Protocol: Transports (Streamable HTTP)
  2. Model Context Protocol: Authorization
  3. Caddy: reverse_proxy (flush_interval)
  4. nginx Docker image: using environment variables in configuration
  5. nginx: ngx_http_limit_req_module
  6. Claude Code Docs: Connect to MCP servers

Updated

Next step

Agent Permissions Generator

A free public tool by ANAROQ, a company building compute infrastructure. What ANAROQ is building