Node.js and Docker

frontmcp build --target node turns your server into a small bundle and a start script. The bundle is only your code: it runs next to a node_modules folder with your production dependencies, FrontMCP's included, and starts FrontMCP's HTTP server on the port you give it. In production it wants a few environment variables, something in front of it that terminates TLS, and something that restarts it. This page runs it on a machine, in a Docker image, with Redis, behind nginx, and on a Unix socket.

frontmcp build --target node
NODE_ENV=production MCP_SESSION_SECRET=… PORT=8080 node dist/node/help-desk.bundle.js

Reference

Running the bundle

The machine that runs dist/node/<name>.bundle.js needs:

  • Node 22 or later. The start script checks it; projects made with frontmcp create ask for 24 in engines.
  • Your production dependencies: npm ci --omit=dev in a folder with your package.json and lockfile. The bundle imports @frontmcp/sdk, reflect-metadata and FrontMCP's other packages instead of containing them (what's bundled). @frontmcp/sdk brings what it loads, vectoriadb and tslib included: a folder with only @frontmcp/sdk and reflect-metadata installed ran the bundle. (Changed in 1.9: before, vectoriadb came with the frontmcp package, which had to stay in dependencies.)
  • Your widget files, when a tool's widget is a file: frontmcp build copies each *.widget.tsx and *.widget.jsx beside the bundle, like dist/node/queue.widget.tsx, and the server reads it there when the tool is called. Deploy dist/node/ whole (widget sources).

Importing the bundle starts the server: FrontMCP bootstraps on import, listens, and logs FrontMCP bootstrap complete. When the bundle is the program Node runs, it first sets defaults from frontmcp.config, as below.

Environment

These are the variables a Node deployment usually sets. Configuration files lists every one FrontMCP reads.

VariableSet it toWhy
NODE_ENVproductionInternal errors stop reaching callers, and FrontMCP stops making up secrets. See production mode.
MCP_SESSION_SECRETopenssl rand -hex 32Required in production for clients on protocol versions before 2026-07-28, public servers included: without it, their initialize answers 500 with {"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED","message":"Set MCP_SESSION_SECRET in the deployment environment …"}, and the log says SESSION_SECRET_REQUIRED. Clients on MCP 2026-07-28 are served without it (changed in 1.8.5). Changed in 1.8.7: the answer was a bare Internal Server Error on the Node server.
JWT_SECRETopenssl rand -hex 32Signs the tokens local and remote auth issue. In production a server with local auth and no JWT_SECRET doesn't start (JwtSecretRequiredError). A server with static or local auth warns JWT_SECRET is not set at startup; a public one no longer does (changed in 1.8.7).
PORTThe portDefault 3000. http.port in @FrontMcp wins over it.
FRONTMCP_BIND_ADDRESSall in a containerThe server listens on 127.0.0.1 by default, which a published container port can't reach.
FRONTMCP_ALLOWED_HOSTSYour public host namesThe Host headers the server answers. See host checks.
FRONTMCP_TRUST_PROXY1 behind your own proxyTake the client's address from X-Forwarded-For. See behind a proxy.
FRONTMCP_HTTP_ENTRY_PATH/mcpThe MCP endpoint when http.entryPath isn't set; http.entryPath wins. The bundle and the start script set it from transport.http.path when it isn't set. See the endpoint path.
FRONTMCP_PUBLIC_URLhttps://desk.example.comThe address FrontMCP builds its OAuth URLs from, and the issuer and audience of the tokens it issues. Without it, they follow each request's Host. Needed with auth behind TLS termination: see HTTPS and the public URL.

The start script

dist/node/<name> is a Bash script that runs the bundle beside it:

ItDetails
Checks NodeExits with an error below Node 22, or below nodeVersion in frontmcp.config.
Sets the endpoint pathExports FRONTMCP_HTTP_ENTRY_PATH from transport.http.path in frontmcp.config, unless the variable is already set. The bundle does the same when it's started with node, since 1.9.
Loads .envFrom its own folder, dist/node/.env, not the project's.
Caches compiled codeSets NODE_COMPILE_CACHE to ~/.cache/frontmcp/<name>, so later starts are faster.
Takes flags--stdio serves over stdio instead of HTTP; --help, --version, --print-manifest print and exit. Any other flag exits with code 2: this script only starts the server.

What the bundle reads from frontmcp.config

