Skip to main content

Configure values and secrets

Set the DALP Helm chart's datastore, TLS, and secret-manager values, and choose how secrets reach each pod.

This page sets the datastore, TLS and secret values of the dalp chart, and says how each secret reaches its pod. Read it before your first helm install with Install the DALP Helm chart, because a release does not render or does not start until some of these values are set.

Four values block a release until you set them:

Value
Ships as
Without your own value
ENCRYPTION_MASTER_PASSWORD
Empty
Every pod refuses to start
BETTER_AUTH_SECRET
A published placeholder
A production pod refuses to start on the placeholder
CUSTODY_CREDENTIAL_IDENTITY_KEY
A development placeholder
A production pod refuses to start on the placeholder
global.dalpApp.env.DALP_GOVERNANCE_DOMAIN_SCOPE
Empty
helm install and helm upgrade fail the render

Encryption at rest, Custody credential identity key and Governance ballot domain describe each check. The secret-manager provider ships as local, which does not block the install and keeps application secrets in the platform's own database on a production deployment. Review datastore TLS, credentials, connection capacity and the secret delivery path against the target environment before installation.

dapi (the Platform API) and dapp (the Console) are the two application subcharts this page configures most. The migrator is the dapp subchart's schema-migration Helm hook, a Job that runs on every install and upgrade. It appears throughout this page because two of its steps, direct database access and encryption rotation, shape the values below.

Values structure

values.yaml groups into a shared global block: observability, image-pull credentials, datastores, the embedded application config, Conjur and HTTP routing.

Each service has its own block, among them dapi (the Platform API), dapp (the Console), blockscout (an optional block explorer), investorPortal (the optional Investor portal), investorDocs, canton, utilaSigningAgent, contractsDeploy, docs, backup, overprovisioning and postgres-exporter. The PgBouncer connection pooler is a separate chart dependency that global.pgbouncer.enabled switches on.

dapi and dapp each carry autoscaling, image, resource and exposure settings, through ingress, a Gateway httpRoute on Kubernetes or an openShiftRoute on OpenShift.

Services you supply

No chart deploys a datastore, so PostgreSQL, Redis, and object storage are services you supply. The chart's default values name placeholder in-cluster hosts and passwords for them, and the platform cannot connect until services answer under those names or you replace them. Point all three at services for a production deployment: a managed service can provide replication, patching, and backups, depending on the plan and configuration you choose. Verify those capabilities and test recovery. Topologies covers the deployment shapes.

Datastores

global.datastores sets connections per service for PostgreSQL and Redis, either inline or through existingSecret. Object storage is set under global.dalpApp.config.objectStorage, with its keys in OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY. Default passwords in the chart, such as "dalp" for PostgreSQL, are placeholders. Change every one before you deploy to production.

PostgreSQL TLS reads sslMode, and the leg it secures depends on whether pooling is on. global.pgbouncer.enabled defaults to true, and a pooled deployment removes sslMode from the application's own connection URL. The pooler's client listener is plaintext inside the cluster, so with pooling on the leg from the application pods to the pooler is unencrypted.

An application that tries to negotiate TLS against it fails to connect and never falls back to plaintext. Encryption on that leg needs pooling off, with DATABASE_TLS on the direct connections. sslMode sets the pooler's own upstream leg to Postgres, and the chart ships it as disable, so the upstream leg is plaintext by default.

The pooler accepts four values there: disable, require, verify-ca and verify-full. Any other value fails the render.

For a managed PostgreSQL on a public certificate authority, set sslMode to require. The chart maps it to verify-full over the image's system CA bundle, which verifies both certificate and hostname with no CA to mount. For a server behind a private certificate authority, set sslMode to an empty string instead, and supply the CA under pgbouncer.upstreamTls, the pooler subchart's own values:

global:
  pgbouncer:
    enabled: true # umbrella gate: whether pooling exists at all
pgbouncer:
  upstreamTls:
    ca: | # the private CA's certificate, PEM-encoded
      -----BEGIN CERTIFICATE-----
      ...
      -----END CERTIFICATE-----
    # existingSecret: my-ca-secret   # or point at a Secret instead

An empty sslMode with that CA configured resolves to a verified verify-full connection. The shipped disable with a mounted CA leaves the CA unused, because disable passes through as it is. The application's own DATABASE_TLS value takes effect only on direct connections: the migrator's, or every connection when pooling is off.

How application pods reach Postgres

Set global.datastores.default.postgresql.maxConnections to your PostgreSQL server's real max_connections. The chart sizes the pooler's server connections against that number and fails the render when they do not fit.

A central transaction-mode connection pooler, PgBouncer, sits between every application pod and Postgres.

Each process role has its own pooler alias with its own fixed pool: dalp_api for the API role, dalp_indexer and dalp_indexer_rebuild for chain ingestion, dalp_runner and dalp_scheduler for background work, dalp_app for the Console, dalp_cluster for the storage traffic of the platform's durable-execution engine for background jobs, and dalp_probe for the health-probe connection.

A pod holds pooler client connections against that fixed pool, with a ceiling of 3,000 clients per pooler process (pgbouncer.pool.maxClientConn), and no steady direct Postgres backend of its own. Raising dapi's replica count changes that client load and leaves Postgres's own max_connections untouched.

