Deployment & Docker

Build Patterns

This document describes the build patterns used for FlowState Docker images.

Overview

FlowState services use optimized multi-stage Docker builds with esbuild bundling. The goal is to produce small, efficient images (~200-500MB) compared to naive approaches that can produce 20GB+ images.

Build Architecture

Two-Stage Pattern

All service Dockerfiles follow this pattern:

Stage 1: Builder (node:20-slim)
├── Install build tools (git, python3, build-essential)
├── Clone repository from GitHub
├── Install all dependencies (yarn install)
├── Build with Nx
├── Bundle with esbuild
└── Install external module dependencies

Stage 2: Production (node:20-slim)
├── Install curl (for health checks)
├── Copy bundled server.js
├── Copy node_modules (external deps only)
├── Create non-root user
└── Configure health check

esbuild Bundling

We use esbuild to bundle most dependencies into a single server.js file (~1-5MB). This eliminates the need to copy the entire node_modules directory (~3GB in a monorepo).

RUN npx esbuild packages/my-service/dist/index.js \
    --bundle \
    --platform=node \
    --target=node20 \
    --outfile=/app/bundle/server.js \
    --minify \
    --sourcemap \
    --external:bcrypt  # Native modules must be external

Handling External Dependencies

Some modules cannot be bundled with esbuild:

Module TypeReasonSolution
Native modules (bcrypt)Contains compiled C++ bindingsMark external, install via npm
Dynamic imports (rxdb)Uses require() at runtime for pluginsMark external, install via npm

Pattern: Installing External Dependencies

Instead of manually copying node_modules directories (which misses transitive deps), use npm to install:

# Get the version from the monorepo
RUN BCRYPT_VERSION=$(node -p "require('./node_modules/bcrypt/package.json').version") && \
    cd /app/bundle && \
    echo "{\"name\":\"deps\",\"private\":true,\"dependencies\":{\"bcrypt\":\"$BCRYPT_VERSION\"}}" > package.json && \
    npm install --omit=dev

This ensures all transitive dependencies are included.

Service-Specific Patterns

Services with bcrypt (auth-server, mcp-http)

# Bundle everything except bcrypt
RUN npx esbuild ... --external:bcrypt

# Install bcrypt with all its dependencies
RUN BCRYPT_VERSION=$(node -p "require('./node_modules/bcrypt/package.json').version") && \
    cd /app/bundle && \
    echo "{\"dependencies\":{\"bcrypt\":\"$BCRYPT_VERSION\"}}" > package.json && \
    npm install --omit=dev

Services with RxDB (rxdb-server)

RxDB uses dynamic imports for plugins, so it must be external:

# Bundle everything except rxdb
RUN npx esbuild ... \
    --external:rxdb \
    --external:rxdb/* \
    --external:rxdb-premium \
    --external:rxdb-premium/*

# Install rxdb with all dependencies
RUN --mount=type=secret,id=rxdb_premium \
    RXDB_VERSION=$(node -p "require('./node_modules/rxdb/package.json').version") && \
    cd /app/bundle && \
    echo "{\"dependencies\":{\"rxdb\":\"$RXDB_VERSION\"}}" > package.json && \
    npm config set //npm.rxdb.info/:_authToken "$(cat /run/secrets/rxdb_premium)" && \
    npm install --omit=dev

Services with Static Assets (auth-server)

Some services need additional directories copied:

# In production stage
COPY --from=builder /app/packages/flowstate-auth-server/templates /templates
COPY --from=builder /app/packages/flowstate-auth-server/views /views

Services with Pure JS (obs-server, rag-sync, document-store)

These services have no native modules and can be fully bundled:

RUN npx esbuild packages/my-service/dist/index.js \
    --bundle \
    --platform=node \
    --target=node20 \
    --outfile=/app/bundle/server.js \
    --minify \
    --sourcemap

No external dependencies needed - results in smallest images (~200MB).

Image Sizes

ServiceSizeExternal Deps
obs-server~217MBNone
document-store~225MBNone
rag-sync~237MBNone
auth-server~371MBbcrypt
mcp-http~383MBbcrypt
rxdb-server~969MBrxdb, rxdb-premium

Common Issues

"Cannot find module X"

Cause: A dependency was marked as external but not installed in the production image.

Solution: Add the dependency to the npm install step:

RUN echo "{\"dependencies\":{\"missing-module\":\"^1.0.0\"}}" > package.json && \
    npm install --omit=dev

"Cannot find module '@mapbox/node-pre-gyp'"

Cause: bcrypt is bundled but it requires node-pre-gyp at runtime.

Solution: Mark bcrypt as external and install it via npm (see pattern above).

Missing templates/views/assets

Cause: Static assets aren't included in the esbuild bundle.

Solution: Copy them explicitly in the production stage:

COPY --from=builder /app/packages/my-service/assets /assets

Build uses cached old code

Cause: Docker caches the git clone step.

Solution: Use --no-cache flag:

./docker/build.sh local my-service --no-cache

Build Commands

# Build single service
./docker/build.sh local rxdb

# Build with no cache (after pushing code changes)
./docker/build.sh local rxdb --no-cache

# Build all services
./docker/build.sh local all

# Build from specific branch
./docker/build.sh local rxdb --branch feature-xyz

Debugging Builds

Check what's in the bundle

# Run a temporary container
docker run --rm -it flowstate-my-service:latest sh

# List files
ls -la /app/
ls -la /app/node_modules/

Check bundle size

docker images | grep flowstate

View build logs

# Build with progress output
DOCKER_BUILDKIT=1 docker build --progress=plain ...

Adding a New Service

  1. Copy an existing Dockerfile as a template
  2. Update package name in yarn nx build command
  3. Update entry point in esbuild command
  4. Identify any native modules and mark them external
  5. Add npm install step for external dependencies
  6. Update port and health check endpoint
  7. Test the build: ./docker/build.sh local my-service --no-cache
  8. Verify the container runs: docker run --rm flowstate-my-service:latest
Previous
Mail Server