Dynamiq
Self-Hosted Deployment

Install on Kubernetes (Helm)

The canonical, cloud-agnostic install of self-hosted Dynamiq: namespaces, secrets, a values file, the Helm release, and migrations.

This is the canonical install that every provider guide builds on. It targets any conformant Kubernetes cluster and uses release name dynamiq in namespace dynamiq throughout — change both together if you use different names. Work through System Requirements first: you need a reachable Postgres, NATS with JetStream, an S3 bucket, an ingress controller, DNS, TLS, and your Dynamiq license and registry credentials before Step 1.

Everything below is written for a domain of dynamiq.example.com. Replace it with your own throughout.

Create the namespaces

Create the release namespace and the workload feature namespaces. The chart's default createNamespaces: false expects them to exist already:

kubectl create namespace dynamiq --dry-run=client -o yaml | kubectl apply -f -
kubectl create namespace dynamiq-inferences
kubectl create namespace dynamiq-databases
kubectl create namespace dynamiq-fine-tuning

The dynamiq namespace command is idempotent, so it's safe to re-run even if that namespace already exists — for example, if you set up NATS with JetStream first with --create-namespace. The three feature-namespace commands aren't idempotent; if you're re-running this step, apply the same --dry-run=client -o yaml | kubectl apply -f - pattern to them too, or skip any that already exist.

nexus schedules user workloads — inferences, managed databases, and fine-tuning jobs — into those feature namespaces. If you later enable user services (dynamiq.features.services.enabled: true), also create dynamiq-services and dynamiq-image-builder.

Alternatively, let the chart create them by setting dynamiq.features.createNamespaces: true in your values file. That requires your installing identity to have cluster-level permission to create Namespace resources.

Chart-created namespaces are deleted on helm uninstall unless you set helm.sh/resource-policy: keep in dynamiq.features.namespaceAnnotations. Creating them manually (the default above) keeps their lifecycle independent of the release, which is safer for production.

Provide registry credentials