The chart pins the pool, reserve and hard-cap values for each alias, and checks two bounds when it renders. A render fails if either one is broken:

  • Server side. Every alias's max_db_connections across the peers, plus pgbouncer.pool.directConnections (26), plus 3 reserved superuser slots, must fit global.datastores.default.postgresql.maxConnections. The defaults use 371 of 400: at the pinned replicaCount of 2 times processCount of 1, that is 342 alias connections, 26 direct and 3 reserved.
  • Client side. A role's pods at their replica ceiling, times the per-pod pool, may hold at most pgbouncer.pool.clientOversubscription (8) clients per server connection of its alias. For the API role that is 28 pods times 40, so 1,120 clients onto 140 server connections.

The server-side check knows only the maxConnections you declare. The chart declares 400, which is a placeholder and not a reading of your server. A server configured lower than the declared value still accepts the deployment, and starts failing individual connections once traffic passes its ceiling. PostgresAggregateBackendsHigh in the observability chart watches the real server. Watch your server's connection count through postgres-exporter or pg_stat_activity before and after the change.

The migrator connects to Postgres directly, without the pooler, because its schema-migration locks are session-scoped and cannot survive a transaction-mode pooler. postgres-exporter holds one direct session by default too, to report the server's aggregate backend count.

Blockscout, an optional block explorer that ships disabled (blockscout.enabled: false), holds the largest direct share once you turn it on: 20 sessions. The indexer also opens up to 4 direct sessions for concurrent index builds. Blockscout's 20, the exporter's 1, the migrator's 1 and the index builds' 4 sum to the 26 direct connections in pgbouncer.pool.directConnections.

Enabling Blockscout adds its configured pool to the database connection budget.

A saturated pooler queues clients before it rejects them, so the signal to watch is a growing client queue. The observability chart's alert rules catch that. PgBouncerClientsWaiting pages after one minute of continuous queueing. PgBouncerClientMaxWait pages when the oldest queued client has waited more than one second for 10 seconds, so it is the earlier signal.

PgBouncer rejects a queued client at its 10-second queryWaitTimeout, ahead of the application role's 30-second statement timeout and the 60-second HTTP deadline. PgBouncerDeploymentAbsent and PgBouncerDown page separately when the pooler itself goes missing or becomes fully unavailable, since every application DATABASE_URL depends on it existing.

PgBouncerReplicaUnavailable catches the state between those two: it fires when redundancy stays below target for 3 minutes while at least one replica still serves, which halves pool capacity without tripping either alert. Watch the exporter's own health too.

PgBouncerMetricsAbsent and PgBouncerMetricsUnreadable both mean the saturation alerts have gone silent, because they read the same telemetry the exporter reports. Treat silence from them as unproven until the exporter is confirmed up.

These alerts ship in the observability chart, not in dalp, and fire only once you install it and switch its alert rules on, as Install the observability stack describes.

With pooling off, global.pgbouncer.enabled: false, application traffic uses direct Postgres connections, sized by each role's own pool-size setting, and the pooler alerts stop applying. PostgresAggregateBackendsHigh watches the server in both cases and pages at 80% of max_connections. Size the change against max_connections by hand before you apply it.

Pooler requirements for prepared statements

Application connections through the pooler use named prepared statements. A connection prepares a statement once and reuses its name, so PostgreSQL plans each statement once per server connection instead of on every call. The pooler must track those names across the server connections it hands out.

The chart's pooler does. It runs PgBouncer 1.26 with max_prepared_statements at 512 (pgbouncer.pool.maxPreparedStatements). Each application connection keeps at most 100 statements prepared (global.dalpApp.config.database.preparedStatementCacheSize). Keep the pooler limit at or above that cache size.

A pooler you supply must track named prepared statements in transaction mode. PgBouncer 1.21 and later does when max_prepared_statements is above 0. These setups do not:

  • PgBouncer before 1.21, or with max_prepared_statements at 0.
  • Any pooler in statement mode.
  • A pooler that pins the session to one server connection on its first prepared statement, such as RDS Proxy. Pinning removes the connection reuse the pooler exists for.

With one of those, set global.dalpApp.config.database.preparedStatementCacheSize to 0. Pooled application connections then send every statement unprepared. Direct connections, such as the migrator's, never pass through the pooler, ignore this value and keep preparing.

A migration that changes a column's type invalidates the statements the pooler prepared before it. The first run of each such statement inside a transaction fails with cached plan must not change result type, once for each application process. That process then sends the statement unprepared, and a retry of the failed request succeeds. Restarting the application pods does not avoid the first failure, because the pooler keeps the old statement on its server connections.

PostgreSQL can switch a prepared statement to a generic plan after five executions. On a large, uneven database a few statements can run slower on that plan. If one does, set plan_cache_mode to force_custom_plan on the application role with ALTER ROLE ... SET. Role settings reach pooled connections. Connection startup options do not. This gives back the planning time that prepared statements save.

Secret injection: Conjur