Nothing, at run time: frontmcp build writes what it needs into the top of the bundle, as defaults that run only when the bundle is the program Node starts (node dist/node/<name>.bundle.js, the start script, a Docker CMD), not when another module imports it. A variable already set wins over each:

In frontmcp.configBecomes
transport.http.path, or deployments[].server.http.entryPathFRONTMCP_HTTP_ENTRY_PATH. http.entryPath in @FrontMcp still wins.
deployments[].server.http.port, socketPathPORT, FRONTMCP_DAEMON_SOCKET
deployments[].server.http.corsFRONTMCP_CORS_*, used when @FrontMcp sets no http.cors
deployments[].server.headers, cspFRONTMCP_HSTS, FRONTMCP_CSP_* and the like (security headers)

A --stdio argument sets FRONTMCP_STDIO=1, so node dist/node/<name>.bundle.js --stdio serves stdio. A build whose deployments entry set server.http to a port and entryPath: "/api", and server.headers.hsts, listened on that port, served MCP at /api (/mcp answered 404) and sent Strict-Transport-Security; PORT and FRONTMCP_HSTS=off given at run time won. One with server.http.socketPath and cors listened on the socket and answered an allowed Origin with Access-Control-Allow-Origin. Changed in 1.9: before, the bundle read none of this, so it served / on port 3000 whatever frontmcp.config said, and --stdio started the HTTP server.

install-<name>.sh copies the bundle, the manifest and the script to ~/.frontmcp/apps/<name>/. It installs no packages and doesn't register the app with the CLI; frontmcp install does both.

What it logs in production

With NODE_ENV=production, the server logs an audit of its network settings before it listens. It reports, it doesn't change anything:

[Security] CORS_DISABLED: No CORS headers are sent. Cross-origin requests still reach the server — a browser just will not let the calling page read the response. CORS is not server-side access control.
[Security] BIND_RESTRICTED: Server bound to 127.0.0.1.
[Security] DNS_REBINDING_PROTECTED: DNS rebinding protection is enabled: Host headers are checked against the loopback names for 127.0.0.1.
[Security] STRICT_MODE_HINT: To enable strict security defaults, set security.strict = true in HttpOptions.
MCP HTTP (Express) on 127.0.0.1:8080

With FRONTMCP_BIND_ADDRESS=all and no host list, the second and third lines are BIND_ALL_INTERFACES: Server bound to 0.0.0.0 — accessible from all network interfaces. … and DNS_REBINDING_NOT_ENFORCED: DNS rebinding protection is on, but no Host allow-list is enforced: the server binds 0.0.0.0 and no allowed hosts are configured. …, and a [frontmcp] DNS-rebinding protection is not enforcing a Host allow-list line follows STRICT_MODE_HINT. With FRONTMCP_ALLOWED_HOSTS set, the third line is DNS_REBINDING_PROTECTED: … Host/Origin headers are checked against the configured allow-list. On a Unix socket it's DNS_REBINDING_NOT_APPLICABLE. Without Redis the server also logs [storage] Warning: No distributed storage backend detected in production. Using in-memory storage. Set REDIS_URL, UPSTASH_REDIS_REST_URL, or KV_REST_API_URL.

Changed in 1.9: before, the audit printed DNS_REBINDING_PROTECTED on 0.0.0.0 with no host list too, though nothing was enforced. (In 1.8.6 it printed DNS_REBINDING_UNPROTECTED on every server.)

Stopping

On SIGTERM or SIGINT the server shuts itself down: it stops taking new connections, lets requests in flight finish for up to 5 seconds, closes what it holds (its Redis connections, and in distributed mode its heartbeat, so another instance takes its sessions over at once), and exits 0. It exits 1 when a step of the shutdown fails, or when the whole of it takes more than 10 seconds. A tool call that took 3 seconds and was in flight when SIGTERM arrived got its answer, and the process exited 0 about 2.5 seconds after the signal; a call that took 8 seconds was cut off after 5.

That's the same in a container, where Node is process 1: docker stop returned at once, with exit code 0, with or without --init. Before 1.9.2 the server installed no signal handlers, so SIGTERM ended the process at once, and in a container Node ignored it: docker stop waited its ten seconds and killed the container (137) unless it ran with --init.

A server on a Unix socket, through FRONTMCP_DAEMON_SOCKET, shuts down the same way since 1.9.3. Its socket file goes as soon as the signal arrives, so a new client gets ENOENT, while a 3-second call in flight got its answer and the process exited 0; an 8-second call was cut off after 5. In 1.9.2 SIGTERM removed the socket file and exited at once, and a call in flight got an empty reply.

