Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Dependencies (installed fresh inside the image)
node_modules/
server/node_modules/

# Build outputs (generated fresh inside the image)
dist/
server/dist/
server/public/

# Test outputs
coverage/
playwright-report/
test-results/
e2e/

# VCS / CI / editor
.git/
.github/
.husky/
.bob/
.claude/
.vscode/
.idea/
.DS_Store

# Env files — never bake secrets/config into the image; env comes from
# `docker run -e` / compose at runtime.
*.env
*.env.*
!.env*.example
server/.env

# Docs (not needed at build time)
*.md
LICENSE
62 changes: 62 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# BFF config — the single .env for both ways of running this app. Copy to
# .env at the repo root:
# cp .env.example .env
# Both `cd server && npm run dev`/`npm start` (via `--env-file-if-exists=../.env`)
# and `docker compose up` read this same file. Every var maps 1:1 to
# server/src/config.ts. For a production-ready template, see
# .env.prod.example.

PORT=3000
HOST=0.0.0.0

# Upstream ContextForge/mcpgateway instance. Set this to your instance's
# address; 0.0.0.0:8000 is the default local dev address.
# Docker: if not on the same docker network, use the host.docker.internal
# line instead.
# CONTEXTFORGE_URL=http://host.docker.internal:8000
CONTEXTFORGE_URL=http://0.0.0.0:8000

# Must match mcpgateway's own AUTH_HEADER_NAME.
CONTEXTFORGE_AUTH_HEADER_NAME=Authorization

# Left UNSET on purpose — behaves correctly either way this file is used:
# Native: config.ts's own default applies (memory:// — in-process,
# lost on restart, single-instance only, no Redis needed).
# Docker: docker-compose.yml defaults REDIS_URL to its own `redis`
# service, so sessions are Redis-backed automatically.
# Set a value here to override either default.
# REDIS_URL=redis://localhost:6379/0

# Opaque session_id -> bearer token TTL in Redis, seconds.
SESSION_TTL_SECONDS=86400

# Redis key namespace. Only needs changing if multiple BFF deployments
# (e.g. staging and prod) ever share one Redis instance.
REDIS_KEY_PREFIX=bff

# Leave unset for a host-only cookie (recommended unless the BFF and its
# subdomains genuinely need to share the session cookie).
COOKIE_DOMAIN=

# REQUIRED false for a zero-config boot (native or Docker) — the default
# (true) is for prod and fails closed on memory:// Redis and on a missing
# PUBLIC_ORIGIN/TRUST_PROXY. See config.ts's two fail-closed startup checks.
COOKIE_SECURE=false

# Only safe behind a trusted reverse proxy that overwrites (not appends to)
# X-Forwarded-For. Leave "false" for a directly-exposed BFF.
TRUST_PROXY=false

# Exact scheme://host the BFF is publicly reached at (e.g.
# https://app.example.com), used for Origin-header validation on login/SSE.
# Leave unset to derive it from the request itself — fine for a
# single-hostname deployment; set explicitly behind a reverse proxy where
# that derivation isn't trustworthy (e.g. TLS-terminated without
# TRUST_PROXY=true).
PUBLIC_ORIGIN=

# How often an open SSE connection re-checks Redis for session revocation,
# as a fallback to the pub/sub-based instant revocation.
SSE_SESSION_RECHECK_SECONDS=15

LOG_LEVEL=info
54 changes: 54 additions & 0 deletions .env.prod.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# BFF config — production-ready template. Copy to .env:
# cp .env.prod.example .env
# Unlike .env.example, nothing here has a safe zero-config default — every
# blank value below MUST be set before this will boot (server/src/config.ts
# fails closed rather than serving traffic insecurely). See DOCKER.md's
# production checklist.

PORT=3000
HOST=0.0.0.0

# Your real upstream ContextForge/mcpgateway instance. Set this to your
# instance's address; 0.0.0.0:4444 is the default local address.
# Docker: if not on the same docker network, use the host.docker.internal
# line instead.
# CONTEXTFORGE_URL=http://host.docker.internal:4444
CONTEXTFORGE_URL=http://0.0.0.0:4444

# Must match mcpgateway's own AUTH_HEADER_NAME.
CONTEXTFORGE_AUTH_HEADER_NAME=Authorization

# Real, persistent Redis — required. Set to your instance's address.
# Docker, different network: use the host.docker.internal line instead.
# REDIS_URL=redis://host.docker.internal:6379/0
REDIS_URL=redis://0.0.0.0:6379/0