Two secret systems, each with its own value and its own consumer.Two columns. On the left, Conjur and summon deliver deployment secrets when Conjur is switched on. A Conjur authenticator init container fetches an access token, then summon injects the resulting secrets as environment variables at process start, gated on that init container plus a conjur-certificate ConfigMap present in the namespace before the pod starts. Miss either one and the pod crash-loops at startup, naming the environment variable summon never injected, a faster signal than the underlying Conjur connectivity failure. On the right, the application's own secret-manager setting is a separate decision: pick one of local, aws, gcp, azure, or vault, and that choice decides where application-level secrets live at rest, independent of Conjur entirely. The highest-stakes value in this second system is the encryption master password, boxed below the provider choice. Rotate it in two steps: set the outgoing password as ENCRYPTION_PREVIOUS_MASTER_PASSWORD for the migration Job only, keep the new one as ENCRYPTION_MASTER_PASSWORD, then deploy. The migration Job decrypts with either key and re-seals every row under the new one before runtime pods start with only the new key. The migrator's rotation boot step runs automatically on that same deploy. Only remove the old password once that job succeeds, not just once its summary lines look clean. Removing it too early fails the next deploy loudly rather than silently losing data. Only the master password itself being lost makes a row permanently unreadable. A closing bar beneath both columns states the risk of getting the secret-manager provider wrong: mismatch it against your existing practice and you run two separate secret stores with two separate audit trails.Two secret systems, each with its own value and its own consumer.The same figure as the desktop variant, drawn portrait for phone widths. Conjur and summon deliver deployment secrets as environment variables at pod start, gated on an init container and a certificate ConfigMap. A missing one crash-loops the pod, naming the environment variable summon never injected, a faster signal than the underlying connectivity failure. The application's own secret-manager provider is a separate decision, one of local, aws, gcp, azure, or vault, that places application-level secrets, above all the encryption master password, at rest. Rotate the master password in two steps, old key then new, and remove the old one only once the rotation job succeeds, not just once its summary lines look clean. Mismatching the secret-manager provider against your existing practice runs two separate secret stores with two separate audit trails.

Conjur and summon deliver deployment secrets at pod start, while the application's secret-manager provider places application-level secrets at rest.

By default the chart renders the application secrets in global.dalpApp.env into one shared Kubernetes Secret that the Console and Platform API pods read, and each datastore block can read its password from a Secret you name in existingSecret. CyberArk Conjur is an opt-in path: global.conjur.enabled, or a service's own conjur.enabled, switches a service to Conjur and summon, and both ship false.

summon is Conjur's command-line tool. It reads a secrets.yml mapping of environment variable names to Conjur paths, resolves each path against Conjur, and starts the service process with the resolved values in its environment. With Conjur on, each pod runs a Conjur authenticator init container that fetches an access token, and summon injects the secrets as environment variables at process start.

Declare each service's secrets under <service>.conjur.secrets, a map of environment variable names to Conjur paths. The chart renders that map as a <release>-<service>-conjur-secrets ConfigMap, mounts it as /etc/summon/secrets.yml and wraps the container's entrypoint with summon. Changing a mapping is a values change and a pod restart. A service with no conjur.secrets entries runs without summon.

The published DALP images do not include summon or summon-conjur. The Conjur path needs images built on top of them with summon at /usr/local/bin/summon and the summon-conjur provider at /usr/local/lib/summon/summon-conjur. It also needs the authenticator init container and a conjur-certificate ConfigMap in your release namespace before you deploy.

A pod that starts before its Conjur authenticator init container can reach the Conjur server, or before the conjur-certificate ConfigMap exists, fails at process start and never starts with empty secrets. Its crash loop names the environment variable summon did not inject, which shows sooner than the Conjur connectivity failure behind it. Check the init container's logs first, because the main container's log shows only the symptom.

Application secret-manager providers

The application's own secret-manager setting is a separate choice from the delivery path in Conjur secret injection. It selects one provider: local, aws, gcp, azure or vault. Selecting a provider makes its configuration block required. For example, selecting vault requires a token, and selecting aws accepts an access key ID, region and secret access key.

local keeps application secrets encrypted under the master password in the platform's PostgreSQL database. Choose it only for development. For production, choose the one of aws, gcp, azure or vault that matches infrastructure you already operate, with rotation and audit practices in place for it. This setting decides where application-level secrets live at rest, as opposed to the deployment secrets that reach the pods at start. A provider that differs from your existing practice leaves you with two secret stores and two audit trails.

Encryption at rest

The application requires a master password for encryption at rest. Config decode enforces a 16-character floor, but the encryption service applies a stricter check on top of it at pod startup: a minimum of 128 bits of estimated entropy. A 16-character password can still fail there if it is guessable, so generate one from a cryptographic random source, for example openssl rand -base64 32, and store it in your secret manager. The platform rejects the password it uses for local development on any pod whose NODE_ENV is not development.

BETTER_AUTH_SECRET ships with the published placeholder "strong-secret-of-at-least-32-characters". A production pod checks the resolved value against that exact string and fails with “BETTER_AUTH_SECRET must be set to a unique value in production”. Set your own value of at least 32 characters before production. Never change ENCRYPTION_MASTER_PASSWORD or BETTER_AUTH_SECRET on an existing environment outside the supported rotation procedure. The master password protects encrypted application data and the auth secret protects authentication material, so changing either another way can break access. A new key alone cannot decrypt data encrypted under a lost key.

Rotate the master password in two steps. Set the outgoing password as ENCRYPTION_PREVIOUS_MASTER_PASSWORD for the migration Job only, through dapp.migrator.env or, with Conjur, dapp.migrator.conjur.secrets, which the chart merges over the runtime dapp.conjur.secrets map so the Job keeps the current key.

Keep the new password as ENCRYPTION_MASTER_PASSWORD, then deploy: the migration Job can now decrypt with either key, so it reads every row sealed under the outgoing one. Runtime pods never receive the previous password. The migrator runs as a Helm hook on every install and upgrade.