Docker

frontmcp create --target node writes ci/Dockerfile, ci/docker-compose.yml, ci/.env.docker, ci/.env.docker.example and .dockerignore. The Dockerfile builds in one stage and runs in another, from node:24-slim, with NODE_ENV=production and FRONTMCP_BIND_ADDRESS=all, as the image's unprivileged node user, with a HEALTHCHECK that calls /health with Node's fetch (node:24-slim has neither curl nor wget). /health answers whatever health.healthzPath says; in 1.9.2 the check called /healthz. Its last line runs the bundle the build writes:

CMD ["node", "dist/node/help-desk.bundle.js"]

The image starts, and serves MCP at the transport.http.path of frontmcp.config (in 1.8.7 it served /). Before 1.8.7 the line was CMD ["node", "dist/main.js"], a file the build doesn't write, so every container exited at once with Error: Cannot find module '/app/dist/main.js'; a project made by an older frontmcp create keeps its old Dockerfile, so change the line.

docker-compose.yml runs that image with a Redis beside it. It publishes Redis on 127.0.0.1:6379 only (which still fails if something on your machine uses that port), defaults the app's NODE_ENV to production, and won't start without MCP_SESSION_SECRET: npm run docker:up passes --env-file ci/.env.docker, which create writes with the secret empty, so it stops with required variable MCP_SESSION_SECRET is missing a value: set MCP_SESSION_SECRET in ci/.env.docker (openssl rand -hex 32) until you fill it in. ci/.env.docker is git-ignored, and ci/.env.docker.example is the copy to commit.

What the compose file doesn't do: it sets REDIS_HOST=redis for the app, but the generated main.ts doesn't configure redis, so sessions stay in memory. REDIS_HOST alone moves only background tasks to Redis. See Running with Redis.

Before 1.9.2 the Dockerfile had no health check and ran as root, and the compose file published Redis on every interface and started the app in development mode.

Host checks

Every request's Host header, and X-Forwarded-Host when there is one, is checked against the host names the server answers to, before anything else runs. Others get 403 {"error":"Forbidden","message":"Invalid Host header"} (or "Invalid X-Forwarded-Host header").

The serverAnswers these hosts
On 127.0.0.1 (the default)localhost, 127.0.0.1 and [::1], with the port it listens on or without one. So a proxy that sends Host: desk.example.com is refused.
On all interfaces (FRONTMCP_BIND_ADDRESS=all)Any host, until you list them. It logs that no allow-list is enforced.
With FRONTMCP_ALLOWED_HOSTS or allowedHostsOnly the ones listed. The list replaces the default one, so add 127.0.0.1:3000 if a health check calls the server from the same machine.
On a Unix socketAny host.

Names match without regard to case, and a default port counts as none: DESK.example.com:443 matches desk.example.com; desk.example.com:8443 doesn't. Security headers and transport has the rest of the http.security options.

Behind a reverse proxy

FrontMCP speaks plain HTTP. Put TLS, and the headers a public site sends, in the proxy:

  • Host: forward the public host name and list it in FRONTMCP_ALLOWED_HOSTS, or FrontMCP refuses it (above).
  • X-Forwarded-For: with FRONTMCP_TRUST_PROXY=1, this.context.metadata.clientIp is the last entry, the one the proxy added. A client that sends its own X-Forwarded-For only adds entries before it. See the client's address.
  • Streaming: calls that stream progress, and clients on the older SSE transport, get text/event-stream responses that stay open. Turn off the proxy's response buffering and raise its read timeout.
  • Security headers: FrontMCP already sends X-Content-Type-Options: nosniff and X-Frame-Options: DENY, and never X-Powered-By. Add Strict-Transport-Security at the proxy, or give FrontMCP FRONTMCP_HSTS: Security headers and transport. A header both set shows up twice.

Usage

