# Node.js and Docker

> Run the node build of a FrontMCP server on a machine, in a Docker image, with Redis in Docker Compose, behind nginx, or on a Unix socket: what it needs, the environment it reads, how it stops, and what the generated Docker files do.

Source: https://frontmcp.dev/reference/deployment/node

`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.

```bash
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](https://frontmcp.dev/reference/deployment/production-build#whats-in-the-bundle)). `@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](https://frontmcp.dev/reference/deployment/production-build#widget-sources)).

Importing the bundle starts the server: FrontMCP [bootstraps on import](https://frontmcp.dev/reference/sdk/frontmcp-instance#what-frontmcp-does-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](#what-the-bundle-reads-from-frontmcpconfig).

#### Environment

These are the variables a Node deployment usually sets. [Configuration files](https://frontmcp.dev/reference/server/config-files#environment-variables) lists every one FrontMCP reads.

| Variable | Set it to | Why |
| --- | --- | --- |
| `NODE_ENV` | `production` | Internal errors stop reaching callers, and FrontMCP stops making up secrets. See [production mode](https://frontmcp.dev/reference/auth/production#production-mode). |
| `MCP_SESSION_SECRET` | `openssl rand -hex 32` | Required 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_SECRET` | `openssl rand -hex 32` | Signs 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). |
| `PORT` | The port | Default `3000`. `http.port` in `@FrontMcp` wins over it. |
| `FRONTMCP_BIND_ADDRESS` | `all` in a container | The server listens on `127.0.0.1` by default, which a published container port can't reach. |
| `FRONTMCP_ALLOWED_HOSTS` | Your public host names | The `Host` headers the server answers. See [host checks](#host-checks). |
| `FRONTMCP_TRUST_PROXY` | `1` behind your own proxy | Take the client's address from `X-Forwarded-For`. See [behind a proxy](#behind-a-reverse-proxy). |
| `FRONTMCP_HTTP_ENTRY_PATH` | `/mcp` | The 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](https://frontmcp.dev/reference/deployment/production-build#the-endpoint-path). |
| `FRONTMCP_PUBLIC_URL` | `https://desk.example.com` | The 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](https://frontmcp.dev/reference/auth/production#https-and-the-public-url). |

#### The start script

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

| It | Details |
| --- | --- |
| Checks Node | Exits with an error below Node 22, or below `nodeVersion` in `frontmcp.config`. |
| Sets the endpoint path | Exports `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 `.env` | From its own folder, `dist/node/.env`, not the project's. |
| Caches compiled code | Sets `NODE_COMPILE_CACHE` to `~/.cache/frontmcp/<name>`, so later starts are faster. |
| Takes flags | `--stdio` serves [over stdio](https://frontmcp.dev/reference/deployment/mcp-clients#starting-the-server-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.config` | Becomes |
| --- | --- |
| `transport.http.path`, or `deployments[].server.http.entryPath` | `FRONTMCP_HTTP_ENTRY_PATH`. `http.entryPath` in `@FrontMcp` still wins. |
| `deployments[].server.http.port`, `socketPath` | `PORT`, `FRONTMCP_DAEMON_SOCKET` |
| `deployments[].server.http.cors` | `FRONTMCP_CORS_*`, used when `@FrontMcp` sets no `http.cors` |
| `deployments[].server.headers`, `csp` | `FRONTMCP_HSTS`, `FRONTMCP_CSP_*` and the like ([security headers](https://frontmcp.dev/reference/deployment/security#from-frontmcpconfig)) |

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`](#installing-with-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:

```text
[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](https://frontmcp.dev/reference/deployment/high-availability#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`](https://frontmcp.dev/reference/deployment/health-and-metrics#what-each-runtime-answers) says; in 1.9.2 the check called `/healthz`. Its last line runs the bundle the build writes:

```text
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](#running-with-redis-in-docker-compose).

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 server | Answers 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 `allowedHosts` | Only 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 socket | Any 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](https://frontmcp.dev/reference/deployment/security) 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](https://frontmcp.dev/reference/sdk/create-fetch-handler#the-clients-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](https://frontmcp.dev/reference/deployment/security#securityheaders). 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:

```bash
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:

```bash
./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](#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`:

```text title="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"]
```

```bash
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:

```ts 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 {}
```

```yaml 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
```

```bash
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](https://frontmcp.dev/reference/deployment/redis) has every option, and [High availability](https://frontmcp.dev/reference/deployment/high-availability#running-two-instances-that-share-sessions) 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:

```text title="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;
  }
}
```

```yaml 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:

```bash
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:

```bash
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](#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()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancerununixsocketoptions) does it from code. Keep the path short: on macOS a path over 104 bytes fails, see [below](#error-listen-einval-invalid-argument-sock).

---

## 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](#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](#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](#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`.)