One of its required boot steps, rotate-encryption, scans every encrypted row and rewrites it under the new key whenever ENCRYPTION_PREVIOUS_MASTER_PASSWORD is set. No separate command is needed. The same deploy's migration Job re-seals the data.

It logs an Encrypted secret rotation summary line, and a matching KYC encryption rotation summary line, each with scanned, rotated, skipped, and failed counts, and fails the job if any row fails. Use the migrator's successful exit as the completion signal. The Job also verifies KYC documents after rotation and can fail even when both rotation summaries show zero failures.

Wait for the Job to succeed, not just for those two lines to look clean, before you remove ENCRYPTION_PREVIOUS_MASTER_PASSWORD. Removing it too early does not silently destroy data: the next deploy's rotation step checks for leftover pre-rotation rows and fails loudly, naming exactly how many secret rows and KYC objects it found, rather than skipping past them.

Only the master password itself being lost makes an encrypted row permanently unreadable.

Write-once report storage

global.dalpApp.config.objectStorage.objectLock.enabled turns on write-once storage for archived reports. It defaults to false. Set OBJECT_STORAGE_OBJECT_LOCK_ENABLED to the same effect outside the chart.

With it on, every file a filing generates is written with a lock that lasts for the retention class of its obligation: five, seven or ten years, or 100 years for Indefinitely. DALP then refuses to replace or delete that file until the lock ends, and each pod refuses to start if the bucket cannot enforce the lock. The error names the bucket and what it is missing.

The lock runs from the moment the filing is generated. The retention period the platform erases by runs from the day the filing is accepted, so the lock ends slightly before that period does.

Provider
Prerequisite on the bucket
What enforces the lock
aws, s3-compatible
Created with Object Lock enabled
Object Lock in compliance mode
azure
Blob versioning and version-level immutability on the container
A locked immutability policy on each blob
gcp
Created with object retention enabled and object versioning off
Object retention in locked mode
filesystem
Not supported
Pods refuse to start

Compliance mode cannot be shortened or removed by anyone, including the cloud account's root user, until the date passes. Check the setting on a test bucket first.

Before you turn it on:

  • Create the bucket with the prerequisite above. Most providers can enable it only when the bucket is created.
  • Leave the bucket's default retention unset. The bucket is shared with every other DALP object, and a default would lock those too. DALP sets retention on each report file itself.
  • On S3, give the storage credentials s3:PutObjectRetention, s3:GetObjectRetention, s3:GetBucketObjectLockConfiguration, s3:DeleteObjectVersion and s3:ListBucketVersions.
  • Deny s3:PutObject and s3:DeleteObject on the reporting/ prefix to every other principal. Object Lock protects a stored version, so a client with direct bucket access could still add a newer version or a delete marker over it. The locked version stays intact and report digests expose the change, but a bucket policy stops it happening.

Keep the value on once locked files exist. With it off, DALP deletes an object with a plain delete, which on a versioned bucket hides the object and leaves its data behind.

Switch application layers on or off

DALP sells seven application layers as licensed modules. The deployment decides which modules it offers, and that is the licence. Each switch sits under global.dalpApp.config.features. Each switch also reads an environment variable when you set the application config from the environment.

Pricing layer
Value
Environment variable
Default
Primary market
tokenSale
FEATURE_TOKEN_SALE
true
Secondary market
tradingVenue
FEATURE_TRADING_VENUE
false
Market operations and compliance
marketOperations
FEATURE_MARKET_OPERATIONS
true
Reporting and finance operations
regulatoryReporting
FEATURE_REGULATORY_REPORTING
false
Market surveillance
surveillance
FEATURE_SURVEILLANCE
false
Self-custody
custodyOps
FEATURE_CUSTODY_OPS
true
Client portal
clientPortal
FEATURE_CLIENT_PORTAL
true

Primary market covers offerings and fund dealing: the subscription and redemption requests of a fund and the settlement of its dealing days. Secondary market covers the trading venue. Market operations and compliance covers the audit trail views and export. Self-custody covers the custody gateway, embedded wallets and multisig wallets. Client portal covers investor sign-in to the Investor portal. Surveillance sends the live FIX copy of every order and trade to a surveillance provider, and needs the secondary market.

Everything else DALP ships is base platform: always on, with no switch. That includes issuance, corporate actions and holder votes, second-person approval of sensitive changes, and delivery-versus-payment (XvP) and escrow settlement. Bank rail and the AI assistant are not modules. Canton is neither a module nor base platform: it has its own switch, and it is off by default (see Canton).

A module that is off for the deployment has no screens and no API routes for any organisation. Its routes answer 404, the same answer as a route that does not exist. A module that is on is available to every organisation until an organisation owner switches it off in Organisation settings. An organisation that switched a module off can still read its records, and the API refuses its writes with platform.module.DISABLED. See Read and handle DALP errors.

A new organisation starts with every module the deployment offers ticked. Organisations that existed before you switched a module on get it too, unless they opted out of it. Switching a module off on the deployment does not stop work already in motion: scheduled payouts, trading sessions and provider syncs keep running, and only new writes through the API and the Console are refused.

global:
  dalpApp:
    config:
      features:
        tradingVenue: true
        surveillance: true

Canton

Canton is off by default, and release 3.2 ships with it off. Set global.dalpApp.config.features.canton to true, or the environment variable CANTON_ENABLED to true when you set the application config from the environment, to turn it on. The switch is separate from the licensed modules above: no organisation can opt out of it, and Organisation settings has no row for it.

