# Utila signing agent Helm values

Source: https://docs.settlemint.com/docs/deployment/utila-signing-agent
Deploy the customer-run Utila signing agent beside DALP: chart values, image, state, keys, and the network path that keeps the decision route internal.

Utila signs a DALP transaction only after a co-signer you run approves it. The Helm chart can run that co-signer, called the signing agent here, beside DALP. In its default mode the agent asks DALP before it signs, and DALP answers yes only when the held request matches the request DALP filed. The agent adds no judgement of its own: it catches a request changed after DALP filed it, not a request DALP should not have filed, so approval by a person belongs in your Utila policy. The agent is off by default. Before you enable it, you need a Utila provider configuration, a device id from registration, an image you built and a state store.

Use this page to render the workload and to set its values. For enrolment, key rotation and incident work, use the [Utila signing agent runbook](https://docs.settlemint.com/docs/deployment/runbooks/utila-signing-agent).

Utila ships switched off. Switch it on with `global.dalpApp.config.custody.providers.utila.enabled: true`, then add the Utila configuration in the Console with its "Signing agent secret", as [Utila setup](https://docs.settlemint.com/docs/deployment/utila-setup) describes. The agent answers only for a Utila provider configuration that holds that secret, so the agent alone signs nothing for DALP.

[Figure] The agent reaches DALP from inside the cluster, and the public edge never routes the decision route. Inside one cluster, the signing agent pod has an accent arrow to the DALP API Service. The arrow is labelled decision request. The agent also has a dashed arrow leaving the cluster to the Utila API, which holds the transactions it polls. Outside the cluster, a public edge box shows the paths it publishes, the API path and the DALP path, with an arrow to the DALP API Service. A crossed note says the internal path is not published, so a request from outside the cluster for the decision route is never routed. Labels: Your cluster; Utila co-signer pod; Signing agent; One replica; Serves nothing; API Service; DALP; Decides, never signs; asks; Network policy: no ingress to the agent.; Egress only to DNS, the DALP API pods, and the rules you add.; Public edge; Publishes the API paths; Utila; Holds the transactions; The edge does not route; the decision route.

The agent calls DALP over the cluster network, and the public edge never routes the decision route, so outside requests cannot reach it.

## What the chart renders [#what-the-chart-renders]

The `utilaSigningAgent` block in `values.yaml` is off by default. When you enable it, the chart renders these objects in the release namespace:

| Object | What it does |
| --- | --- |
| Deployment | One replica with the `Recreate` strategy. Two agents on one key share are unsafe, so the old pod stops before the new one starts. |
| ServiceAccount | No API token is mounted into the pod. |
| PodDisruptionBudget | `maxUnavailable` from `utilaSigningAgent.podDisruptionBudget.maxUnavailable`. The default is `0`. |
| NetworkPolicy | No ingress. Egress to DNS, the DALP API pods and the rules you add. |
| PersistentVolumeClaim | The agent state, when `state.location` and `state.persistence.existingClaim` are both empty. Kept on upgrade and uninstall. |
| Secret | Only when `signingAgentSecret.create` is `true`. Use it outside production only. |

The chart renders no Service for the agent, and no Ingress, HTTPRoute or Route. The agent polls Utila and calls DALP, and nothing calls the agent.

## Build the image [#build-the-image]

The chart never builds or pulls a public image. Build the image yourself from Utila's distroless Dockerfile with the Utila CLI binary from the Utila Resource Centre, then push it to a registry your cluster trusts and record the digest.

Utila's Dockerfile copies the CLI binary into a distroless non-root image and sets it as the entrypoint. The chart passes `cosigner run` as arguments to that entrypoint. The Utila co-signer guide asks for CLI version 1.5.0 or later.

Set both `image.repository` and `image.digest`. The chart refuses to render without them. An upgrade changes the digest. The new pod starts against the existing state and does not enrol again.

## Set the values [#set-the-values]

These values are required when `utilaSigningAgent.enabled` is `true`:

| Value | Meaning |
| --- | --- |
| `image.repository`, `image.digest` | The image you built. |
| `organizationId` | The DALP organisation id of the Utila provider configuration. |
| `providerConfigurationId` | The id of that provider configuration. |
| `vaultId`, `deviceId` | The Utila vault and the device id printed at registration. |
| `account` | The Utila service account email, passed as `UTILA_ACCOUNT`. |
| `serviceAccountKey` | `kmsKeyUrl`, or `privateKeySecret.name`. |
| `signingAgentSecret.existingSecret` | The Secret that holds the agent secret. Required in webhook-decision mode, unless `signingAgentSecret.create` is `true`. |
| `state.encryptionProvider` | How Utila encrypts the state. |
| `networkPolicy.egress` | Egress rules for the Utila API and your KMS or object store. Required while `networkPolicy.enabled` is `true`. |

`dapiInternalUrl` defaults to `http://dalp-api:3001`, the Service of the API pods for a release named `dalp`. Change it when your release has another name or your API Service sits on another port. It must not be empty in webhook-decision mode.

### Default invocation [#default-invocation]

The default arguments run the co-signer in webhook-decision mode. The chart adds the vault id, the device id and the decision address to the arguments `cosigner run`, so the pod runs the equivalent of:

```shell
utila cosigner run --vault-id <vault-id> --device-id <device-id> \
  --webhook-url <dapiInternalUrl>/internal/custody/utila/signing-decision/<organization-id>/<provider-configuration-id>/$(SIGNING_AGENT_SECRET)
```

The agent secret is the last segment of the address. Kubernetes expands `$(SIGNING_AGENT_SECRET)` from a Secret reference, so the secret never sits in values or in a ConfigMap. Anyone who can read the pod's process arguments can read the expanded secret. Restrict `pods/exec` and pod read access in the namespace, and never log the address. The Platform API records no request span for any request on the decision address, however the request spells it, in upper case, with doubled slashes or with percent-encoding, so the address and the secret in it do not reach the trace store.

The command and flags follow the Utila co-signer setup guide, which shows `utila cosigner run --vault-id <vault-id> --device-id <device-id> --webhook-url <url>`. Utila's Dockerfile names its binary `utilacli` in the build context. Check the flags against the CLI version you build, and use `command`, `args` and `extraArgs` to adapt the invocation.

### Sign-all mode [#sign-all-mode]

`mode: sign-all` runs the co-signer with `--sign-all-txs`. It signs every transaction designated to the service account without asking DALP. That makes the agent a signing oracle for the whole service account. Use it for first-contact testing in a vault with no value, then set `mode` back to `webhook-decision`. The chart adds a pod annotation and a warning to the Helm notes while it is set.

## State and keys [#state-and-keys]

The agent keeps its device key and key shares in a state store, encrypted with the provider you name in `state.encryptionProvider`. Utila documents `awskms://...`, `gcpkms://...` and `passphrase`. A passphrase comes from the Secret in `state.passphraseSecret`.

| Choice | Value | Notes |
| --- | --- | --- |
| Chart volume | `state.location` empty | A PVC mounted at `/state`. Back it up. |
| Object storage | `s3://bucket` or `gs://bucket` | No PVC is rendered. Use it on OpenShift, as [OpenShift](#openshift) explains. |

If you lose the state, the agent cannot sign. Recovery is a new enrolment: register a new device, have the vault admins approve it in the Utila mobile app, and download a new key share. Plan a backup policy for the state store that matches how quickly you must recover. Your recovery time is the time to restore the state store, or to enrol a new device and have the vault admins approve it.

The service account key has two sources. Prefer `serviceAccountKey.kmsKeyUrl`, which sets `UTILA_SA_KMS_KEY_URL`. The fallback `privateKeySecret` sets `UTILA_SA_PRIVATE_KEY` from a Secret. With the fallback, the key sits in the pod environment, so anyone who can read pod specs with secret access or exec into the pod can read it.

Annotate the agent's ServiceAccount through `serviceAccount.annotations`: `eks.amazonaws.com/role-arn` for IAM roles for service accounts on EKS, `iam.gke.io/gcp-service-account` for GKE Workload Identity.

The Utila CLI talks to `api.utila.io` by default. For another Utila environment, set `UTILA_HOST` in `extraEnv`.

These `UTILA_*` names are Utila's own. They are separate from the credentials you enter for the Utila provider in the Console.

## Availability [#availability]

The agent runs one replica. While it is down, no Utila transaction that DALP filed gets signed, and DALP writes through Utila wait.

The PodDisruptionBudget defaults to `maxUnavailable: 0`. A node drain that would evict the agent is blocked until you act, so writes pause only when you choose to. Set `podDisruptionBudget.maxUnavailable` to `1` to let a drain proceed. The agent then stops, and designated work waits in `AWAITING_SIGNATURE` until the pod is back.

Kubernetes has no health signal for the agent. The Utila documentation lists no liveness endpoint or command for the co-signer, so the chart defines no probe. A hung agent looks healthy to Kubernetes. The health check is the work itself: alert when work DALP designated to the service account stays in `AWAITING_SIGNATURE` longer than the bound you accept. The [runbook](https://docs.settlemint.com/docs/deployment/runbooks/utila-signing-agent) says what to do when the alert fires.

This page gives no detection time for an unreachable DALP endpoint, because DALP has not measured it against a live Utila vault. The Utila documentation says an unreachable endpoint leaves transactions pending and the co-signer asks again on the next poll.

## Network policy [#network-policy]

The chart renders a NetworkPolicy for the agent pod, on by default. It allows no ingress. Egress goes to:

- DNS.
- The DALP API pods on the port set in `dapi.service.httpPort`.
- Any rule you add in `networkPolicy.egress`.

Add egress rules for the Utila API and for your KMS or object store. Without them the agent cannot poll Utila or open its state. The agent's pod carries the label `app.kubernetes.io/component: utila-signing-agent`. The DALP API NetworkPolicy admits pods of the same release with that label. The entry matches nothing unless the chart renders the agent pod.

The decision route lives under `/internal`, not under `/api`. No Ingress, HTTPRoute or OpenShift Route published by the chart carries an `/internal` path, and the Console does not proxy it. With the default `/api` and `/dalp` edge paths, a request from outside the cluster cannot reach the route. Do not publish `/` or an unset path at the edge, because either covers `/internal` too.

## OpenShift [#openshift]

`values-openshift.yaml` pins the agent resources and leaves the user and group ids unset, so the restricted security context constraint assigns them. The pod takes its security context from the same shared settings as the other chart workloads and runs with a read-only root filesystem. The overlay clears `fsGroup`, so a PVC root can be unwritable under the assigned user id. On OpenShift, point `state.location` at object storage.

## Verify the render [#verify-the-render]

1. Run `helm template` with the agent enabled and the values above. Confirm a Deployment, a PodDisruptionBudget and a NetworkPolicy, and no Service, Ingress, HTTPRoute or Route that names the agent.
2. Confirm the decision address in the rendered arguments ends in `$(SIGNING_AGENT_SECRET)` and the secret value appears in no rendered object except a chart-created Secret.
3. Render once without `image.repository`. The render must fail with the message that names it.
