Deploying MCP Servers to Production: Hosting, Docker, and Transport Decisions
Most MCP servers run on a developer's laptop. When you need a team to share one, everything changes: transport, auth, lifecycle, and monitoring all become real problems.
Most MCP servers run locally on a developer’s laptop. That’s fine for personal tools. I run scry, tome, lore, and flume as local daemons and they work great in that mode. But when you want a team to share a server, or run one in production serving multiple clients, the deployment model changes significantly. Transport choice, auth, health checks, and lifecycle management all become real concerns.
This post covers what I’ve learned about taking MCP servers from “runs on my machine” to “runs for a team.”
Local vs remote: what actually changes
A local MCP server has it easy. It communicates over stdio. The client spawns it as a child process, pipes JSON-RPC messages through stdin/stdout, and when the client dies, the server dies with it. No network. No authentication. No lifecycle management. The operating system handles everything.
Remote servers lose all of those conveniences:
- Transport: stdio doesn’t work over a network. You need HTTP.
- Authentication: anyone who can reach the server can call your tools. You need auth on every request.
- Lifecycle: the server must stay alive independently of any client. It needs graceful shutdown, restart tolerance, and crash recovery.
- Discovery: clients need to find the server. That means a stable URL, DNS, TLS.
- Monitoring: you can’t watch stdout anymore. You need structured logs, health checks, and metrics.
None of this is exotic. It’s the same set of problems you solve deploying any HTTP service. The difference is that MCP adds a protocol layer on top, and you need to make sure your deployment infrastructure doesn’t break the protocol’s assumptions.
Choosing a transport for production
The MCP spec defines three transports: stdio, Streamable HTTP, and the legacy HTTP+SSE (deprecated). For remote deployments, Streamable HTTP is the answer in almost every case.
Why:
- It’s plain HTTP. All of your existing infrastructure, including firewalls, reverse proxies, load balancers, and CDNs, works without modification.
- It supports both request-response and server-sent events on the same endpoint, so tools that return quickly and tools that stream progress both work.
- It’s stateless from the infrastructure’s perspective. Each request is independent. You can restart the server without breaking long-lived connections.
Here’s a production-ready Streamable HTTP server in Go with graceful shutdown:
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
s := server.NewMCPServer(
"my-server",
"1.0.0",
server.WithToolCapabilities(true),
)
// Register your tools
s.AddTool(mcp.NewTool("ping",
mcp.WithDescription("Health check tool"),
), handlePing)
httpServer := server.NewStreamableHTTPServer(s)
srv := &http.Server{
Addr: ":" + envOrDefault("PORT", "8080"),
Handler: httpServer,
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Graceful shutdown
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
logger.Info("server starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("shutdown error", "error", err)
}
}
func handlePing(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultText("pong"), nil
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
The key details: WriteTimeout is set high enough for streaming responses. Signal handling gives in-flight requests time to complete. The server logs structured JSON, not human-readable text. You’ll thank yourself when you’re debugging at 2am.
Docker deployment
A Go MCP server is a static binary. That makes Docker trivial:
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Multi-stage build, scratch base, no shell, no package manager, no attack surface. The final image is typically under 20MB.
Docker Compose for local team testing:
services:
mcp-server:
build: .
ports:
- "8080:8080"
environment:
- PORT=8080
- LOG_LEVEL=info
- OAUTH_ISSUER=https://auth.example.com
healthcheck:
test: ["CMD", "/server", "--health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
One thing I’ve learned: don’t bake configuration into the image. Everything goes through environment variables: database URLs, API keys, log levels, feature flags. The binary stays the same across environments.
For the health check, I prefer building a --health flag into the binary itself that makes an HTTP call to the server’s health endpoint. This avoids needing curl or wget in a scratch image.
Authentication in production
If your MCP server faces any network (even a private one), it needs authentication. The spec mandates OAuth 2.1 for remote servers.
The flow:
- Client discovers the server’s OAuth metadata at
/.well-known/oauth-authorization-server - Client obtains a token (typically via authorization code flow for interactive clients, or client credentials for service-to-service)
- Client includes the token as a Bearer token on every request
- Server validates the token on every request
In practice, you’re usually delegating to an existing identity provider. Your company already has OAuth infrastructure: Auth0, Okta, your own Keycloak instance, whatever. The MCP server is a resource server that validates tokens against that provider.
The mcp-go SDK supports auth middleware:
httpServer := server.NewStreamableHTTPServer(s,
server.WithHTTPMiddleware(oauthMiddleware),
)
func oauthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims, err := validateToken(token)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Practical advice: start with client credentials grants for team deployments. They’re the simplest OAuth flow. Your CI system or team members get a client ID and secret, exchange them for a token, and use the token. No browser redirects, no authorization pages. Save the full authorization code flow for when you’re serving end users who need to authenticate interactively.
Health checks and monitoring
Every production MCP server needs a /health endpoint. Keep it simple:
mux := http.NewServeMux()
mux.Handle("/mcp", httpServer)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
This gives your load balancer, Docker health check, and monitoring system something to probe. If your server depends on a database or external service, check that connectivity in the health endpoint and return a 503 if it’s degraded.
For logging, structured JSON to stdout is non-negotiable. Use slog in Go. Every log line should include at minimum: timestamp, level, message, request ID, and tool name (for tool calls). Don’t use fmt.Println in production code. It’s not parseable, not queryable, and not useful when you have more than one server running.
Metrics you should track:
- Request count by tool: which tools are being called and how often
- Latency by tool: P50, P95, P99. Some tools are fast lookups, some hit external APIs
- Error rate by tool: a tool failing 50% of the time is a problem you want to know about before your users tell you
- Active connections: how many clients are currently connected
- Token validation failures: spikes here mean something is misconfigured or someone is probing
Expose these as Prometheus metrics or push them to whatever observability stack you use. The MCP protocol doesn’t prescribe a metrics format, so use whatever your team already has.
What I’d do for a team deployment
If I needed to deploy a shared MCP server for a team of 5-20 engineers tomorrow, here’s what I’d build:
- Single Go binary with Streamable HTTP transport
- Caddy as a reverse proxy handling TLS via Let’s Encrypt (zero certificate management)
- OAuth client credentials for auth, so each engineer gets a client ID/secret pair
- Structured JSON logging to stdout, collected by whatever log aggregator the team uses
- Docker container deployed on a single VM or a small Kubernetes deployment
/healthendpoint for liveness probes- Prometheus metrics endpoint at
/metrics
Nothing else is needed. No service mesh. No API gateway. No managed MCP hosting platform. A single binary behind a reverse proxy with TLS and auth handles the vast majority of team use cases.
The Caddyfile is three lines:
mcp.internal.example.com {
reverse_proxy localhost:8080
}
Caddy automatically provisions and renews the TLS certificate. Your server gets HTTPS for free.
For larger deployments (multiple replicas, horizontal scaling), you need session affinity or a stateless server design. Streamable HTTP helps here because each request is independent, but if your server maintains in-memory state between calls, you’ll need to externalize that state to Redis or a database.
Where to go from here
This post covers the infrastructure side of MCP deployment. For the protocol fundamentals, start with the pillar guide. For building the server itself in Go, see Building MCP Servers in Go. For more on transport mechanics and the differences between stdio, SSE, and Streamable HTTP, read MCP Transport Patterns.
The deployment story for MCP servers is still maturing. The protocol is young and most servers today are local-only. But the teams I’ve seen move to shared remote servers have found it straightforward. It’s just HTTP, and we know how to deploy HTTP services.
Frequently Asked Questions
How do I deploy an MCP server to production?
Build your MCP server as a single binary (Go is ideal for this), package it in a Docker container using a multi-stage build, expose it over Streamable HTTP transport, put it behind a reverse proxy like Caddy or nginx with TLS termination, and add OAuth 2.1 authentication. The server needs a health check endpoint, structured logging, and graceful shutdown handling.
How do I host an MCP server for a team?
Run the MCP server as a long-lived HTTP service behind a reverse proxy with TLS. Use Streamable HTTP transport so any standard HTTP client can connect. Add OAuth 2.1 tokens for authentication so each team member has their own identity. Deploy with Docker or as a systemd service. The server needs to stay running independently of any client connection.
Can I run an MCP server in Docker?
Yes. Use a multi-stage Dockerfile: build stage with the Go toolchain, production stage with scratch or distroless base. The final image should contain only the static binary. Expose the HTTP port, pass configuration via environment variables, and define a health check against your /health endpoint.
What transport should I use for a production MCP server?
Streamable HTTP. It works behind reverse proxies and load balancers, passes through firewalls, uses standard HTTP infrastructure your ops team already understands, and supports both request-response and streaming patterns. Stdio is only appropriate for local servers that run on the same machine as the client.