MCP server đầu tiên tôi đưa lên prod là một bản nhỏ vài tool query database nội bộ. Tôi deploy lên Cloudflare Workers, build mất 90 giây, lấy URL .workers.dev, paste vào config Cursor, hoạt động ngay. Vài tuần sau muốn thêm Postgres add-on cho server thứ hai, tôi nhận ra Workers không phải chỗ cho cái đó. Dọn sang Railway. Tới khi compliance team ép data không được rời mạng nội bộ, tôi dọn server thứ ba về Docker trên homelab.

Ba bậc thang này không phải bậc nào cũng tốt hơn bậc nào. Mỗi nơi giải một bài toán khác. Bài này đi qua đủ ba: Cloudflare Workers, Railway, self-host Docker. Code mẫu, secret management, HTTPS, observability, và cuối bài là khi nào bạn nên đổi tier.

Quan điểm cá nhân trước: Cloudflare Workers là default cho hobby và prototype. Railway cân nhắc khi cần Postgres hoặc Redis. Self-host chỉ khi compliance hoặc latency nội bộ ép. Đừng over-engineer ở bước 0. Tôi đã thấy nhiều team chọn Kubernetes cho 200 request/ngày, kết quả ngốn cuối tuần debug ingress trong khi feature chính đứng yên.

Cloudflare Workers, default cho hobby

Cloudflare là chỗ rẻ nhất, maintenance gần như zero, và là chỗ Anthropic đầu tư mạnh trong 2025-2026. Họ có sẵn agents-mcp SDK, OAuth helper, và Durable Objects cho session state. Bạn không phải nghĩ gì về HTTPS hay scaling.

Khởi tạo project bằng Wrangler CLI:

npm create cloudflare@latest -- remote-mcp-server-authless \
  --template=cloudflare/ai/demos/remote-mcp-authless
cd remote-mcp-server-authless
npm install

Template tạo ra một project với src/index.ts extend McpAgent. Đây là wrapper Cloudflare làm sẵn để map MCP Streamable HTTP vào một Durable Object instance:

import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export class MyMcp extends McpAgent {
  server = new McpServer({
    name: "demo-mcp",
    version: "1.0.0",
  });

  async init() {
    this.server.tool(
      "add",
      "Cộng hai số",
      { a: z.number(), b: z.number() },
      async ({ a, b }) => ({
        content: [{ type: "text", text: String(a + b) }],
      })
    );
  }
}

export default {
  fetch(request: Request, env: Env, ctx: ExecutionContext) {
    return MyMcp.mount("/mcp").fetch(request, env, ctx);
  },
};

Phần wrangler.jsonc bind class MyMcp thành một Durable Object, deploy bằng npx wrangler deploy, output trả về URL dạng https://demo-mcp.<your-account>.workers.dev/mcp. Đó là endpoint Streamable HTTP để Claude Desktop, Cursor, gptme connect vào.

Vì sao phải Durable Objects? Spec MCP cần session state: client gửi initialize, server trả sessionId, các call sau cùng session phải chia sẻ state. Worker bình thường là stateless, mỗi request có thể chạy ở edge khác. Durable Object pin một session vào đúng một instance, giải đúng nhu cầu này. Bonus: WebSocket hibernation cho phép instance ngủ khi idle, cost gần như zero giờ không có traffic.

Khi cần auth (đa số production), đổi template sang cloudflare/ai/demos/remote-mcp-github-oauth. Template này dựng sẵn /.well-known/oauth-protected-resource, OAuth flow qua GitHub, và Resource Indicator (RFC 8707) cho token binding. Đây chính là chuẩn bài 4 đã nói tới, Cloudflare làm sẵn cho bạn.

Secret trên Workers chia ba chỗ. Public config (không bí mật) commit vào wrangler.jsonc. Bí mật runtime set qua CLI npx wrangler secret put OPENAI_API_KEY. Dữ liệu lớn dùng Workers KV hoặc R2. Trong code đọc qua env.OPENAI_API_KEY. Tuyệt đối không hardcode secret vào wrangler.jsonc vì file đó vào git.

HTTPS Cloudflare lo hết, custom domain thêm qua dashboard Workers, tab Settings, Triggers, thêm Custom Domain. Cloudflare tự tạo DNS record và cert nếu domain bạn đang manage qua Cloudflare. Endpoint MCP của bạn giờ là https://mcp.example.com/mcp. Sạch.

Railway, khi bạn cần một process Linux đầy đủ