While the switch is off:

  • The Console and the Investor portal show no Canton network, asset type, screen or menu.
  • The Canton wallet routes (/api/v2/wallets and everything under it) answer 404, the same answer as a route that does not exist. The OpenAPI document, the SDK and the CLI still list them, because they ship once for every deployment, and each of the six operations says in its description that it needs features.canton. A Canton command of the CLI answers FEATURE_NOT_ENABLED with the text “Canton is not enabled on this deployment.”
  • GET /api/v2/tokens answers 400 COLLECTION_FILTER_VALUE_INVALID when the request selects the Canton family with filter[family]=canton, with a filter[network] that names a Canton network, or with the X-Network-Id header. data.errors says that Canton is not enabled on this deployment. The list without those selections answers from the EVM tokens only.
  • Any other route that selects a Canton network is refused with CANTON_NETWORK_NOT_CONFIGURED.

Turning the switch on is not enough to use Canton: the deployment also needs an enabled Canton network in its network configuration and a reachable participant. See Helm charts for the canton chart block.

global:
  dalpApp:
    config:
      features:
        canton: true

External-token auto-registration

global.dalpApp.config.features.externalTokenAutoRegistration, or the environment variable FEATURE_EXTERNAL_TOKEN_AUTO_REGISTRATION, decides whether the platform registers an unknown ERC-20 or ERC-3643 token on its own when it lands in a wallet an organisation manages. It is false by default. While it is off, a token sent to a managed wallet stays unregistered until an asset manager registers it by hand.

With it on, anyone can make the platform write a registry entry by sending a token to a managed wallet, up to 20 tokens per organisation per day, and no person confirms the entry. Turn it on only when every organisation on the deployment accepts that. See External tokens.

Governance ballot domain

Set global.dalpApp.env.DALP_GOVERNANCE_DOMAIN_SCOPE to a name that belongs to this deployment alone. The platform hashes the name into the signing domain of governance poll ballots. A ballot signed in one deployment must not verify in another, so never copy the name between installs. Changing it invalidates every ballot signed under the old name.

The chart has no default for this value. helm install and helm upgrade fail while it is empty or set to local. A production process also refuses to start on either value, and the error names DALP_GOVERNANCE_DOMAIN_SCOPE. Set it in the values file, not through a Conjur secret, because the chart checks the value when it renders.

Custody gateway keeper gas price

The custody gateway keeper can ask a withdrawing client to repay the gas it spends on the payout, in the withdrawn coin. To turn gas into that coin it needs the price of the chain's native coin, and the platform reads no market feed for it. Without a price, a request carries no gas recharge and the organisation pays the keeper's gas.

Set global.dalpApp.config.custodyOps.gateway.nativePrices to a list of prices, one for each network and gateway coin:

global:
  dalpApp:
    config:
      custodyOps:
        gateway:
          nativePrices:
            - chainId: 1
              token: "0x00000000000000000000000000000000000000c1"
              coinPerNativeCoin: "3000.25"
              observedAt: "2026-11-11T08:00:00Z"
Field
Meaning
chainId
The chain whose native coin the price is for.
token
The gateway coin the gas is repaid in. Add one entry per coin.
coinPerNativeCoin
Whole units of the coin that one whole unit of the native coin buys, as a quoted decimal above zero.
observedAt
When you read the price.
nativeDecimals
Decimals of the native coin. Defaults to 18. Set it for a network whose native coin differs.

An entry stops quoting once observedAt is older than nativePriceMaxAgeSeconds, which defaults to 3600 and accepts 60 to 604800. Set it through CUSTODY_OPS_GATEWAY_NATIVE_PRICE_MAX_AGE_SECONDS. A price that is missing, older than that bound, or paired with a coin whose decimals cannot be read quotes no gas recharge, so a stale price never overcharges a client. The keeper logs a warning with the reason for a stale or unreadable entry. Refresh observedAt and the price together whenever you update an entry. The platform caps every quote at the gas ceiling stored on the gateway.

Custody credential identity key

Set global.dalpApp.env.CUSTODY_CREDENTIAL_IDENTITY_KEY to a value of at least 32 characters that belongs to this deployment alone. The platform hashes each custodian credential identity under this key, so that it can tell when two organisations hold the same custodian credential. The key grants no access. Never copy it between installs, because the stored hashes derive from credential material.

The chart ships the development placeholder local-custody-credential-identity-key, and a production pod refuses to start on it. CUSTODY_CREDENTIAL_IDENTITY_KEY_ID, default v1, names the key the stored hashes were written under. Rotating the key leaves every stored identity incomparable until they are hashed again under the new key, so raise the key id when you rotate.

Ask AI provider key

Ask AI is the question-and-answer chat on the documentation site that the docs subchart serves under /docs on the Console hostname. It turns on when the documentation pod holds an OpenRouter API key. OpenRouter is a hosted service that routes requests to language models. The browser sends the question to the documentation server, and only the server calls OpenRouter, so the key never reaches the browser. Each call sends the reader's conversation and the documentation passages the server found for it to OpenRouter, which runs it on a DeepSeek model that the platform sets. The key does not choose the model. The investor documentation site has no Ask AI and receives no key.

The chart gives the pod the key as the OPENROUTER_API_KEY environment variable, read from one entry of a Kubernetes Secret. docs.openRouter.existingSecret names the Secret, and docs.openRouter.existingSecretKeys.apiKey names the entry. They default to the release's shared environment Secret and its ASSISTANT_OPENROUTER_API_KEY entry, which the chart renders from global.dalpApp.env. Supply global.dalpApp.env.ASSISTANT_OPENROUTER_API_KEY at deploy time from your secret store, as you do for the other secrets in that block, and keep it out of version control. That one value turns on Ask AI and also lets the Console assistant answer for organisations that have no key of their own.

