From 2dd5e2ee8096cd9c089659a4d75fce5e56b93069 Mon Sep 17 00:00:00 2001 From: Nshuti7 Date: Mon, 6 Jul 2026 22:19:31 +0300 Subject: [PATCH 1/5] Gate HTTP/SSE endpoints on an optional shared-secret token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MCP_AUTH_TOKEN as an optional env var. When set, every request to /mcp, /sse, and /messages must present the token as either 'Authorization: Bearer ' (RFC 6750-clean, preferred) or '?token=' query param. The query-string path exists so this server works with MCP client UIs that only accept a URL — the Cowork 'Add custom connector' dialog and equivalents — which have no way to attach a custom header. Comparison is timing-safe. When MCP_AUTH_TOKEN is unset, behavior is unchanged (endpoints open) and a startup warning is logged. /health stays open regardless so orchestrators can probe. --- .env.example | 8 ++++++ README.md | 42 +++++++++++++++++++++++++++++ src/http-server.ts | 67 +++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index f24bb43..269b4f8 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,11 @@ MCP_TRANSPORT=http # External port mapping (container always uses 3000 internally) EXTERNAL_PORT=3000 + +# Optional shared-secret gate on /mcp, /sse, /messages. When set, callers +# must send `Authorization: Bearer ` OR append `?token=` +# to the URL (the query-string form exists for MCP client UIs like Cowork's +# "Add custom connector" dialog, which only accept a URL). When unset, the +# endpoints are open — fine for local dev, NOT fine for a public URL. +# Generate one with: openssl rand -hex 32 +# MCP_AUTH_TOKEN= diff --git a/README.md b/README.md index adf1224..f79621a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,51 @@ # Dokploy MCP Server +> **Fork note.** This is a fork of [Dokploy/mcp](https://github.com/Dokploy/mcp) that adds an opt-in shared-secret gate on the HTTP transport so the server can be safely exposed to Claude / Cowork over the public internet. See [HTTP endpoint authentication](#http-endpoint-authentication-fork-addition) for details. Everything else is unchanged from upstream. + [![npm version](https://img.shields.io/npm/v/@dokploy/mcp.svg)](https://www.npmjs.com/package/@dokploy/mcp) [Install in VS Code (npx)](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%7B%22name%22%3A%22dokploy-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40dokploy%2Fmcp%40latest%22%5D%7D) Dokploy MCP Server exposes **all Dokploy API endpoints** as tools consumable via the Model Context Protocol (MCP). It allows MCP-compatible clients (e.g., AI models, other applications) to interact with your Dokploy server programmatically. +## HTTP endpoint authentication (fork addition) + +The upstream project ships the HTTP/SSE transports with **no authentication** on `/mcp`, `/sse`, or `/messages`. That's fine when they're bound to `localhost`, but exposing them publicly (Cowork, Claude Desktop's URL-based custom connectors, remote CLI) means anyone who can reach the URL can call every tool against your Dokploy instance. + +This fork adds an optional shared-secret gate. Set the env var: + +```bash +MCP_AUTH_TOKEN=$(openssl rand -hex 32) +``` + +Callers then present the token in one of two ways: + +**Header (preferred, RFC 6750-clean)** — works with the Claude CLI and anything else that lets you attach headers: + +```bash +claude mcp add --transport http dokploy https://mcp.yourdomain.tld/mcp \ + --header "Authorization: Bearer $MCP_AUTH_TOKEN" +``` + +**Query string** — works with UIs that only take a URL (Cowork's "Add custom connector" dialog, etc.): + +``` +https://mcp.yourdomain.tld/mcp?token= +``` + +Both paths use the same timing-safe comparison. Requests presenting neither (or a wrong value) get `401 Unauthorized` with a `WWW-Authenticate: Bearer` header. The `/health` endpoint is intentionally left open for container orchestrators. + +If `MCP_AUTH_TOKEN` is unset, behavior matches upstream (endpoints open) and the process logs a warning at startup. + +**Why offer the query-string form at all?** RFC 6750 discourages tokens in URLs because they can leak via server access logs, referer headers, or browser history. None of those apply here: this URL is only ever POST'd by an MCP client (never navigated in a browser), and this process doesn't log request URLs. Prefer the header when your client supports it. + +For Cowork's "Add custom connector" dialog: + +- **Name:** anything (e.g. `Dokploy`) +- **URL:** `https://mcp.yourdomain.tld/mcp?token=` — note the `/mcp` path *and* the `?token=` query param +- Leave OAuth Client ID / Client Secret blank — this server uses a static shared secret, not OAuth 2.0. + +--- + + With **508 tools** across **49 categories**, this server provides complete coverage of the Dokploy API — from project and application management to databases, notifications, SSO, Docker, backups, and more. ## Getting Started diff --git a/src/http-server.ts b/src/http-server.ts index 0ab5e9b..a759d32 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -1,12 +1,12 @@ #!/usr/bin/env node -import { randomUUID } from "node:crypto"; +import { randomUUID, timingSafeEqual } from "node:crypto"; import type { HttpBindings } from "@hono/node-server"; import { serve } from "@hono/node-server"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { Hono } from "hono"; +import { Hono, type Context, type Next } from "hono"; import { createServer } from "./server.js"; import { createLogger } from "./utils/logger.js"; @@ -19,6 +19,52 @@ const jsonrpcError = (code: number, message: string) => ({ id: null, }); +/** + * Optional shared-secret gate on the MCP endpoints. When MCP_AUTH_TOKEN is + * set, callers must present the token as *either*: + * - `Authorization: Bearer ` (preferred, RFC 6750-clean), or + * - `?token=` query param + * + * The query-param path exists so this server works with MCP client UIs that + * only let you paste a URL (Cowork's "Add custom connector" dialog, and + * similar). Those UIs can't attach a custom header. The token only travels + * over the URL between the client and this process; this server doesn't + * log request URLs, so the leakage vectors RFC 6750 warns about (access + * logs, referer headers, browser history) don't apply to this shape. + * + * When MCP_AUTH_TOKEN is unset, the endpoints are open — matches the + * upstream Dokploy/mcp behavior so upgrading doesn't lock existing + * deployments out. A startup warning is logged in that case. + */ +const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN; +const MCP_AUTH_TOKEN_BUF = MCP_AUTH_TOKEN + ? Buffer.from(MCP_AUTH_TOKEN, "utf8") + : null; + +function tokensMatch(presented: string): boolean { + if (!MCP_AUTH_TOKEN_BUF) return true; + const buf = Buffer.from(presented, "utf8"); + if (buf.length !== MCP_AUTH_TOKEN_BUF.length) return false; + return timingSafeEqual(buf, MCP_AUTH_TOKEN_BUF); +} + +async function requireAuth(c: Context, next: Next) { + if (!MCP_AUTH_TOKEN_BUF) return next(); + + const header = c.req.header("authorization"); + if (typeof header === "string" && header.startsWith("Bearer ")) { + if (tokensMatch(header.slice("Bearer ".length))) return next(); + } + + const queryToken = c.req.query("token"); + if (typeof queryToken === "string" && tokensMatch(queryToken)) { + return next(); + } + + c.header("WWW-Authenticate", 'Bearer realm="dokploy-mcp"'); + return c.json(jsonrpcError(-32001, "Unauthorized"), 401); +} + // When MCP transport takes over the raw Node response, we must prevent // Hono/@hono/node-server from trying to write its own response headers. // We return a Promise that NEVER resolves — the underlying Node response @@ -35,9 +81,17 @@ export async function main() { sse: {} as Record, }; - // Health check + // Health check — intentionally left open so orchestrators (Docker, + // Traefik, k8s) can probe without a token. app.get("/health", (c) => c.json({ status: "ok", timestamp: new Date().toISOString() })); + // Gate every MCP transport endpoint behind the optional shared-secret + // check. `/health` above is deliberately registered before this line so + // probes stay open. + app.use("/mcp", requireAuth); + app.use("/sse", requireAuth); + app.use("/messages", requireAuth); + // Modern Streamable HTTP - POST app.post("/mcp", async (c) => { const { incoming, outgoing } = c.env; @@ -193,7 +247,14 @@ export async function main() { legacy: `http://localhost:${PORT}/sse`, health: `http://localhost:${PORT}/health`, }, + auth: MCP_AUTH_TOKEN_BUF ? "bearer token required" : "open (MCP_AUTH_TOKEN unset)", }); + if (!MCP_AUTH_TOKEN_BUF) { + logger.warn( + "MCP_AUTH_TOKEN is not set — MCP endpoints are open to anyone who can reach this port. " + + "Set MCP_AUTH_TOKEN or restrict access at the proxy layer before exposing publicly.", + ); + } }); } From 870595a1634adf7f5f7515f01981579631ce9056 Mon Sep 17 00:00:00 2001 From: Nshuti7 Date: Mon, 6 Jul 2026 22:39:56 +0300 Subject: [PATCH 2/5] Extract auth middleware, add tests Move the shared-secret gate into src/utils/auth.ts as a makeAuthMiddleware(token) factory so it can be exercised directly with different token values, without module-load env-var gymnastics. Add 12 vitest cases covering: unset/empty token (no-op), header-only accept/reject, query-only accept/reject, mismatched Authorization scheme, wrong-length token (guards the timing-safe compare), and header-and-query both present. --- src/http-server.ts | 58 +++---------------- src/utils/auth.test.ts | 126 +++++++++++++++++++++++++++++++++++++++++ src/utils/auth.ts | 60 ++++++++++++++++++++ 3 files changed, 194 insertions(+), 50 deletions(-) create mode 100644 src/utils/auth.test.ts create mode 100644 src/utils/auth.ts diff --git a/src/http-server.ts b/src/http-server.ts index a759d32..ca5be0c 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -1,70 +1,28 @@ #!/usr/bin/env node -import { randomUUID, timingSafeEqual } from "node:crypto"; +import { randomUUID } from "node:crypto"; import type { HttpBindings } from "@hono/node-server"; import { serve } from "@hono/node-server"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { Hono, type Context, type Next } from "hono"; +import { Hono } from "hono"; import { createServer } from "./server.js"; +import { makeAuthMiddleware } from "./utils/auth.js"; import { createLogger } from "./utils/logger.js"; const PORT = 3000; const logger = createLogger("MCP-HTTP-Server"); +const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN; +const requireAuth = makeAuthMiddleware(MCP_AUTH_TOKEN); + const jsonrpcError = (code: number, message: string) => ({ jsonrpc: "2.0" as const, error: { code, message }, id: null, }); -/** - * Optional shared-secret gate on the MCP endpoints. When MCP_AUTH_TOKEN is - * set, callers must present the token as *either*: - * - `Authorization: Bearer ` (preferred, RFC 6750-clean), or - * - `?token=` query param - * - * The query-param path exists so this server works with MCP client UIs that - * only let you paste a URL (Cowork's "Add custom connector" dialog, and - * similar). Those UIs can't attach a custom header. The token only travels - * over the URL between the client and this process; this server doesn't - * log request URLs, so the leakage vectors RFC 6750 warns about (access - * logs, referer headers, browser history) don't apply to this shape. - * - * When MCP_AUTH_TOKEN is unset, the endpoints are open — matches the - * upstream Dokploy/mcp behavior so upgrading doesn't lock existing - * deployments out. A startup warning is logged in that case. - */ -const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN; -const MCP_AUTH_TOKEN_BUF = MCP_AUTH_TOKEN - ? Buffer.from(MCP_AUTH_TOKEN, "utf8") - : null; - -function tokensMatch(presented: string): boolean { - if (!MCP_AUTH_TOKEN_BUF) return true; - const buf = Buffer.from(presented, "utf8"); - if (buf.length !== MCP_AUTH_TOKEN_BUF.length) return false; - return timingSafeEqual(buf, MCP_AUTH_TOKEN_BUF); -} - -async function requireAuth(c: Context, next: Next) { - if (!MCP_AUTH_TOKEN_BUF) return next(); - - const header = c.req.header("authorization"); - if (typeof header === "string" && header.startsWith("Bearer ")) { - if (tokensMatch(header.slice("Bearer ".length))) return next(); - } - - const queryToken = c.req.query("token"); - if (typeof queryToken === "string" && tokensMatch(queryToken)) { - return next(); - } - - c.header("WWW-Authenticate", 'Bearer realm="dokploy-mcp"'); - return c.json(jsonrpcError(-32001, "Unauthorized"), 401); -} - // When MCP transport takes over the raw Node response, we must prevent // Hono/@hono/node-server from trying to write its own response headers. // We return a Promise that NEVER resolves — the underlying Node response @@ -247,9 +205,9 @@ export async function main() { legacy: `http://localhost:${PORT}/sse`, health: `http://localhost:${PORT}/health`, }, - auth: MCP_AUTH_TOKEN_BUF ? "bearer token required" : "open (MCP_AUTH_TOKEN unset)", + auth: MCP_AUTH_TOKEN ? "bearer token required" : "open (MCP_AUTH_TOKEN unset)", }); - if (!MCP_AUTH_TOKEN_BUF) { + if (!MCP_AUTH_TOKEN) { logger.warn( "MCP_AUTH_TOKEN is not set — MCP endpoints are open to anyone who can reach this port. " + "Set MCP_AUTH_TOKEN or restrict access at the proxy layer before exposing publicly.", diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts new file mode 100644 index 0000000..ebcbdc0 --- /dev/null +++ b/src/utils/auth.test.ts @@ -0,0 +1,126 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; +import { makeAuthMiddleware } from "./auth.js"; + +/** + * Builds a tiny Hono app that runs the middleware in front of a fixed + * `200 ok` handler, so tests can drive it with real `fetch()` calls + * instead of hand-mocking a Context. + */ +function appWithAuth(token: string | undefined) { + const app = new Hono(); + app.use("/mcp", makeAuthMiddleware(token)); + app.post("/mcp", (c) => c.text("ok")); + return app; +} + +const post = async (app: Hono, path: string, init: RequestInit = {}): Promise => + app.request(path, { method: "POST", ...init }); + +describe("makeAuthMiddleware", () => { + describe("when token is undefined (upstream default)", () => { + it("passes through without any header or query param", async () => { + const app = appWithAuth(undefined); + const res = await post(app, "/mcp"); + expect(res.status).toBe(200); + }); + + it("passes through with an arbitrary Authorization header (ignored)", async () => { + const app = appWithAuth(undefined); + const res = await post(app, "/mcp", { + headers: { authorization: "Bearer anything" }, + }); + expect(res.status).toBe(200); + }); + }); + + describe("when token is empty string", () => { + it("treats it as unset (does not become an accept-anyone-with-empty-token trap)", async () => { + const app = appWithAuth(""); + const res = await post(app, "/mcp"); + expect(res.status).toBe(200); + }); + }); + + describe("when token is set", () => { + const token = "s3cret-value"; + + it("rejects a request with no credentials", async () => { + const app = appWithAuth(token); + const res = await post(app, "/mcp"); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toBe('Bearer realm="dokploy-mcp"'); + const body = await res.json(); + expect(body).toEqual({ + jsonrpc: "2.0", + error: { code: -32001, message: "Unauthorized" }, + id: null, + }); + }); + + it("accepts the correct Authorization: Bearer header", async () => { + const app = appWithAuth(token); + const res = await post(app, "/mcp", { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); + + it("rejects a wrong-value Authorization: Bearer header", async () => { + const app = appWithAuth(token); + const res = await post(app, "/mcp", { + headers: { authorization: "Bearer wrong-value-same-length!!" }, + }); + expect(res.status).toBe(401); + }); + + it("rejects an Authorization header that isn't a Bearer scheme", async () => { + const app = appWithAuth(token); + const res = await post(app, "/mcp", { + headers: { authorization: `Basic ${Buffer.from(`user:${token}`).toString("base64")}` }, + }); + expect(res.status).toBe(401); + }); + + it("accepts the correct ?token= query param", async () => { + const app = appWithAuth(token); + const res = await post(app, `/mcp?token=${encodeURIComponent(token)}`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); + + it("rejects a wrong-value ?token= query param", async () => { + const app = appWithAuth(token); + const res = await post(app, "/mcp?token=nope"); + expect(res.status).toBe(401); + }); + + it("rejects a token of the wrong length (guards the timing-safe compare)", async () => { + const app = appWithAuth(token); + const res = await post(app, "/mcp?token=short"); + expect(res.status).toBe(401); + }); + + it("prefers the header when both header and query are present and both valid", async () => { + // Not a behavioral guarantee we need to document — just checks that + // presenting both doesn't somehow break by double-consuming or + // short-circuiting to a rejection. + const app = appWithAuth(token); + const res = await post(app, `/mcp?token=${encodeURIComponent(token)}`, { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + }); + + it("still rejects if the header is valid but the query is wrong (no fall-through)", async () => { + // Regression guard: valid header must accept the request even if + // the query has garbage. + const app = appWithAuth(token); + const res = await post(app, "/mcp?token=garbage", { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..a9a7155 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,60 @@ +import { timingSafeEqual } from "node:crypto"; +import type { Context, MiddlewareHandler, Next } from "hono"; + +/** + * Optional shared-secret gate for the HTTP/SSE transports. When `token` + * is a non-empty string, the returned middleware requires callers to + * present the token as *either*: + * - `Authorization: Bearer ` (preferred, RFC 6750-clean), or + * - `?token=` query param + * + * The query-param path exists so this server works with MCP client UIs + * that only accept a URL (Cowork's "Add custom connector" dialog, and + * similar). The token is only visible to whoever already holds the URL, + * and this process doesn't log request URLs — so the leakage vectors + * RFC 6750 warns about (access logs, referer headers, browser history) + * don't apply to this deployment shape. Prefer the header form when the + * client supports it. + * + * When `token` is undefined or empty, the middleware is a no-op — this + * preserves upstream Dokploy/mcp behavior so existing deployments aren't + * locked out on upgrade. + * + * Comparisons use `timingSafeEqual` so a byte-by-byte early return + * can't leak the token via response-time differences. + */ +export function makeAuthMiddleware(token: string | undefined): MiddlewareHandler { + if (!token) { + return async (_c: Context, next: Next) => next(); + } + + const expected = Buffer.from(token, "utf8"); + + const matches = (presented: string): boolean => { + const buf = Buffer.from(presented, "utf8"); + if (buf.length !== expected.length) return false; + return timingSafeEqual(buf, expected); + }; + + return async (c: Context, next: Next) => { + const header = c.req.header("authorization"); + if (typeof header === "string" && header.startsWith("Bearer ")) { + if (matches(header.slice("Bearer ".length))) return next(); + } + + const queryToken = c.req.query("token"); + if (typeof queryToken === "string" && matches(queryToken)) { + return next(); + } + + c.header("WWW-Authenticate", 'Bearer realm="dokploy-mcp"'); + return c.json( + { + jsonrpc: "2.0" as const, + error: { code: -32001, message: "Unauthorized" }, + id: null, + }, + 401, + ); + }; +} From bba3c63d85e73e5317b085fb8b5b22c935a2deaa Mon Sep 17 00:00:00 2001 From: Nshuti7 Date: Tue, 7 Jul 2026 00:00:53 +0300 Subject: [PATCH 3/5] Forward MCP_AUTH_TOKEN through docker-compose to the container Without this, setting MCP_AUTH_TOKEN in the shell (or in Dokploy's environment editor) wouldn't reach the running process and the gate would be silently disabled. --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index ae8cdd8..49eac12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,10 @@ services: - MCP_TRANSPORT=${MCP_TRANSPORT:-http} - DOKPLOY_URL=${DOKPLOY_URL:-https://your-dokploy-server.com} - DOKPLOY_API_KEY=${DOKPLOY_API_KEY:-your_token_here} + # Optional shared-secret gate on /mcp, /sse, /messages. Leave unset + # for open access, or set to require Authorization: Bearer + # (or ?token= for URL-only clients like Cowork). + - MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-} restart: unless-stopped networks: - mcp-network From 3d04b2b956b03949d52644c7e898ff68a76a2a83 Mon Sep 17 00:00:00 2001 From: Nshuti7 Date: Tue, 7 Jul 2026 00:11:29 +0300 Subject: [PATCH 4/5] Fix docker build: include pnpm-lock.yaml, pin pnpm, copy .npmrc Pre-existing upstream issue (Dokploy/mcp#59, PR #60): the Dockerfile COPYs pnpm-lock.yaml but .dockerignore excludes it, so builds fail from a fresh clone. Also pin pnpm to 10.33.0 to avoid 'latest' drift between build stages, and include .npmrc so registry/config is consistent with the local install. Mirrors the upstream fix from Dokploy/mcp#60. --- .dockerignore | 1 - Dockerfile | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index 322ddd0..0bdd2ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -96,4 +96,3 @@ Makefile # Package locks (can be regenerated) package-lock.json yarn.lock -pnpm-lock.yaml diff --git a/Dockerfile b/Dockerfile index bb29163..5581648 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,10 @@ FROM node:lts-alpine AS builder WORKDIR /app # Install pnpm -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN corepack enable && corepack prepare pnpm@10.33.0 --activate # Copy package and configuration -COPY package.json pnpm-lock.yaml tsconfig.json ./ +COPY package.json pnpm-lock.yaml tsconfig.json .npmrc ./ # Copy source code COPY src ./src @@ -19,13 +19,13 @@ FROM node:lts-alpine WORKDIR /app # Install pnpm -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN corepack enable && corepack prepare pnpm@10.33.0 --activate # Copy built artifacts COPY --from=builder /app/build ./build # Copy package.json and lockfile for production install -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml .npmrc ./ # Install only production dependencies RUN pnpm install --prod --frozen-lockfile --ignore-scripts From 5795c177c85f6789447d57a8df815c1c0f7afa42 Mon Sep 17 00:00:00 2001 From: Nshuti7 Date: Tue, 7 Jul 2026 00:13:45 +0300 Subject: [PATCH 5/5] Drop host port publish so Dokploy/Traefik deploys don't collide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing 3000:3000 on the host conflicts with Dokploy's own UI (which binds :3000) and adds nothing when a reverse proxy is in front — the proxy reaches the container over the shared Docker network on its internal port. Left commented for anyone running this compose standalone without a proxy. --- docker-compose.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 49eac12..5a31ed5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,16 @@ services: dokploy-mcp-http: build: . container_name: dokploy-mcp-http - ports: - - "${EXTERNAL_PORT:-3000}:3000" + # No host port mapping: when this compose is deployed behind Dokploy / + # Traefik / any other reverse proxy, the proxy reaches the container on + # its internal port over the shared Docker network — publishing to the + # host causes port collisions (Dokploy itself binds :3000) without + # adding anything. If you want to run this compose standalone without + # a proxy, uncomment the block below. + # ports: + # - "${EXTERNAL_PORT:-3000}:3000" + expose: + - "3000" environment: - MCP_TRANSPORT=${MCP_TRANSPORT:-http} - DOKPLOY_URL=${DOKPLOY_URL:-https://your-dokploy-server.com}