Skip to content

Deploy to production

The production image is a multi-stage, non-root Alpine build. The three supported deployment paths are:

Path File Use it for
Docker Compose docker-compose.prod.yml Single-host / VPS
Kubernetes docs/k8s/ (coming soon) Cluster deployments
Manual apps/mcp-server/Dockerfile Custom infra

To run one persistent server that all AI agents share as primary memory, see the agent memory server runbook.

  • Docker 25+ with BuildKit (DOCKER_BUILDKIT=1)
  • Docker Compose v2.24+
  • A .env.prod file based on .env.example
Terminal window
# From the repository root
docker build \
--file apps/mcp-server/Dockerfile \
--tag engram-mcp-server:latest \
.

The multi-stage build:

  1. deps — installs all workspace deps from the locked lockfile.
  2. builder — compiles TypeScript (nest build) and runs pnpm deploy to produce a flat production bundle at /prod.
  3. production — copies only the pruned bundle; runs as a non-root engram user.
Property Value
Base node:22-alpine
User engram (non-root UID 1000)
Port 3000
Default profile standard
Entrypoint node dist/main.js
Terminal window
cp .env.example .env.prod
# Edit .env.prod and set at minimum:
# POSTGRES_PASSWORD, MCP_ADMIN_TOKEN

Embeddings default to local Ollama (EMBEDDING_PROVIDER=ollama) — no API key needed. To opt in to OpenAI instead, set EMBEDDING_PROVIDER=openai and OPENAI_API_KEY in .env.prod.

Terminal window
docker compose -f docker-compose.prod.yml run --rm mcp-server \
sh -c 'node_modules/.bin/prisma migrate deploy'
Terminal window
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d

The prod compose file defaults EMBEDDING_PROVIDER to ollama and OLLAMA_URL to http://ollama:11434 — a bundled Ollama service behind the opt-in ollama compose profile (with an ollama_data volume for pulled models). Start it with the profile and pull the default model once:

Terminal window
docker compose -f docker-compose.prod.yml --env-file .env.prod --profile ollama up -d
docker compose -f docker-compose.prod.yml exec ollama ollama pull nomic-embed-text

Skip the profile if Ollama already runs on the host (point OLLAMA_URL at the host’s endpoint) or if you opted in to OpenAI. If Ollama is unreachable or the model is not pulled, writes still succeed — memories are stored without vectors and picked up by a later reindex. For GPU inference inside the container, add the standard Docker GPU configuration (e.g. the NVIDIA container toolkit and a gpus reservation); host installs use the GPU automatically.

Terminal window
curl http://localhost:3000/health
curl http://localhost:3000/health/ready
curl http://localhost:3000/health/metrics

Set DEPLOYMENT_PROFILE in .env.prod. Both profiles run on the same PostgreSQL backing store; the difference is the auth stack:

Value Backing store Auth stack
lite PostgreSQL + pgvector Not wired (single-user)
standard PostgreSQL + pgvector Multi-tenant: auth, per-agent keys, rate limits (default)

Note: PostgreSQL is the only backing service docker-compose.prod.yml provisions. Choosing standard over lite needs no extra services — see Migrate lite to standard for the auth configuration standard requires.

GET /health/metrics returns Prometheus text format. Scrape it with a Prometheus job:

prometheus.yml
scrape_configs:
- job_name: engram
static_configs:
- targets: ['engram-mcp-server:3000']
metrics_path: /health/metrics

The full metric catalogue lives in the observability reference.

Set OTEL_EXPORTER_OTLP_ENDPOINT to enable distributed tracing. When unset the SDK is never loaded and there is zero overhead:

Terminal window
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_SERVICE_NAME=engram-mcp-server

HTTP and Express spans are emitted automatically. Memory operation spans can be added via @opentelemetry/api in service code. See Enable observability for the full tracing setup, including a local Jaeger walkthrough.

The docker-build job in .github/workflows/ci.yml builds and smoke-tests the image on every push to main and on pull requests, but never pushes it (push: false) — CI is build validation only. Published images come exclusively from the release workflow below.

The release workflow (.github/workflows/release.yml) publishes ghcr.io/osirison/engram/mcp-server — the image that docker-compose.prod.yml pulls. It runs when a git tag matching v* is pushed and authenticates with the workflow’s own GITHUB_TOKEN (packages: write); no extra registry secret is required.

Terminal window
git checkout main && git pull
git tag v1.2.3
git push origin v1.2.3

The workflow then:

  1. Builds the image and smoke-tests it (boots the lite profile against a throwaway Postgres and polls /health) before anything is published.
  2. Pushes the image with BuildKit provenance and SBOM attestations attached to the manifest.
  3. Records a GitHub build-provenance attestation for the pushed digest.
  4. Creates a GitHub release for the tag with auto-generated notes (tags containing a -, e.g. v1.3.0-rc.1, are marked pre-release).
Tag Example Notes
<major>.<minor>.<patch> 1.2.3 Exact release
<major>.<minor> 1.2 Latest patch of the minor line
<major> 1 Latest release of the major line
sha-<commit> sha-6c93444… Immutable; pins the exact build source
latest latest Non-prerelease releases only

docker-compose.prod.yml uses image: ghcr.io/osirison/engram/mcp-server:${IMAGE_TAG:-latest}. Pin a specific version in .env.prod instead of relying on latest:

Terminal window
IMAGE_TAG=1.2.3
Terminal window
# GitHub build-provenance attestation
gh attestation verify oci://ghcr.io/osirison/engram/mcp-server:1.2.3 \
--repo osirison/engram
# BuildKit provenance / SBOM attached to the manifest
docker buildx imagetools inspect ghcr.io/osirison/engram/mcp-server:1.2.3 \
--format '{{ json .Provenance }}'
Terminal window
# Pull the released image (set IMAGE_TAG in .env.prod to move versions)
docker compose -f docker-compose.prod.yml pull mcp-server
docker compose -f docker-compose.prod.yml up -d mcp-server
# After schema changes
docker compose -f docker-compose.prod.yml run --rm mcp-server \
node_modules/.bin/prisma migrate deploy
  • All secrets are passed via environment variables; never baked into the image.
  • The container runs as user engram (non-root) on a minimal node:22-alpine base.
  • The Postgres port is not published externally in docker-compose.prod.yml.
  • Review the OWASP security checklist before going to production.