Anyone who can reach the documentation can ask questions, and each answer spends the key's credit. The platform admits at most 10 questions a minute per client and 300 an hour in total. It tells clients apart by the address that your routing layer passes on.

The chart sets DOCS_CHAT_TRUSTED_PROXY to envoy, nginx or openshift only when the documentation Service is ClusterIP and exactly one of docs.ingress.enabled, docs.httpRoute.enabled and docs.openShiftRoute.enabled is true. Otherwise it sets none, and all readers share one allowance of 10 questions a minute. The default install turns on both ingress and HTTP route, so it uses none.

To override the value, set DOCS_CHAT_TRUSTED_PROXY in docs.env, and choose envoy, nginx or openshift only when every public path to the documentation goes through that routing layer. Any other value makes every Ask AI, search and Model Context Protocol request to the documentation fail.

The chart gives every documentation pod the release's Redis store when global.dalpApp.env holds REDIS_URL, so every count then covers all pods. A pod without a Redis store keeps its own counts, so each pod admits 10 questions a minute per client and 300 an hour.

Use a separate key when a different team pays for documentation questions or should cap them in its own OpenRouter account. You can supply it in one of two ways:

  • Create a Secret in the release namespace, point docs.openRouter.existingSecret at it, and set docs.openRouter.existingSecretKeys.apiKey to the entry that holds the key.
  • Set docs.openRouter.existingSecret to an empty string and supply docs.openRouter.apiKey at deploy time from your secret store, as you do for global.dalpApp.env.ASSISTANT_OPENROUTER_API_KEY. The chart then renders a Secret that holds the key under the entry named by docs.openRouter.existingSecretKeys.apiKey, and the pod reads it from there. With the default docs.fullnameOverride, the Secret is named docs-openrouter-key.

Only one of docs.openRouter.existingSecret and docs.openRouter.apiKey can hold a value, and helm install and helm upgrade fail when both do. docs.openRouter.existingSecret holds the name of the shared environment Secret by default, so set it to an empty string whenever you set docs.openRouter.apiKey.

helm install and helm upgrade also fail when docs.openRouter.apiKey is set and docs.openRouter.existingSecretKeys.apiKey is empty, because the key then has no entry to hold it. The pod specification holds a reference to the Secret and never the key. With the second way, the chart-created Secret and the Helm release record hold the key.

The release record holds every other secret in your values file the same way.

The documentation pod does not run summon, so a Conjur secrets.yml mapping does not reach it, and a key that exists only in Conjur leaves Ask AI off. The pod reads the key from a Kubernetes Secret: one of the two Secrets in the list, or the shared environment Secret that global.dalpApp.env.ASSISTANT_OPENROUTER_API_KEY fills.

To keep Ask AI off while the Console assistant keeps its key, set docs.openRouter.existingSecret to an empty string and leave docs.openRouter.apiKey empty:

docs:
  openRouter:
    existingSecret: ""

If your values set an OPENROUTER_API_KEY entry in docs.env, helm install and helm upgrade fail. Remove the entry. With the default values, the pod then reads the shared key, or you can supply a separate key in one of the two ways in the list.

With no key, an empty key, a missing Secret, or a Secret without the named entry, Ask AI stays off and the pod still starts. The chat route at /docs/api/chat then answers 404, the documentation pages hide every Ask AI entry point, and search keeps working because the documentation pod serves it. The pod makes no call to OpenRouter.

To check the state, open /docs/api/chat-config on the Console hostname. The response is {"enabled":true} when the pod holds a key and {"enabled":false} when it does not. The check proves only that the pod holds a key. A key that OpenRouter rejects still reports enabled, and each answer then ends with “The answer could not be completed. Try again.” A pod reads the variable when it starts. Restart the documentation pods after you set or change the key, unless your cluster already restarts pods when a Secret changes.

When an answer fails, the documentation pod logs Ask AI provider call failed. Its stage field says where the call stopped, and error_code carries the network error code when there is one. no-response means the pod got no HTTP response from OpenRouter, so check the pod's outbound network and DNS. first-chunk and between-chunks mean OpenRouter answered but sent no answer text in time, and total means the answer ran past 30 seconds. response means OpenRouter answered and the call then failed. provider_status carries the HTTP status when OpenRouter refused the call, for example 401 for a rejected key or 429 for a rate limit.

After a no-response failure, the pod tests whether it can reach OpenRouter and logs each step as an Ask AI provider host probe line, up to 20 seconds after the failure line. A step that worked logs at info level and a failed step at error level. The line with stage provider-host-lookup records whether openrouter.ai resolved to an address.

When it did, the pod opens a TCP connection on port 443 to the first IPv4 address and the first IPv6 address that the lookup returned and closes it at once. It logs a provider-host-connect line for each, with address_family set to ipv4 or ipv6. Every line carries elapsed_ms and timed_out, which is true when the step ran past 10 seconds.

A step that failed for another reason carries error_code, which is no_address when openrouter.ai has no address of that family.

The probe connects directly and does not use a proxy set in the pod's environment, so when the pod reaches the internet through a proxy, failed provider-host-connect lines are expected and say nothing about the proxy. Without a proxy, when both provider-host-connect lines fail, the pod could not open a connection to OpenRouter on port 443, so check the egress rules that apply to the documentation pods.