Nothing on this page runs in the Playground: it needs a real port, a container or a socket. The blocks were run in a project made with frontmcp create --target node, with Docker 28: running on a machine, the image, install and the socket with frontmcp 1.9.3; Redis in Compose (Valkey 9, which speaks Redis's protocol) and nginx:1.27-alpine with 1.9.1.

Running on a machine

Build, then install only the production dependencies next to the bundle:

npx frontmcp build --target node
npm ci --omit=dev
export MCP_SESSION_SECRET=…   # from your secret store
NODE_ENV=production PORT=8080 node dist/node/help-desk.bundle.js

Or start it with the script, which checks Node, reads dist/node/.env, serves the transport.http.path of frontmcp.config when http.entryPath isn't set, and caches compiled code:

./dist/node/help-desk

Keep it running with whatever your platform uses to restart processes, and send SIGTERM to stop it: requests in flight finish first (stopping).

Building a Docker image

This is the Dockerfile frontmcp create writes, with its comments left out. The last line names the bundle after the name in frontmcp.config:

ci/Dockerfile
FROM node:24-slim AS builder
WORKDIR /app
COPY package*.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev

FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV FRONTMCP_BIND_ADDRESS=all
RUN chown node:node /app
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["node", "dist/node/help-desk.bundle.js"]
docker build -f ci/Dockerfile -t help-desk .
docker run -p 127.0.0.1:3000:3000 -e MCP_SESSION_SECRET="$(openssl rand -hex 32)" help-desk

The container runs as node, logs MCP HTTP (Express) on 0.0.0.0:3000, with a [frontmcp] DNS-rebinding protection is not enforcing a Host allow-list line until you list host names, docker inspect reports it healthy after the first check, and /healthz answers with "platform":"linux" and "env":"production". docker stop ends it at once, with exit code 0. The generated .dockerignore keeps node_modules, dist, .env files, ci/.env.docker, .frontmcp, e2e and Markdown files out of the build.

Running with Redis in Docker Compose

Sessions of clients before MCP 2026-07-28 move to Redis only when the server's redis option is set. Read it from the environment, so the same image runs with or without Redis:

main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  ...(process.env.REDIS_URL ? { redis: { url: process.env.REDIS_URL } } : {}),
})
export default class Server {}
ci/compose.yml
services:
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      timeout: 5s
      retries: 3
  app:
    build:
      context: ..
      dockerfile: ci/Dockerfile
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      MCP_SESSION_SECRET: ${MCP_SESSION_SECRET:?set MCP_SESSION_SECRET}
      REDIS_URL: redis://redis:6379
    depends_on:
      redis:
        condition: service_healthy
MCP_SESSION_SECRET="$(openssl rand -hex 32)" docker compose -f ci/compose.yml up -d --build

The app logs redis session store will be initialized for transport persistence and Session store connection validated successfully; a session a client opens shows up in Redis as mcp:session:<id>, and /readyz checks Redis. Redis isn't published on your machine. redis takes the URL since 1.9; Redis has every option, and High availability runs two instances on it.

Putting nginx in front

nginx terminates TLS with a certificate in ci/nginx/certs/, adds Strict-Transport-Security, and forwards the public Host, which the server is told to answer:

ci/nginx/desk.conf
server {
  listen 443 ssl;
  server_name desk.example.com;
  ssl_certificate     /etc/nginx/certs/desk.crt;
  ssl_certificate_key /etc/nginx/certs/desk.key;

  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

  location / {
    proxy_pass http://app:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_read_timeout 1h;
  }
}
ci/compose.yml
services:
  app:
    build:
      context: ..
      dockerfile: ci/Dockerfile
    environment:
      MCP_SESSION_SECRET: ${MCP_SESSION_SECRET:?set MCP_SESSION_SECRET}
      FRONTMCP_ALLOWED_HOSTS: desk.example.com,127.0.0.1:3000
      FRONTMCP_PUBLIC_URL: https://desk.example.com
      FRONTMCP_TRUST_PROXY: "1"
  proxy:
    image: nginx:1.27-alpine
    ports:
      - "443:443"
    volumes:
      - ./nginx/desk.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      - app

https://desk.example.com/healthz then answers with nginx's Strict-Transport-Security and FrontMCP's own X-Content-Type-Options: nosniff and X-Frame-Options: DENY, and no X-Powered-By. (An nginx that also added X-Content-Type-Options sent it twice.) A tool that returns this.context.metadata.clientIp gets the address that connected to nginx, even when the client sends X-Forwarded-For: 6.6.6.6. The certificate for this check was self-signed, with curl -k and --resolve. 127.0.0.1:3000 in FRONTMCP_ALLOWED_HOSTS is for the image's HEALTHCHECK, which calls the server by that name.

Installing with frontmcp install