Cloudflare Workers mạnh nhưng có giới hạn: CPU time mỗi request bị cap (10 ms free, 50 ms Bundled paid, 30 giây Unbound), không có process Linux đầy đủ, package npm dùng fs, net, hoặc native binding sẽ không chạy. Khi tool của bạn cần một process Node.js hoặc Python kiểu cũ, Railway là điểm cân bằng giữa managed và linh hoạt.

Railway có template gallery cho MCP, hai cái phổ biến nhất là Railway MCP Servers (Sokanon) và arifOS MCP. Một-click chạy qua URL https://railway.com/deploy/mcp-servers, login, chọn region, đợi 2-3 phút là có endpoint.

Khi bạn muốn server tự viết, push code lên GitHub, tạo project mới trên Railway, chọn “Deploy from GitHub repo”, trỏ tới repo, Railway tự detect Node hoặc Python qua Railpack, build, deploy. Generate Domain trong Settings để lấy URL HTTPS.

File src/index.ts chạy MCP server qua HTTP với SDK chính chủ:

import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

const app = express();
app.use(express.json());

const server = new McpServer({ name: "my-mcp", version: "1.0.0" });

server.tool(
  "ping",
  "Trả về pong",
  {},
  async () => ({ content: [{ type: "text", text: "pong" }] })
);

app.all("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID(),
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

const port = process.env.PORT ?? 3000;
app.listen(port, () => console.log(`MCP listening on :${port}`));

Railway tự inject PORT ở runtime, đừng hardcode. Mở project, tab Variables, thêm OPENAI_API_KEY=sk-..., Railway redeploy service tự động. Tách biến theo environment (production, staging), đừng nhồi tất cả vào một env duy nhất.

Điểm Railway hơn Workers rõ nhất: bạn thêm Postgres, Redis, hoặc MongoDB chỉ vài click. Database tự inject DATABASE_URL vào service, code chỉ cần đọc process.env.DATABASE_URL. Volume cho file persistent cũng có sẵn. Khi server cần lưu state, audit log, hoặc cache, Railway không bắt bạn nghĩ thêm. Đây là lý do server thứ hai của tôi chạy ở đây thay vì Workers.

Custom domain: Settings, Networking, Custom Domain, nhập hostname, Railway show CNAME record, bạn add vào DNS provider của mình. SSL cert Railway tự xử lý qua Let’s Encrypt.

Self-host Docker, khi compliance ép

Khi compliance, latency nội bộ, hoặc cost ở scale lớn ép bạn phải tự host, Docker Compose vẫn là cách chuẩn. Setup này cho bạn full control: distro, kernel, reverse proxy, observability stack. Trade off: bạn ăn toàn bộ chi phí maintenance, security patching, scaling.

Dockerfile đa-stage cho server Node 22:

FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Cặp docker-compose.yml với Caddy reverse proxy (auto-HTTPS qua Let’s Encrypt, config 2 dòng):

services:
  mcp:
    build: .
    restart: unless-stopped
    environment:
      OPENAI_API_KEY: ${OPENAI_API_KEY}
      DATABASE_URL: ${DATABASE_URL}
    expose:
      - "3000"
    networks: [edge]

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks: [edge]

volumes:
  caddy_data:
  caddy_config:

networks:
  edge:

Caddyfile:

mcp.example.com {
  reverse_proxy mcp:3000
  encode gzip
  log {
    output file /var/log/caddy/access.log
  }
}

Trỏ DNS mcp.example.com về IP server, docker compose up -d, Caddy tự lấy cert. Hết. Caddy là lựa chọn mặc định của tôi cho self-host vì config ngắn nhất. Traefik tốt khi có nhiều service vì label-based routing, Nginx control sâu nhất nhưng phải tự config cert qua certbot. Cân nhắc Docker MCP Gateway nếu bạn host nhiều MCP server và muốn một entry point chung, dịch giữa stdio, SSE, và streamable-http.

Secret trên VPS có ba cấp. File .env trong gitignore là đủ cho hobby, compose tự đọc. Docker secrets mount vào /run/secrets/, không lộ qua env, bật bằng swarm hoặc secrets: block trong compose. Vault HashiCorp khi audit nội bộ đòi short-lived credential và rotation tự động. Một ví dụ Docker secret trong compose:

services:
  mcp:
    secrets:
      - openai_api_key
    environment:
      OPENAI_API_KEY_FILE: /run/secrets/openai_api_key

secrets:
  openai_api_key:
    file: ./secrets/openai_api_key.txt

App đọc process.env.OPENAI_API_KEY_FILE rồi readFile thay vì đọc env trực tiếp. Pattern này tránh secret nằm trong docker inspect output, khá quan trọng khi nhiều người được mount Docker socket.

Observability, không phụ thuộc nơi deploy

Logs, metrics, traces. Ba thứ phải có khi server lên production thật, không phụ thuộc bạn deploy ở đâu. Cloudflare có Workers Logs dashboard, cấu hình retention dài hơn qua Logpush. Railway stream logs vào tab Logs, export qua webhook đến Datadog hoặc Logtail. Docker self-host dùng docker logs cho dev và ship log qua Promtail/Loki cho production.

Một nguyên tắc quan trọng: đừng log payload tool call thô. Tool argument có thể chứa API key, PII, hoặc secret từ user. Sanitize trước khi ghi. Pattern hay dùng: log tool_name, session_id, latency_ms, error_code, không log argumentsresult. Tôi từng audit một server MCP của team khác thấy raw token Bearer đi vào application log, retention 90 ngày. Đó là sự cố chờ xảy ra.

OpenTelemetry là chuẩn 2026 cho MCP observability. GenAI Semantic Conventions define attribute như gen_ai.tool.name, gen_ai.tool.call.duration, gen_ai.system, cho phép tag span MCP tool call thống nhất giữa các vendor. Setup tối thiểu trong Node:

import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
  }),
});
sdk.start();