When the ipv4 line succeeds, the pod can open a TCP connection to OpenRouter on port 443. The probe does not test TLS or HTTP, so a rule that filters those can still block the call. A failed ipv6 line next to it does not explain the failure, because the pod's HTTP client falls back to IPv4 when IPv6 does not connect.

Count readers separately behind Cloudflare

Behind Cloudflare, Envoy Gateway sees a Cloudflare address instead of the reader's address, so readers who arrive through the same Cloudflare address share one budget. To count each reader separately, have the Gateway trust the entry Cloudflare adds to X-Forwarded-For, keep that entry last, remove any x-envoy-external-address a request carries, and let only Cloudflare connect to the load balancer.

When DOCS_CHAT_TRUSTED_PROXY is envoy, the documentation server counts each reader by the x-envoy-external-address header. When that header is missing or does not hold a valid IP address, the server uses the last entry of X-Forwarded-For. It never reads an earlier entry, because a client can write every entry before the one Cloudflare adds.

Ask AI, answer ratings, search, the docs unlock form and the Model Context Protocol endpoint all count requests by that address. The chart sets envoy when docs.service.type is ClusterIP, docs.httpRoute.enabled is true, and docs.ingress.enabled and docs.openShiftRoute.enabled are false. The DALP chart turns docs.ingress.enabled on by default, so set it to false.

Otherwise set DOCS_CHAT_TRUSTED_PROXY to envoy in docs.env, which is safe only when every public path to the documentation goes through Envoy Gateway. With global.dalpApp.config.trustedProxy.profile set to envoy, the Console, the Investor portal and the Platform API read the reader's address by the same rule, and the Platform API records the address in its request audit trail.

Cloudflare adds the reader's address as the last entry of the X-Forwarded-For header before it connects to your load balancer. Configure the Gateway to trust exactly that one entry, and allow only Cloudflare to connect to the load balancer. Apply both settings together. If the load balancer accepts connections from any address, a caller that connects directly can put any address in X-Forwarded-For and get a new budget with each request.

With xForwardedFor set, Envoy Gateway no longer sets x-envoy-external-address, so a value that a caller sent reaches the pods unchanged. It also adds Cloudflare's address to the end of X-Forwarded-For unless disableXForwardedForAppend is true.

The policy in the example sets disableXForwardedForAppend to keep the reader's address last, and removes any incoming x-envoy-external-address so that a caller cannot pick its own address. disableXForwardedForAppend needs Envoy Gateway 1.9 or later. The documentation server, the Console, the Investor portal and the Platform API then read the reader's address from the last entry.

They read only that entry, so they see each reader's own address only when Cloudflare connects straight to the load balancer. If another proxy that adds to X-Forwarded-For sits between them, readers behind one Cloudflare address share a budget again.

Set clientIPDetection and headers in the ClientTrafficPolicy that targets your Gateway. Envoy Gateway applies the oldest policy when two policies target the same Gateway, so add clientIPDetection and the headers.earlyRequestHeaders.remove entry to the existing policy instead of creating a second one. A policy that targets one listener through sectionName replaces the Gateway-wide policy for that listener, so add them there too. If no policy targets the Gateway, create one like this:

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
  name: client-ip
  namespace: <gateway-namespace>
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: <gateway-name>
  clientIPDetection:
    xForwardedFor:
      numTrustedHops: 1
      disableXForwardedForAppend: true
  headers:
    earlyRequestHeaders:
      remove:
        - x-envoy-external-address

Restrict the load balancer Service in the EnvoyProxy resource that your Gateway or GatewayClass references through parametersRef. If neither references one, create it and point spec.infrastructure.parametersRef on the Gateway at it. A GatewayClass-level EnvoyProxy applies to every Gateway of that class. If other Gateways must stay reachable without Cloudflare, give this Gateway its own EnvoyProxy.

In an existing EnvoyProxy, add only the envoyService fields in the example. The list holds Cloudflare's IPv4 and IPv6 ranges as published in November 2026 at https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6. Compare it with those lists before you apply it, and update it when Cloudflare changes them.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
  name: <proxy-name>
  namespace: <gateway-namespace>
spec:
  provider:
    type: Kubernetes
    kubernetes:
      envoyService:
        type: LoadBalancer
        externalTrafficPolicy: Local
        loadBalancerSourceRanges:
          - 173.245.48.0/20
          - 103.21.244.0/22
          - 103.22.200.0/22
          - 103.31.4.0/22
          - 141.101.64.0/18
          - 108.162.192.0/18
          - 190.93.240.0/20
          - 188.114.96.0/20
          - 197.234.240.0/22
          - 198.41.128.0/17
          - 162.158.0.0/15
          - 104.16.0.0/13
          - 104.24.0.0/14
          - 172.64.0.0/13
          - 131.0.72.0/22
          - 2400:cb00::/32
          - 2606:4700::/32
          - 2803:f800::/32
          - 2405:b500::/32
          - 2405:8100::/32
          - 2a06:98c0::/29
          - 2c0f:f248::/32

Source ranges apply only to the load balancer address. Check that your cloud provider enforces loadBalancerSourceRanges, and block the Service's node ports from outside the cluster.

When DOCS_CHAT_TRUSTED_PROXY is envoy, nginx or openshift and a request carries no address the server can use, the request counts against one budget that all such requests share. The documentation pod then logs Docs client address unavailable.

