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.
Your kit
# 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: {}
# 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"]
# 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
# 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
}
}
# 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.
# 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.exampleto.envon 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.jsat its root. - The application must listen on 0.0.0.0, port 8000 (the
PORTvariable), 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.envout 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
- 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.
- 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.
- 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.
- 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:
# 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: {}
# 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
Related
Next step
A free public tool by ANAROQ, a company building compute infrastructure. What ANAROQ is building