Ship traces về Tempo, Jaeger, Elastic APM, hoặc bất cứ OTel-compatible backend nào.

Rate limit là phần khác đáng làm sớm. Production cần rate limit để chống abuse và bảo vệ tool downstream. Edge level dùng Rule rate-limit của Cloudflare chặn theo IP hoặc theo header Authorization. Application level dùng middleware Express express-rate-limit cho self-host hoặc Railway. Gateway level (Docker MCP Gateway, Bifrost, MintMCP tier enterprise) cấp rate-limit theo tool, theo user. Ví dụ Express middleware:

import rateLimit from "express-rate-limit";

app.use("/mcp", rateLimit({
  windowMs: 60_000,
  max: 60,
  message: "Rate limit exceeded",
}));

So sánh cost và complexity

Tiêu chíCloudflare WorkersRailwaySelf-host Docker
Setup ban đầu5 phút10 phút30-60 phút
Cost idleGần $0~$5/thángCost VPS
Cost khi traffic caoPay-per-requestPay-per-RAM-hourLinear theo VPS size
State persistentDurable Objects, KV, R2DB add-on, volumeBất cứ DB nào
HTTPSAuto, Cloudflare certAuto, Let’s EncryptManual hoặc Caddy auto
LimitCPU time per requestRAM/CPU theo planTài nguyên VPS
Custom domainVài clickVài clickTự setup DNS
Vendor lock-inCao (Durable Objects, KV)Vừa (Railway-specific env)Thấp
ObservabilityWorkers Logs, LogpushLogs tab, webhook exportOTel stack tự dựng
Phù hợp khiHobby, prototype, traffic burstTeam nhỏ cần DBEnterprise, compliance, scale

Khi nào nâng cấp tier

Ba “trigger” tôi thấy hay đẩy người ta đổi tier. Một là tool gọi LLM downstream mà CPU time mỗi request vượt 30 giây. Cloudflare Workers Unbound cap đúng 30 giây, tool nào lâu hơn phải đi Railway hoặc self-host. Hai là cần persistent state phức tạp. Durable Objects ổn cho session, nhưng khi bạn cần Postgres, full-text search, hay queue, Railway tiết kiệm thời gian setup hơn rất nhiều. Ba là compliance hoặc data sovereignty. Data không được rời khỏi network nội bộ thì self-host trong VPC riêng là cách duy nhất.

Bắt đầu với Cloudflare Workers cho 99% prototype. Khi gặp một trong ba trigger trên, migrate. Đừng migrate trước.

Bước tiếp

Bài 9 đi vào registry và discovery: publish MCP server cho người khác tìm thấy, đăng ký vào mcp.so, semantic versioning cho schema. Bài 10 nói về authn/authz nâng cao cho server multi-tenant. Bài 11 vào MCP Inspector cùng security checklist trước khi go live.

Hiện tại tôi đang chạy ba MCP server song song. Một trên Cloudflare Workers cho 3 tool nhẹ public, cost gần $0. Một trên Railway có Postgres add-on cho team. Một trên homelab Docker cho 5 tool gọi DB nội bộ, không bao giờ rời network. Ba chỗ phục vụ ba mục đích, không phải một thay cho cái khác. Khi nào workload bạn chia thành nhiều profile như vậy, bảng so sánh phía trên mới thực sự có nghĩa.

Tham khảo