Skip to main content

Deployment

The app is a single container plus a Postgres database and a vLLM endpoint.

Docker image​

The Dockerfile is a two-stage build:

  1. build (node:22-slim) — npm ci, npm run build (Vite → Nitro bundle in .output/).
  2. runtime (node:22-slim) — NODE_ENV=production, npm ci --omit=dev, copies .output/, drizzle/, and scripts/migrate.mjs. EXPOSE 3000, CMD ["node", ".output/server/index.mjs"].
# Talos is amd64 — Dockerfiles pin linux/amd64; keep the flag explicit when building
docker build --platform linux/amd64 -t registry.cdot.io/clippy/clippy-chat:sha-$GIT_SHA .
docker push registry.cdot.io/clippy/clippy-chat:sha-$GIT_SHA

docker build --platform linux/amd64 -t registry.cdot.io/clippy/clippy-mcp:sha-$GIT_SHA mcp-server
docker push registry.cdot.io/clippy/clippy-mcp:sha-$GIT_SHA

Forgejo CI (.forgejo/workflows/ci.yml) builds and pushes both images automatically on every push to main — the commands above are for building locally, not the normal path.

:::note Migrations run separately scripts/migrate.mjs is copied into the image but is not part of the container CMD. Run npm run db:migrate (or an init job) against DATABASE_URL before/at rollout. :::

Runtime dependencies​

DependencyPurpose
Postgres 17conversations, messages, users, sessions
vLLM (OpenAI-compatible)inference — POST /v1/chat/completions, stream: true
OIDC provider (e.g. Keycloak)web-user login + machine tokens
Clippy MCPauthenticated tool execution with an independent scoped JWT

All configuration is via environment variables. Provide secrets (SESSION_SECRET, KC_CLIENT_SECRET, ADMIN_PASSWORD, and INFERENCE_API_KEY if used) through your platform's secret store — never bake them into the image.

Kubernetes (illustrative)​

A typical topology: the app behind an ingress that terminates TLS and rate-limits, running multiple replicas as a ClusterIP on port 3000, with Postgres and vLLM reachable in-cluster.

Example — secrets via a Secret (values are placeholders)
apiVersion: v1
kind: Secret
metadata:
name: clippy-chat
type: Opaque
stringData:
SESSION_SECRET: "REPLACE_ME" # openssl rand -base64 32
KC_CLIENT_SECRET: "REPLACE_ME" # from your IdP web client
ADMIN_PASSWORD: "REPLACE_ME"
# INFERENCE_API_KEY: "REPLACE_ME" # only if vLLM runs with --api-key
# INFERENCE_CLIENT_SECRET: "REPLACE_ME" # gateway mode: Keycloak completions.write client
Example — non-secret config via env
env:
- { name: APP_URL, value: "https://chat.example.com" }
- { name: KC_ISSUER, value: "https://auth.example.com/realms/myrealm" }
- { name: KC_CLIENT_ID, value: "clippy-web" }
- { name: INFERENCE_BASE_URL, value: "http://airs-gw.airs-gw.svc.cluster.local:80" }
- { name: INFERENCE_MODEL, value: "@vllm2/unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_XL" }
- { name: INFERENCE_AUTH_MODE, value: "gateway" }
- { name: DATABASE_URL, valueFrom: { secretKeyRef: { name: clippy-postgres, key: url } } }

:::warning Examples and production manifests differ The snippets above are portable examples with placeholders (chat.example.com, auth.example.com, REPLACE_ME). This repository also ships the Argo-managed production raw manifests under k8s/. They reference ExternalSecret resources (Conjur via External Secrets Operator) and never contain secret values. Review k8s/README.md; do not hand-apply production changes while Argo manages the Application. :::

The production MCP route and its OAuth/JWKS policy are documented in AI Gateway MCP operations. Deploy image digests before Argo rollout, then execute the E2E security plan.

Because the app runs multiple replicas, its code already handles the concurrency that implies (admin bootstrap, conversation-create races) — see Architecture.

Documentation site (this site)​

These docs are a Docusaurus site under docs-site/. Forgejo is authoritative; its main branch is pushed to a read-only GitHub mirror, then GitHub Pages publishes on changes to docs-site/** (.github/workflows/deploy-docs.yml). The workflow builds docs-site/ and deploys the static output — it never runs the app or handles app secrets.

To preview the docs locally:

cd docs-site
npm install
npm run start # dev server with hot reload
npm run build # production build (must pass; onBrokenLinks: 'throw')