The private dynamiqai/* images need a pull Secret named docker-registry in the release namespace. Choose one of two paths.

Option A — let the chart create it: add this to your values file (Step 5 includes it as a commented block):

dynamiq:
  imageCredentials:
    registry: https://index.docker.io/v1/
    username: YOUR_DOCKER_HUB_USERNAME
    password: YOUR_DOCKER_HUB_PAT

Option B — pre-create it yourself with kubectl:

kubectl -n dynamiq create secret docker-registry docker-registry \
  --docker-server=https://index.docker.io/v1/ \
  --docker-username="$DYNAMIQ_REGISTRY_USER" \
  --docker-password="$DYNAMIQ_REGISTRY_TOKEN"

The pull Secret's name — docker-registry — is hardcoded in the chart. The migration Job references it by that exact name, and it is the default value of FINE_TUNING_IMAGE_PULL_SECRET. Keep the name. Because workload pods run in the feature namespaces (not the release namespace), each of those namespaces needs its own copy of the pull Secret; repeat the kubectl create secret docker-registry command in dynamiq-inferences, dynamiq-databases, and dynamiq-fine-tuning.

Create the license Secret

Store the license JWT Dynamiq gave you in a Secret. The chart mounts it at /etc/dynamiq/license.jwt into nexus, synapse, catalyst, and runtime:

kubectl -n dynamiq create secret generic dynamiq-license \
  --from-file=license.jwt=./license.jwt

The key inside the Secret must be license.jwt (the chart's default). You reference the Secret by name in the values file (dynamiq.license.secretName: dynamiq-license, Step 5).

Create the application secrets

nexus, synapse, and catalyst load signing keys and database credentials from per-service Secrets; runtime loads signing keys only. Create them all now. The names below are the exact names the rendered Deployments reference — do not rename them.

The three AUTH_* values are signing keys; generate them with openssl rand -base64 48. AUTH_INTERNAL_TOKEN_KEY is the shared internal-auth key and must be identical across all four services, so it is generated once and reused. Fill in your real database host, users, and passwords, plus your SMTP password (EMAIL_SMTP_PASSWORD, paired with the EMAIL_SMTP_* values in Step 5):

# Signing keys — AUTH_INTERNAL_TOKEN_KEY is shared across all services.
AUTH_ACCESS_TOKEN_KEY=$(openssl rand -base64 48 | tr -d '\n')
AUTH_VERIFICATION_TOKEN_KEY=$(openssl rand -base64 48 | tr -d '\n')
AUTH_INTERNAL_TOKEN_KEY=$(openssl rand -base64 48 | tr -d '\n')

cat <<EOF | kubectl apply -n dynamiq -f -
apiVersion: v1
kind: Secret
metadata:
  name: nexus
type: Opaque
stringData:
  AUTH_ACCESS_TOKEN_KEY:       '${AUTH_ACCESS_TOKEN_KEY}'
  AUTH_VERIFICATION_TOKEN_KEY: '${AUTH_VERIFICATION_TOKEN_KEY}'
  AUTH_INTERNAL_TOKEN_KEY:     '${AUTH_INTERNAL_TOKEN_KEY}'
  FIRECRAWL_API_KEY:           'REPLACE_WITH_YOUR_FIRECRAWL_KEY'
  EMAIL_SMTP_PASSWORD:         'REPLACE_WITH_YOUR_SMTP_PASSWORD'
---
apiVersion: v1
kind: Secret
metadata:
  name: synapse
type: Opaque
stringData:
  AUTH_INTERNAL_TOKEN_KEY: '${AUTH_INTERNAL_TOKEN_KEY}'
---
apiVersion: v1
kind: Secret
metadata:
  name: catalyst
type: Opaque
stringData:
  AUTH_INTERNAL_TOKEN_KEY: '${AUTH_INTERNAL_TOKEN_KEY}'
---
apiVersion: v1
kind: Secret
metadata:
  name: runtime
type: Opaque
stringData:
  AUTH_INTERNAL_TOKEN_KEY: '${AUTH_INTERNAL_TOKEN_KEY}'
---
apiVersion: v1
kind: Secret
metadata:
  name: nexus-db
type: Opaque
stringData:
  DATABASE_HOST:     'postgres.example.com'
  DATABASE_PORT:     '5432'
  DATABASE_NAME:     'nexus'
  DATABASE_SCHEMA:   'public'
  DATABASE_SSLMODE:  'require'
  DATABASE_USERNAME: 'nexus'
  DATABASE_PASSWORD: 'REPLACE_WITH_NEXUS_DB_PASSWORD'
---
apiVersion: v1
kind: Secret
metadata:
  name: synapse-db
type: Opaque
stringData:
  DATABASE_HOST:     'postgres.example.com'
  DATABASE_PORT:     '5432'
  DATABASE_NAME:     'synapse'
  DATABASE_SCHEMA:   'public'
  DATABASE_SSLMODE:  'require'
  DATABASE_USERNAME: 'synapse'
  DATABASE_PASSWORD: 'REPLACE_WITH_SYNAPSE_DB_PASSWORD'
---
apiVersion: v1
kind: Secret
metadata:
  name: catalyst-db
type: Opaque
stringData:
  DATABASE_HOST:     'postgres.example.com'
  DATABASE_PORT:     '5432'
  DATABASE_NAME:     'catalyst'
  DATABASE_SCHEMA:   'public'
  DATABASE_SSLMODE:  'require'
  DATABASE_USERNAME: 'catalyst'
  DATABASE_PASSWORD: 'REPLACE_WITH_CATALYST_DB_PASSWORD'
EOF

These names are not release-prefixed. The chart derives them from dynamiq.namePrefix, which defaults to "" — so the Deployments reference nexus, nexus-db, synapse, and so on, regardless of the dynamiq release name. (The inline comments in the chart's values.yaml show a <release>- prefix; that is the namePrefix, not the Helm release name.) runtime has no -db Secret — it has no database. The DATABASE_PORT, DATABASE_NAME, DATABASE_SCHEMA, and DATABASE_SSLMODE keys are included because the migration Job builds its Postgres connection string from them.

catalyst needs provider API keys. Beyond AUTH_INTERNAL_TOKEN_KEY, catalyst expects roughly a dozen LLM and tool provider API keys in its catalyst Secret and will not become healthy without the ones your platform uses. Add them to the catalyst Secret above — the full list and how to supply them is in the Configuration Reference.

Write the values file

Save this as values.yaml. It sets the domain and license, points every backend service at NATS, names the S3 bucket, configures nexus email, and enables Ingress with TLS for all seven hosts. The imageCredentials block is commented out because Step 2 pre-created the pull Secret; uncomment it (and skip the manual Secret) to have the chart create docker-registry for you.

dynamiq:
  domain: dynamiq.example.com

  license:
    secretName: dynamiq-license

  features:
    createNamespaces: false

  # Uncomment to let the chart create the docker-registry pull Secret for you
  # in the dynamiq namespace (Step 2, Option A). Leave commented if you create
  # it yourself with kubectl.
  # imageCredentials:
  #   registry: https://index.docker.io/v1/
  #   username: YOUR_DOCKER_HUB_USERNAME
  #   password: YOUR_DOCKER_HUB_PAT

nexus:
  imagePullSecrets:
    - name: docker-registry
  configMapData:
    STORAGE_SERVICE: s3
    STORAGE_S3_BUCKET: dynamiq-prod
    FINE_TUNING_DOCKER_IMAGE: dynamiqai/fine-tuning:0.3.8
    FINE_TUNING_IMAGE_PULL_SECRET: docker-registry
    EMAIL_PROVIDER: smtp
    EMAIL_FROM_NAME: Dynamiq
    EMAIL_FROM_ADDRESS: noreply@dynamiq.example.com
    EMAIL_SMTP_HOST: smtp.example.com
    EMAIL_SMTP_PORT: "587"
    EMAIL_SMTP_USERNAME: postmaster@example.com
    NATS_URL: nats://nats.dynamiq.svc.cluster.local:4222
  ingress:
    enabled: true
    className: nginx
    tls:
      - secretName: dynamiq-tls
        hosts:
          - api.dynamiq.example.com

synapse:
  imagePullSecrets:
    - name: docker-registry
  configMapData:
    STORAGE_SERVICE: s3
    STORAGE_S3_BUCKET: dynamiq-prod
    NATS_URL: nats://nats.dynamiq.svc.cluster.local:4222
  ingress:
    enabled: true
    className: nginx
    tls:
      - secretName: dynamiq-tls
        hosts:
          - "*.apps.dynamiq.example.com"
          - "*.inferences.dynamiq.example.com"
          - "*.knowledgebases.dynamiq.example.com"
          - "*.databases.dynamiq.example.com"
          - "*.services.dynamiq.example.com"

catalyst:
  imagePullSecrets:
    - name: docker-registry
  configMapData:
    STORAGE_SERVICE: s3
    STORAGE_S3_BUCKET: dynamiq-prod
    NATS_URL: nats://nats.dynamiq.svc.cluster.local:4222

runtime:
  imagePullSecrets:
    - name: docker-registry
  configMapData:
    STORAGE_SERVICE: s3
    STORAGE_S3_BUCKET: dynamiq-prod
    NATS_URL: nats://nats.dynamiq.svc.cluster.local:4222

ui:
  imagePullSecrets:
    - name: docker-registry
  ingress:
    enabled: true
    className: nginx
    tls:
      - secretName: dynamiq-tls
        hosts:
          - app.dynamiq.example.com

The example references one TLS Secret, dynamiq-tls, from all three ingresses; the certificate it holds must cover api., app., and the five wildcard zones. See Networking, DNS & TLS for per-host certificates and Gateway API. Note that EMAIL_SMTP_PASSWORD isn't set here — it's a secret, so it lives in the nexus Secret from Step 4, not in this ConfigMap-bound values file. Every other value here is documented in the Configuration Reference.

Install the release

The chart is a private OCI artifact, so authenticate to the registry first, then install:

helm registry login registry-1.docker.io \
  --username "$DYNAMIQ_REGISTRY_USER" \
  --password "$DYNAMIQ_REGISTRY_TOKEN"

helm upgrade --install dynamiq \
  oci://registry-1.docker.io/dynamiqai/dynamiq \
  --version 0.39.0 \
  --namespace dynamiq \
  -f values.yaml

Helm validates values.yaml against the chart's JSON schema before rendering anything. Required per-service keys — STORAGE_SERVICE, NATS_URL, and for nexus also FINE_TUNING_DOCKER_IMAGE, EMAIL_PROVIDER, and EMAIL_FROM_ADDRESS — must be present and valid. STORAGE_SERVICE only accepts s3, and NATS_URL must be non-empty.

If a required value is missing or blank, the install fails before it touches the cluster. For example, blanking nexus.configMapData.NATS_URL produces:

Error: values don't meet the specifications of the schema(s) in the following chart(s):
dynamiq:
- nexus.configMapData.NATS_URL: String length must be greater than or equal to 1

Fix the value and re-run the same command — it is idempotent.

Watch the migrations

A post-install/post-upgrade Helm hook runs the database migrations as a Job named after the release — dynamiq — applying the nexus schema with Atlas against the nexus-db credentials. This hook runs during Step 6's helm upgrade --install, which blocks until it finishes, and on success Helm deletes the Job immediately (helm.sh/hook-delete-policy: hook-succeeded). There's nothing left to watch once that command returns.

To watch it live, open a second terminal while Step 6's helm upgrade --install is still running:

kubectl -n dynamiq get job dynamiq -w
kubectl -n dynamiq logs job/dynamiq -f

If the install instead fails on this hook, Helm exits with an error and the failed Job persists (only a successful hook is deleted). Run the same two commands after the fact to inspect it — a failure is almost always the database: check that nexus-db has the right host, credentials, and DATABASE_NAME/DATABASE_SCHEMA, and that Postgres is reachable from the cluster. See Operations & Troubleshooting.

Verify and sign in

Confirm every pod is Ready, then hit the API health endpoint and open the app:

kubectl -n dynamiq get pods
curl https://api.dynamiq.example.com/health

All five Deployments (nexus, synapse, catalyst, runtime, ui) should report Ready, and the health check should return a success response. Then open https://app.dynamiq.example.com in a browser and register.

On a fresh install the first user to register becomes the platform's initial administrator. Register your own account before sharing the URL, and set up organizations and members from there — see Organizations & Projects.

Next steps

On this page