frontmcp install <path> takes a project that was built, finds the manifest in dist/ or dist/<target>, copies the bundle, the manifest and the start script to ~/.frontmcp/apps/<name>/, registers the app, and installs the runtime packages the bundle keeps external there: @frontmcp/sdk and reflect-metadata, at the versions the project declares, and what the SDK brings with it, vectoriadb and tslib included. frontmcp start <name> then runs the app from that folder, under a supervisor that restarts it up to five times if it exits:

npx frontmcp build
npx frontmcp install . --yes
npx frontmcp start help-desk --port 8080

frontmcp start serves the app (the command stays in the foreground while it does), at the path it was built with, /mcp here; frontmcp list shows it running, and frontmcp stop help-desk, from another terminal, stops it. This was run from the project's folder; start without an installed app of that name starts the project's own entry instead. Before 1.9.2 a bare . was read as an npm package name, so install packed the project and built it again in a temporary folder; ./ worked as it does now.

In 1.8.7 install didn't install vectoriadb or tslib, so the app crashed at startup with @frontmcp/sdk skill storage needs the optional peer dependency 'vectoriadb' until you ran npm install vectoriadb tslib --prefix ~/.frontmcp/apps/help-desk. Now they come with @frontmcp/sdk, which depends on vectoriadb, which depends on tslib.

Serving on a Unix socket

For a server only processes on the same machine should reach, give it a socket path instead of a port:

FRONTMCP_DAEMON_SOCKET=./help-desk.sock node dist/node/help-desk.bundle.js
curl --unix-socket ./help-desk.sock http://localhost/healthz

It logs MCP server listening on unix://./help-desk.sock. The socket file gets mode 0660, so only its owner and group can connect, and it's removed when the process gets SIGTERM, which shuts the server down as on a port: requests in flight finish first (stopping). The server accepts any Host on a socket, so the host name in the URL doesn't matter, and it serves MCP and health checks as on a port. http: { socketPath } in @FrontMcp also listens on a socket, and runUnixSocket() does it from code. Keep the path short: on macOS a path over 104 bytes fails, see below.


Troubleshooting

Error: Cannot find module '/app/dist/main.js'

The CMD of a Dockerfile written by frontmcp create before 1.8.7 points at a file the build doesn't write. Change it to CMD ["node", "dist/node/<name>.bundle.js"], with the name from frontmcp.config. See Docker.

Cannot find module '@frontmcp/sdk'

The bundle runs without FrontMCP's packages next to it. Run npm ci --omit=dev where it runs, or copy node_modules into the image as the Dockerfile above does. Cannot find module 'reflect-metadata' is the same problem.

curl: (52) Empty reply from server

The server in the container listens on 127.0.0.1, which the published port can't reach. Set FRONTMCP_BIND_ADDRESS=all: the log line should say on 0.0.0.0:3000.

{"error":"Forbidden","message":"Invalid Host header"}

The request's Host isn't one the server answers. Behind a proxy that forwards the public host name, add it to FRONTMCP_ALLOWED_HOSTS; if you set that list, add the names your health checks use too, like 127.0.0.1:3000. See host checks. Invalid X-Forwarded-Host header is the same check on that header.

The container is unhealthy, but the server works

The HEALTHCHECK gets 403 because FRONTMCP_ALLOWED_HOSTS doesn't include the host it calls, or it uses curl, which node:24-slim doesn't have. Use the fetch health check above and add 127.0.0.1:3000 to the list.

docker stop takes ten seconds, and the exit code is 137

The image runs a server built with FrontMCP before 1.9.2, which installed no signal handlers, so Node, as process 1, ignored SIGTERM and Docker killed it after ten seconds. Rebuild with 1.9.3, or run the container with --init (init: true in Compose). See stopping.

Error: listen EINVAL: invalid argument …sock

The socket path is too long for the operating system: on macOS, longer than 104 bytes. The server exits. Use a shorter path, like one relative to the working folder or under /tmp.

initialize answers 500, with "error":"server_misconfigured" and SESSION_SECRET_REQUIRED

With NODE_ENV=production and no MCP_SESSION_SECRET, a client on a protocol version before 2026-07-28 can't open a session. The answer names the secret; the log says Session secret is required for session ID encryption with the code SESSION_SECRET_REQUIRED. Set the secret. Health checks, and clients on MCP 2026-07-28, don't notice. (Before 1.8.7 the answer was a bare Internal Server Error.)