Its envoy_external_address_present field says whether the request carried x-envoy-external-address, and forwarded_for_present whether it carried X-Forwarded-For. Each is true or false, and the line holds no header value. If these lines appear, check that every public path to the documentation goes through the proxy that DOCS_CHAT_TRUSTED_PROXY names.

That is Envoy Gateway for envoy, the NGINX ingress for nginx and the OpenShift router for openshift.

Documentation host

The documentation pod writes one host into the absolute links of its llms.txt and llms-full.txt indexes, the markdown export of each page, the canonical and Open Graph tags of each page, and the URLs that its Model Context Protocol server returns. AI agents follow those links, so they must point at the host that serves your copy of the documentation. The chart passes the host to the pod as the PUBLIC_URL environment variable.

With the default values the documentation runs under /docs on the Console hostname, and the chart takes that host from the first docs.ingress.hosts entry. That entry ships as dapp.k8s.orb.local with the ingress class dalp-nginx, both development values, so set docs.ingress.hosts to your Console hostname and docs.ingress.ingressClassName to your class. The OpenShift profile serves the documentation on its own Route host and takes the host from docs.openShiftRoute.host. Set docs.publicUrl only to override the host the chart picks.

Give docs.publicUrl as a scheme and a host with no path, such as https://console.example.com. The site adds the /docs path itself. The chart's values schema rejects a value with a path, so helm install, helm upgrade and helm template fail on one.

When docs.publicUrl is empty, which is the default, the chart uses https:// and the first host it finds:

  1. The first host in docs.ingress.hosts, when docs.ingress.enabled is true.
  2. The first entry in docs.httpRoute.hostnames, when docs.httpRoute.enabled is true.
  3. docs.openShiftRoute.host, when docs.openShiftRoute.enabled is true.

The documentation chart enables its HTTP route by default with the hostname docs.settlemint.com. An install that turns off docs.ingress and keeps this HTTP route must set docs.httpRoute.hostnames or docs.publicUrl, or the links point at the public documentation host, docs.settlemint.com. With no enabled route and no docs.publicUrl, the chart sets no PUBLIC_URL, and the links also point at docs.settlemint.com.

A PUBLIC_URL entry in docs.env replaces the value the chart works out. The schema does not check this entry, so give it a scheme and a host with no path.

Documentation served from the Console

The Console also answers /docs on its own hostname. When a /docs request reaches the Console instead of a documentation route, the Console forwards it to the documentation service and returns the response, so the reader stays on the Console hostname. A request for /docs itself is redirected to /docs/operators/introduction. The chart builds the Console configuration from global.dalpApp.config, and the Console reads two keys under global.dalpApp.config.docs to decide where to forward. The chart sets neither key.

Key
What it does
global.dalpApp.config.docs.internalUrl
The address the Console forwards to. When it is empty the Console uses global.dalpApp.config.docs.url, and when both are empty it uses http://docs:3030.
global.dalpApp.config.docs.proxyAllowedHosts
A comma-separated list of host names. The Console forwards only to the host docs, a host on this list or a host name that ends in .localhost. The default is an empty list.

The address must use http or https, and the Console keeps only its scheme, host and port. An address with another scheme, or with a host the list does not admit, is ignored and the Console forwards to http://docs:3030. To forward to a documentation service under another name, set internalUrl to its address and add its host name to proxyAllowedHosts.

The Console removes the authorization, cookie and x-api-key headers from each request before it forwards it, so a Console session credential never reaches the documentation service. It also removes set-cookie from the response, so the documentation service cannot set a cookie on the Console hostname.

Frequently asked questions

Can I supply a datastore password through a plain Kubernetes Secret instead of Conjur?

Yes. Each postgresql and redis block under global.datastores accepts its own existingSecret, which the chart reads as an ordinary Kubernetes Secret, with or without Conjur. The application's other secrets, such as the encryption master password, reach the pods through the shared Secret the chart renders from global.dalpApp.env, or through Conjur and summon when you switch Conjur on.

If I set the application's DATABASE_TLS value while pooling is enabled, does it do anything?

No. The platform ignores it and raises no error. When global.pgbouncer.enabled is true, the application connects to the pooler's plaintext in-cluster listener with its own TLS setting turned off. DATABASE_TLS takes effect only on direct connections, such as the migrator's, or on every connection when pooling is off.

Which model provider keys does Ask AI accept?

Ask AI accepts only an OpenRouter key. A key from another provider still makes /docs/api/chat-config report enabled, and every answer then ends with “The answer could not be completed. Try again.” The platform chooses the model, and the key does not change it. Ask AI provider key shows where to set the key.

Can the documentation run without internet access?

Yes, apart from Ask AI and the YouTube video on the DALP 3.0 changelog page. The documentation pod serves pages and search itself, so neither needs internet access. Without internet access, set docs.openRouter.existingSecret to an empty string and leave docs.openRouter.apiKey empty, because with a key present Ask AI stays visible and every answer fails. The bundled documentation loads no third-party analytics script, because the site loads those scripts only when docs.leadCapture.enabled is true. The setting is false by default and the platform chart does not set it. /docs/api/site-config reports it as leadCapture.

Does postgres-exporter route through PgBouncer like the application does?

No, it connects directly to Postgres. With it off you lose the aggregate backend-count view and PostgresAggregateBackendsHigh, while the pods' own pool-pressure gauges keep working.

Was this page helpful?