Skip to content

Deploy an MCP server with Docker and nginx

Spec
2026-07-28
Evidence
E2Reproducedarm64
Go SDK
github.com/modelcontextprotocol/go-sdk@v1.8.0
MCP Inspector
@modelcontextprotocol/inspector@2.12.0
Verified

A Streamable HTTP server is an ordinary web service, with two differences that matter to a proxy: a response can be a stream of events that arrives over many seconds, and a tool call can stay silent for a long time before it answers. This guide deploys the Go HTTP example with Docker Compose behind nginx and tests what breaks.

We tested with Docker 29.2 on Linux (arm64) and nginx:1.29-alpine (nginx 1.29.8).

EditorDockerfile
examples/mcp/deploy/Dockerfile
# Build the Go Streamable HTTP example into a small image.
# Build context is examples/mcp/ (see compose.yaml).
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go/http-server/go.mod go/http-server/go.sum ./
RUN go mod download
COPY go/http-server/*.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mcp-server .
FROM alpine:3.22
RUN adduser -D -H mcp
USER mcp
COPY --from=build /mcp-server /usr/local/bin/mcp-server
# Listen on all interfaces inside the container; only the proxy publishes a port.
ENV ADDR=0.0.0.0:8080
EXPOSE 8080
ENTRYPOINT ["mcp-server"]

A two-stage build: the Go toolchain compiles a static binary, and the final image holds only that binary on Alpine, running as a non-root user. Ours came to 25 MB. The server listens on 0.0.0.0:8080 inside the container; it does not need to be reachable from outside.

Editornginx.conf
examples/mcp/deploy/nginx.conf
# Reverse proxy for a Streamable HTTP MCP server at /mcp.
server {
listen 80;
location = /mcp {
proxy_pass http://mcp:8080;
proxy_http_version 1.1;
# A response can be an event stream: progress notifications, then the result.
# Pass each event on as it arrives. gzip on text/event-stream held every event
# until the call ended in our test, so keep compression off here.
proxy_buffering off;
proxy_cache off;
gzip off;
# nginx answers 504 if the upstream sends nothing for this long (default 60s).
# Set it above your slowest tool call, or have tools report progress.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Nothing else is served.
location / {
return 404;
}
}
Editorcompose.yaml
examples/mcp/deploy/compose.yaml
# docker compose up --build -d → http://127.0.0.1:8380/mcp
services:
mcp:
build:
context: ..
dockerfile: deploy/Dockerfile
restart: unless-stopped
# No published port: only the proxy can reach it.
proxy:
image: nginx:1.29-alpine
depends_on: [mcp]
ports:
- "127.0.0.1:8380:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped

Only the proxy publishes a port, bound to 127.0.0.1 here. On a real host you would publish 443 and terminate TLS in nginx.

TerminalBuild and start
Terminal window
cd examples/mcp/deploy
docker compose up --build -d
docker compose ps

ExpectedTwo containers running; the proxy on 127.0.0.1:8380.

TerminalCall a tool through the proxy
Terminal window
npx -y @modelcontextprotocol/inspector@2.12.0 --cli \
http://127.0.0.1:8380/mcp --protocol-era auto \
--method tools/call --tool-name add --tool-arg a=2 b=3 --format json

ExpectedstructuredContent is {"sum": 5}.

Through the proxy, the TypeScript client SDK connected with the 2025-11-25 handshake by default and with 2026-07-28 in auto and pinned modes, the same as without the proxy.

The example’s count tool sends a progress event every second. We timed when each event reached curl through different proxy settings, for count with n = 3:

nginx setting When the events arrived
The config above (proxy_buffering off, gzip off) One per second
Buffering left at its default One per second
Only proxy_pass, nothing else One per second
gzip on with text/event-stream in gzip_types All at once, after 3 seconds

So in our test nginx’s default buffering did not hold events back, but compression did: the client saw no progress until the call ended. Keep gzip off for the MCP location, and keep proxy_buffering off as well. It costs nothing here, and other proxies, CDNs and versions may buffer.

To repeat the test, send a tools/call for count with a progressToken in _meta, and run curl -N so curl itself does not buffer.

nginx closes the connection if the upstream sends nothing for proxy_read_timeout (60 seconds by default). We set it to 2 seconds and called count for 3 seconds without a progress token, so the server stayed silent:

504 Gateway Time-out

nginx logged upstream timed out (110: Operation timed out) while reading response header from upstream. The Inspector reported:

{"error":{"code":"error","message":"Error POSTing to endpoint: <html>…504 Gateway Time-out…","status":504}}

With a progress token, the same call succeeded: each event reset the timer. Two fixes, best used together:

  • Set proxy_read_timeout above your slowest tool call. The config above uses 300 seconds.
  • Report progress from long-running tools, so the stream is never quiet for long.

The Go SDK refuses requests that arrive on a localhost address with a non-localhost Host header, to block DNS rebinding. In the Compose setup this never triggers, because nginx reaches the server over the container network. It does trigger when nginx runs on the same host and forwards to 127.0.0.1 with the public host name:

location = /mcp {
proxy_pass http://127.0.0.1:8331;
proxy_set_header Host $host; # the client's Host, e.g. mcp.example.com
}
403 Forbidden: invalid Host header "mcp.example.com"

Remove the proxy_set_header Host line. nginx then sends the host from proxy_pass (127.0.0.1:8331), and the request passes. Turning the check off (DisableLocalhostProtection in the Go SDK) is the other option, but it removes the protection for every request.

Not every SDK has this check. In our tests the Spring AI starter accepted any Host. Do not rely on the server alone to reject requests that should never reach it.

Terminal window
docker compose down --rmi local

Sources

MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.