# Opaque session_id -> bearer token TTL in Redis, seconds.
SESSION_TTL_SECONDS=86400

# Redis key namespace. Change if this deployment shares one Redis instance
# with another BFF deployment (e.g. staging).
REDIS_KEY_PREFIX=bff

# Leave unset for a host-only cookie (recommended unless the BFF and its
# subdomains genuinely need to share the session cookie).
COOKIE_DOMAIN=

# Required true in production — config.ts fails closed if this is true
# without a real REDIS_URL, or without PUBLIC_ORIGIN/TRUST_PROXY below.
COOKIE_SECURE=true

# Set true ONLY if directly TLS-terminated with no reverse proxy in front.
# Otherwise leave false and set PUBLIC_ORIGIN instead.
TRUST_PROXY=false

# Exact scheme://host this deployment is publicly reached at, e.g.
# https://app.example.com. Required unless TRUST_PROXY=true — origin-guard.ts
# can't validate Origin behind a TLS-terminating proxy without one of these.
PUBLIC_ORIGIN=

# How often an open SSE connection re-checks Redis for session revocation,
# as a fallback to the pub/sub-based instant revocation.
SSE_SESSION_RECHECK_SECONDS=15

LOG_LEVEL=info
141 changes: 141 additions & 0 deletions DOCKER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Running in Docker

This repo ships as **one image**: the BFF (`server/`, Fastify) serves the
built UI (root, Vite/React SPA) as static files and proxies `/api/*`, so
the whole client stack — UI + BFF — is a single container. Redis is a
separate service, wired in via `docker-compose.yml`.

`.env`/`.env.example` are shared with native (non-Docker) dev — see the
root README's Getting Started section. `docker-compose.yml` and
`server`'s native `npm run dev`/`start` both read the same repo-root
`.env`.

The upstream ContextForge/mcpgateway API is **not** part of this repo or
this compose file — it's expected to already be running somewhere you
point `CONTEXTFORGE_URL` at.

## Quick start

```bash
cp .env.example .env
# edit .env: CONTEXTFORGE_URL defaults to 0.0.0.0:8000, which is only
# correct for native dev. If your gateway runs on the host, set:
# CONTEXTFORGE_URL=http://host.docker.internal:8000
docker compose up --build
```

Visit `http://localhost:3000/` — redirects to `/app/login`. `GET /healthz`
returns `{"ok":true}`.

By default this boots with `COOKIE_SECURE=false` and sessions backed by
this compose file's own `redis` service (`docker-compose.yml` defaults
`REDIS_URL` to it; see below to override).

## Environment variables

Full reference: `.env.example` (each var has an inline comment).
Summary, grouped the same way:

| Group | Vars | Notes |
|---|---|---|
| Works out of the box | `COOKIE_SECURE=false` | Required (or set `PUBLIC_ORIGIN`/`TRUST_PROXY`) for a zero-config boot — `server/src/config.ts` fails closed otherwise. |
| Must be set | `CONTEXTFORGE_URL` | No safe default reaches your gateway from inside the container. **No boot-time check catches a missing/wrong value** — it just fails every `/api/*` call at request time. Top thing to check if API calls all connection-refuse. |
| Fine as-is for dev | `PORT`, `HOST`, `CONTEXTFORGE_AUTH_HEADER_NAME`, `SESSION_TTL_SECONDS`, `REDIS_KEY_PREFIX`, `COOKIE_DOMAIN`, `TRUST_PROXY`, `PUBLIC_ORIGIN`, `SSE_SESSION_RECHECK_SECONDS`, `LOG_LEVEL` | Defaults match `server/src/config.ts`. |

The image itself (`Dockerfile`) sets **none** of these — it ships
respecting `config.ts`'s own defaults untouched. All configuration comes
from the environment at run time.

## Redis

`docker-compose.yml` defaults `REDIS_URL` to its own `redis` service, so
sessions are Redis-backed out of the box — no `.env` edit needed. Confirm
it: hit the login route, then

```bash
docker compose exec redis redis-cli KEYS 'bff:*'
```

should show keys, and a session survives `docker compose restart app`.

To use something else instead, set `REDIS_URL` in `.env` — e.g. a
different Redis, or `REDIS_URL=memory://` for the in-process,
lost-on-restart, single-instance-only fallback (`.env`'s value overrides
the compose default). If you see the `memory-redis` warning in
`docker compose logs app` and didn't ask for it, check `.env` isn't
setting `REDIS_URL=memory://`.

## Production checklist

Before this leaves a laptop:

- `COOKIE_SECURE=true`
- `REDIS_URL=redis://...` pointing at a real, persistent Redis (not `memory://`)
- `PUBLIC_ORIGIN=https://your-domain.example.com`, or `TRUST_PROXY=true` if
directly TLS-terminated with no reverse proxy in front
- `CONTEXTFORGE_URL` pointing at your real gateway

Get any of the first two wrong and the container won't boot at all —
`server/src/config.ts` throws at startup rather than serving traffic
insecurely. That's intentional; don't work around it by setting
`NODE_ENV=production` or similar in the image itself.

## Joining an existing stack / network

The provided `docker-compose.yml` is a standalone reference stack (app +
redis). If you already have your own Redis, network, or reverse proxy:

**Option A — run the image directly**, pointing at your own infra:

```bash
docker build -t contextforge-web-ui .
docker run -p 3000:3000 \
--network your-existing-network \
-e COOKIE_SECURE=true \
-e REDIS_URL=redis://your-redis-host:6379/0 \
-e CONTEXTFORGE_URL=http://your-gateway:4444 \
-e PUBLIC_ORIGIN=https://your-domain.example.com \
contextforge-web-ui
```

**Option B — override compose**, attaching to an external network instead
of the bundled `redis` service:

```yaml
# docker-compose.override.yml
services:
app:
networks: [external_net]
environment:
REDIS_URL: redis://your-existing-redis:6379/0

networks:
external_net:
external: true
```

```bash
docker compose -f docker-compose.yml -f docker-compose.override.yml up --build
```

## Troubleshooting

- **Every `/api/*` request (including login) fails with `ECONNREFUSED`**:
`CONTEXTFORGE_URL` is unreachable from inside the container — most often
because it's still set to `.env.example`'s native-dev default
(`0.0.0.0:8000`/`127.0.0.1:...`), which inside a container points at the
container's own loopback, not the host. If your gateway runs on the
host, set `CONTEXTFORGE_URL=http://host.docker.internal:8000` instead
(`docker-compose.yml` maps that hostname to the host on both Docker
Desktop and Linux). There's no boot-time check for this — the app
starts fine either way.
- **Container crash-loops on startup**: check `docker compose logs app` —
`config.ts` throws a specific error for each fail-closed case
(`memory://` Redis with `COOKIE_SECURE=true`, or `COOKIE_SECURE=true`
with neither `PUBLIC_ORIGIN` nor `TRUST_PROXY` set). The message tells
you exactly which var to set.
- **Multi-arch builds** (e.g. building on Apple Silicon for an amd64
target): `docker buildx build --platform linux/amd64,linux/arm64 -t contextforge-web-ui .`
— the UI stage's native dependency (`lightningcss`, via
`@tailwindcss/vite`) ships prebuilt musl binaries for both architectures,
so no Dockerfile changes should be needed.
53 changes: 53 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Builds the UI (Vite/React SPA) and the BFF (Fastify) into one runtime
# image. The BFF serves the built SPA as static files — see
# server/src/plugins/static.ts and vite.config.ts's `build.outDir`.
#
# Deliberately sets no NODE_ENV/COOKIE_SECURE/REDIS_URL here: server/src/config.ts
# owns those defaults (and fails closed on insecure combinations by design).
# Supply the right values at `docker run -e` / compose time instead.

# ---- UI dependencies ----
FROM node:22-alpine AS ui-deps
WORKDIR /ui
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund

# ---- UI build ----
# npm run build = "npm run generate && tsc -b && vite build". `generate`
# runs orval against the committed openapi.json (no network call). vite's
# outDir is "server/public", so output lands at /ui/server/public here.
FROM ui-deps AS ui-build
WORKDIR /ui
COPY openapi.json orval.config.ts index.html vite.config.ts ./
COPY tsconfig.json tsconfig.app.json tsconfig.node.json ./
COPY public ./public
COPY src ./src
RUN npm run build

# ---- BFF dependencies ----
# Full (non-prod) install here — tsc is a devDependency needed to build.
FROM node:22-alpine AS bff-deps
WORKDIR /app
COPY server/package.json server/package-lock.json ./
RUN npm ci --no-audit --no-fund

# ---- BFF build ----
FROM bff-deps AS bff-build
WORKDIR /app
COPY server/tsconfig.json ./
COPY server/src ./src
RUN npm run build

# ---- Runtime ----
FROM node:22-alpine AS runtime
WORKDIR /app
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY --from=bff-build /app/dist ./dist
COPY --from=ui-build /ui/server/public ./public
RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/index.js"]
Loading
Loading