# Manifest reference

> Reference for manifest.yaml, with an annotated example, per-chart value overrides, object storage, and a custom CA bundle

`manifest.yaml` is the single input that drives every shdctl operation. Every command checks it against an embedded CUE schema, but only `shdctl manifest validate` rejects a key the schema does not define: every other command drops a misspelled key without a word and renders that field's default. Run `shdctl manifest validate` before `shdctl release generate`, as step 0 of the [recommended pipeline](./workflows.md#pipeline-steps) does.

**Commit it to version control.** The manifest acts as a controlled input for release upgrades: bumping `releaseVersion` and any other field, then rerunning `shdctl release pull --clean-output` and `shdctl release generate`, produces a reviewable diff your team can approve before deployment, and ArgoCD or your CD picks it up from there.

**The manifest contains no secrets.** Secrets are generated separately by `shdctl secret generate` from `secrets.import.yaml`, which lives in your CI secret store or a vault, never in Git. If that file is ever lost, `shdctl secret export` rebuilds it from the secrets already in the cluster.

## Initialize a manifest

If you don't already have a manifest, generate one interactively with [`shdctl manifest init`](./commands/manifest.md#init).

## Validate a manifest

Check a manifest against the embedded CUE schema with [`shdctl manifest validate`](./commands/manifest.md#validate). It is the only command that also rejects keys the schema does not define.

## Annotated example

A Self-Hosted Deployment manifest with the most common fields looks like this. Keep `configuration.kubernetes.storage` and the `configuration.networking.ingress.traefik` block even though the schema marks them optional: `shdctl release generate` fails without them. A manifest written for an earlier release can also hold a `platform:` line: it's ignored, and shdctl warns about it until a future release rejects it, so delete it.

```yaml
# manifest.yaml
releaseVersion: 2.0.0                  # required: matches the release tag in Unity's registry

artifactSync:
  # Set sourceRepository: `shdctl manifest schema` lists this value as its default,
  # but every `artifact sync` subcommand fails when the field is left out.
  sourceRepository: uccmpprivatecloud.azurecr.io
  concurrency: 5                       # parallel `artifact sync` workers. Leave it out and
                                       # the sync runs one at a time (`verify`: four at a time)

deployment:
  helm:
    concurrency: 4                     # optional, default 1; deploy charts within the same wave
                                       # in parallel (helm format only). Waves remain sequential.
                                       # The `--concurrency` CLI flag overrides this when both are set.
  argocd:                              # defaults for `release generate --format argocd`
    repoURL: "git@github.com:your-org/your-argocd-charts.git"
    pathPrefix: "generated-charts"     # the repo directory the rendered charts are committed
                                       # under, relative to its root. Keep it non-empty: an
                                       # empty prefix breaks every child application
    destinationServer: "https://kubernetes.default.svc"   # required: generation fails without it
    targetRevision: "main"             # the branch ArgoCD reads the charts from

configuration:
  # imageVariant: hardened             # optional. When set, shdctl appends the matching
                                       # suffix (such as -hardened) to image versions that
                                       # declare the variant in versions.yaml. Omit for
                                       # default images.
  networking:
    appDomain: uam.example.com         # the FQDN your users reach the app at
    # ipFamily: ipv6                   # optional. "ipv4" (default) or "ipv6" for
                                       # single-stack IPv6 clusters. Omit for IPv4.
    # Optional. Reference an existing Kubernetes Secret (in the install namespace)
    # whose `ca-bundle.crt` key holds a PEM-encoded CA chain (root + intermediates).
    # Mounted into builtin workflow StorageTool containers so .NET trusts your
    # ingress TLS chain. Required only when your ingress uses a private CA not in
    # the default system trust store, such as an internal corporate or government PKI.
    # trustedCaSecretName: my-ca-bundle
    # IPv4 or IPv6 CIDRs allowed to reach the LoadBalancer. List your cluster's
    # own outbound (NAT egress) addresses alongside your users' and operators':
    # Unity Studio calls this deployment back at its public appDomain, so those
    # calls arrive from the cluster's egress address and are refused if it is
    # not here — Studio then fails with the rest of the deployment healthy.
    allowedIngressCIDRs:
      - "203.0.113.0/24"               # users and operators
      - "198.51.100.10/32"             # the cluster's own NAT egress address
    ingress:
      traefik:
        type: LoadBalancer
        tls:
          enabled: true
          certificate: traefik-tls-cert  # name of the K8s Secret holding the cert
        # Optional. Pin specific nodePorts for traefik entrypoints. Omit (default)
        # to let Kubernetes auto-assign from the cluster's nodePort range — the
        # assigned port is stable for the life of the Service. Set explicit values
        # only when an external load balancer or firewall rule requires it.
        # nodePorts:
        #   web: 32080         # HTTP entrypoint
        #   websecure: 32443   # HTTPS entrypoint
    serviceMesh:
      istio:
        enabled: false

  kubernetes:
    namespace: asset-solutions          # namespace where workloads are deployed
    dnsService: kube-dns                # CoreDNS Service in kube-system, for example rke2-coredns-rke2-coredns on RKE2
    docker:
      repository: registry.example.com  # your registry (used after `artifact sync`), or
                                        # Unity's registry itself if you pull from it directly
      namespace: asset-solutions        # subpath/namespace within your registry; the schema
                                        # refuses it when pulling from Unity's registry directly
    imagePullSecret:                    # K8s Secret holding registry pull credentials
      name: regcred                     # the older `imagePullSecret: regcred` spelling
                                        # still works, and logs a deprecation notice
      # generate: false                 # `secret generate` builds that Secret by
                                        # default; set false where something else in the
                                        # cluster creates and refreshes it, such as a
                                        # registry token refresher for short-lived tokens
    autoscaling:
      minReplicas: 1                    # the platform services, Keycloak and Traefik: with 1,
                                        # a service is briefly down while its pod moves during a
                                        # node drain; 2 keeps them up through node maintenance
      maxReplicas: 10
    storage:
      defaultStorageClass: gp3          # garage + license server only; other RWO volumes
                                        # use the cluster's default storage class, which
                                        # must exist
      readWriteManyStorageClass: efs
    # Optional. Annotations added to every pod the release creates. Values are
    # strings, so quote "true". To change or drop one for a single chart, use
    # that chart's overrides: at global.podAnnotations for the release's own
    # charts, at the chart's own pod-annotation key for third-party ones; null
    # removes it.
    #
    # Example: AKS with an HTTP proxy (httpProxyConfig) injects
    # HTTP_PROXY/HTTPS_PROXY/NO_PROXY into every pod, and platform services call
    # each other by short Service name, which no NO_PROXY entry matches — so
    # those calls go to the proxy and fail. This annotation makes AKS skip the
    # pod; nodes, and so image pulls, still use the proxy, but pods get no
    # internet access. The automation-manager job pulls the release manifest
    # from the registry itself, so give it the proxy (unless your registry is
    # reachable without it):
    #   overrides:
    #     automation-manager:
    #       values:
    #         env:
    #           httpsProxy: http://<proxy>:<port>
    #           noProxy: .svc,.cluster.local,localhost,127.0.0.1
    # podAnnotations:
    #   kubernetes.azure.com/no-http-proxy-vars: "true"

  transformations:
    parallelism: 30                     # max concurrent transformation workflows

  monitoring:
    database:
      enabled: true
    prometheus:
      enabled: true                     # installs a Prometheus stack. If the cluster already
                                        # runs the Prometheus Operator, set enabled: false and
                                        # installedInCluster: true to add only ServiceMonitors
    logCollection:
      enabled: true                     # Loki + Alloy

  authentication:
    x509:
      # Mandatory mutual TLS (mTLS): every client reaching the deployment must
      # present a client certificate. Unity Studio's publisher has none, so
      # Studio publishing does not work while this is enabled in this release.
      enabled: false
      # caSecretName: x509-ca-cert      # Secret holding the client CA; this is the default

  infrastructure:
    sizing: medium                      # small | medium (default) | large
    # singleNode: true                  # optional: collapse infrastructure components to
                                        # single-replica for ephemeral test clusters.
                                        # Not for production.
    # components:                       # per-component CPU/memory/storage overrides
    #   postgresql:
    #     storage: 400Gi                #   the data volume
    #     backupStorage: 800Gi          #   the pgBackRest repository volume (backups + WAL).
    #                                   #   Default by sizing profile: 100Gi small, 800Gi
    #                                   #   medium, 1600Gi large. It can only grow, which
    #                                   #   needs allowVolumeExpansion on the StorageClass:
    #                                   #   a smaller value is refused. Ignored, with a
    #                                   #   warning, when objectStore.buckets.postgresBackups
    #                                   #   is set

  objectStore:
    provider: garage                    # "garage" (default) deploys the in-cluster
                                        # object store; "s3" uses a bucket you
                                        # own. See "Bringing your own object storage".
    # provider: s3
    # endpoint: https://s3.us-east-2.amazonaws.com   # scheme://host[:port], no path
    # region: us-east-2
    # forcePathStyle: false             # true for MinIO, Ceph RGW, StorageGRID
    # buckets:
    #   transformationLogs: my-transformation-logs   # transformation step logs
    #   mongodbBackups: my-mongodb-backups           # MongoDB nightly dumps
    #   studioAssets: my-studio-assets               # Unity Studio publications — needs a CORS rule + https
    #   postgresBackups: my-postgres-backups         # optional: PostgreSQL backups + WAL

  # overrides:                          # optional last-resort layer of extra Helm
  #   <chart-name>:                     # values, per chart, applied after every layer
  #     values:                         # the release ships. See "Per-chart Helm value
  #       replicaCount: 3               # overrides" below before reaching for it.
```

## Full schema reference

The annotated example above covers the most common fields. For the **complete** field list, including every type, default value, constraint, and cross-field rule, print the schema your installed shdctl is using with [`shdctl manifest schema`](./commands/manifest.md#schema). Three of the defaults it prints are not applied when you leave the field out: `artifactSync.sourceRepository`, `artifactSync.concurrency`, and `deployment.argocd.destinationServer`. The example above sets all three.

## Auto-discovery

shdctl searches upward from the current working directory for `manifest.yaml`. Pass `--manifest <path>` to override: that file must exist and parse, or the command stops. The search is best-effort, so a command that needs no manifest runs without one.

## Per-chart Helm value overrides

Every Helm value shdctl renders comes from the release package: a chart's own `values.yaml`, a platform layer, and the per-chart values the release's chart template sets. `configuration.overrides` adds one more layer on top of those, per chart, under your control:

```yaml
configuration:
  overrides:
    keycloak:                 # must name a chart in the release (see below)
      values:
        replicaCount: 3
```

`shdctl release generate` merges each block into that chart's generated `values.yaml` **last**, so it wins over every layer the release ships. One thing still happens after the merge: on an IPv6 manifest (`configuration.networking.ipFamily: ipv6`) `global.ipFamily` is set to `ipv6` whatever an override says — set the manifest field rather than overriding that key. Reach for it when a chart needs a setting the manifest does not expose — but treat it as a last resort rather than a configuration style. These values are yours to maintain: the manifest schema does not describe them, and a chart's value keys can move between releases. Tell Unity what you had to override, so the setting can become a supported manifest field.

What to know before you use it:

* **A chart name that isn't in the release fails the command.** The definitive list is the directories under `generated-charts/` after a run. In the release package itself, valid names are the `helm_charts` keys of `extracted-release/common/versions.yaml` merged with the `extracted-release/platform/shd/versions.yaml` overlay — most charts are declared only in the common file, so don't judge a name by the overlay alone. A typo stops `shdctl release generate` with `configuration.overrides: unknown chart "..."` instead of silently doing nothing.
* **Only the block's shape is validated, not what's inside it.** `shdctl manifest validate` confirms that `overrides.<chart>.values` is a mapping; it cannot know whether a key exists in the chart. Misspell a key under `values:` and Helm ignores it without complaint. Read the generated `values.yaml` — or its diff, if you commit `generated-charts/` to Git — before deploying, and re-check your overrides after every release upgrade.
* **Maps deep-merge, and keys you don't name are left alone.** `settings: {logLevel: debug}` changes `logLevel` and keeps every sibling under `settings`.
* **`null` removes a key**, whatever it holds — a scalar, a whole map, a whole array. It is dropped from the generated `values.yaml`. That file *is* the chart's root `values.yaml`, so the chart sees no value at all — unless the key belongs to a subchart, whose own default then takes over, since those `values.yaml` files ship alongside. (An empty map `{}` is not a removal: it merges into the existing map and changes nothing. An empty array `[]` does replace the array.)
* **Listing an array replaces the whole array.** To change one element instead, write a mapping where the array sits, keyed by the element's name:

  ```yaml
  configuration:
    overrides:
      elasticsearch-eck:
        values:
          nodeSets:
            master-nodes:
              podTemplate:
                spec:
                  containers:
                    elasticsearch:
                      resources:
                        limits:
                          cpu: "2"
  ```

  The names depend on your topology — a single-node deployment has one nodeSet named `single-node`, not `master-nodes` — so read the names your release actually produced from the generated `values.yaml`. A key matches the element whose `name` it equals, or whose `metadata.name` it equals for elements shaped like Kubernetes objects; for an array whose elements have no name, use the index. An element that is a plain value — a string or a number — can be removed by index but not changed; to change one, replace the whole array. Only a mapping patches an array: a list written where the array sits replaces it outright, as above, and a scalar is ignored — leaving the array as the release shipped it. Elements you do not name are untouched, order is preserved, and arrays nested inside an element are patched the same way rather than replaced — so changing one field of a container or a volume claim does not mean restating it. A key naming an element the array does not contain **fails** `shdctl release generate`, and the error lists the names it found. To remove an element, set it to `null`; to add one, replace the whole array. A `null` naming an element the array does not contain fails the same way, so a removal does not quietly become a no-op once a later release drops the element itself.

  That failure needs the array to still be there. If a release removes or renames the array itself, there is nothing left for your mapping to patch — the key is simply not in the release's values — so the mapping is written into the generated `values.yaml` as an ordinary map and Helm ignores it in silence. Nothing errors, because the syntax cannot tell a patch that an upgrade left stale from a map the chart genuinely reads: one more reason to read that file and re-check your overrides after every release upgrade.
* **The `_arrayMerge` key is not accepted.** Earlier versions documented it for merging lists; under `values:`, it now stops `shdctl release generate`. Write the mapping form above instead.

## Bringing your own object storage

The deployment needs S3-compatible object storage for three things: the logs of
each transformation step, the nightly MongoDB backups, and the publications Unity
Studio writes. By default it deploys an in-cluster store for all three, which
needs no configuration and no outbound access — this is the only option on a
cluster that cannot reach anything outside itself. PostgreSQL's backups are a
fourth, optional consumer: they go to a PersistentVolume unless you name a bucket
for them.

`configuration.objectStore.provider: s3` uses buckets you own instead. The
in-cluster store is then not deployed at all.

```yaml
configuration:
  objectStore:
    provider: s3
    endpoint: https://s3.us-east-2.amazonaws.com
    region: us-east-2
    forcePathStyle: false
    buckets:
      transformationLogs: my-transformation-logs
      mongodbBackups: my-mongodb-backups
      studioAssets: my-studio-assets
      postgresBackups: my-postgres-backups
```

| Field                        | Required on `s3`     | What it is                                                                                                                                                                                                    |
| ---------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `provider`                   | —                    | `garage` (default) for the in-cluster store, `s3` for your own.                                                                                                                                               |
| `endpoint`                   | yes                  | `scheme://host[:port]`, nothing more. See the constraints below.                                                                                                                                              |
| `region`                     | yes                  | The region string the store expects — for AWS, the buckets' region. Stores that ignore it still need a non-empty value.                                                                                       |
| `forcePathStyle`             | no (default `false`) | `true` for stores that address buckets by path rather than by hostname — MinIO, Ceph RGW, StorageGRID. Only partly wired in this release; see the limitation below.                                           |
| `buckets.transformationLogs` | yes                  | Bucket for the per-step logs a transformation writes. Not application logs.                                                                                                                                   |
| `buckets.mongodbBackups`     | yes                  | Bucket for the nightly MongoDB dumps.                                                                                                                                                                         |
| `buckets.studioAssets`       | yes                  | Bucket for the publications Unity Studio writes. The only one a browser reaches directly, so the only one needing a CORS rule, and it needs an `https` endpoint — see below.                                  |
| `buckets.postgresBackups`    | no                   | Bucket for the pgBackRest repository — PostgreSQL full backups and archived WAL. Naming it moves that repository off its PersistentVolume, which is then no longer provisioned. Requires an `https` endpoint. |

Each key names one dataset with one producer, rather than a shared pool — so a
bucket you point at `transformationLogs` holds nothing else, and a later dataset
would arrive as a key of its own rather than moving into these.

`postgresBackups` is the one bucket key that is optional, because pgBackRest
cannot use an S3 repository over plain HTTP: a store reachable only over `http`
has to be able to adopt this provider for step logs and MongoDB backups while
PostgreSQL stays on its volume. Naming it with an `http` endpoint fails
generation rather than silently leaving the bucket unused. Switching an existing
deployment does not copy the volume's backup history — a fresh chain starts in
the bucket — and with no local repository, WAL archiving depends on the store
being reachable; pgBackRest spools WAL on the data volume meanwhile.

**Create the buckets before you deploy,** together with one access key pair that
can read, write and delete objects in **every** one you name — backup retention
deletes the dumps it ages out, and pgBackRest deletes the backups and WAL it
expires. `shdctl secret generate` prompts for that access key ID and secret access
key on this provider rather than generating them: a generated key could never
authenticate against your store. A key scoped to only some of the buckets passes
`secret generate` cleanly and then fails much later, the first time a backup or a
transformation runs. A `--use-defaults` run with no `--import` file leaves the
pair at the `TBD` placeholder and only warns, so that snapshot must not be
deployed as-is.

**`buckets.studioAssets` needs a CORS rule, and no other bucket here does.** Unity
Studio uploads and downloads publications straight from that bucket over presigned
URLs, so the browser makes a cross-origin request from your Asset Manager domain to
your object store. Allow origin `https://<your appDomain>` — the
`configuration.networking.appDomain` from your manifest — with methods `GET`, `PUT`
and `HEAD`, all request headers (`*`), and expose the `ETag` header. All four parts
matter: a rule that allows no request headers refuses the preflight for an upload
carrying `Content-Type`, which fails the same way as having no rule at all. Nothing
in shdctl can check this for you, and the failure is invisible server-side: the
publisher returns 201 and logs nothing while the browser is blocked, so a green
deployment is not evidence the rule is right. Publish an asset and confirm it comes
back.

**`buckets.studioAssets` also needs an `https` endpoint.** The browser follows the
presigned URL itself, so an `http://` endpoint makes it an unencrypted request from
a page served over `https://<your appDomain>` — mixed content, blocked by every
current browser. shdctl does not refuse the combination the way it refuses `http`
with `buckets.postgresBackups`, so it fails only in the browser, with a correct CORS
rule in place. A store reachable only over `http` cannot serve Studio publishing in
this release; the other buckets are unaffected.

**Endpoint constraints.** The endpoint is `scheme://host[:port]` and nothing else:
no path, no query string, no fragment, and no `user:pass@`. All of those, and a
missing scheme, fail `shdctl manifest validate` — the earliest check — and are
refused again by `shdctl release generate`, which is the gate that still applies if
you skip validation. They are refused rather than accepted-and-dropped: the
component that *writes* step logs takes a host and port with no scheme and no
path, so an endpoint with a path prefix would configure backups correctly and
silently break step-log uploads, and an endpoint with credentials in it would put
them in the values files you commit to Git. (The component that *reads* step logs
back does take a full URL — the two differ, which is why one endpoint has to
satisfy the stricter of them.) An IPv6 literal is supported, written
bracketed: `http://[fd00::1]:9000`. `http` means the connection is not encrypted;
use it only on a network you trust.

**Limitations to check before you commit to this.**

* **The endpoint must present a publicly trusted TLS certificate**, or be reached
  over `http`. An endpoint whose certificate comes from a private CA — an
  on-premises appliance with an internal PKI, typically — is not supported in this
  release. `configuration.networking.trustedCaSecretName` covers your *ingress*,
  not this endpoint. **That `http` fallback does not extend to
  `buckets.postgresBackups` or `buckets.studioAssets`.** `postgresBackups` rejects
  `http` outright; `studioAssets` is not rejected but does not work over it, because
  the browser refuses the mixed content (above). So a store fronted by a
  privately-issued certificate has no supported path to PostgreSQL backups or to
  Studio publishing in this release. Leave `postgresBackups` out and PostgreSQL
  keeps backing up to its volume; Studio publishing has no equivalent fallback.
* **A store that requires path-style bucket addressing is only partly supported.**
  `forcePathStyle: true` reaches the MongoDB backup, pgBackRest and Unity Studio
  clients; the transformation step-log clients have no path-style setting yet. So
  if you are adopting this on MinIO, Ceph RGW or StorageGRID, verify that a
  transformation's step logs are readable after switching — MongoDB backups
  succeeding is *not* evidence that they are — and tell Unity if they are not. On
  AWS S3, or any store serving virtual-hosted-style requests, leave it `false` and
  this does not apply.
* One provider and one credential pair covers every bucket; per-bucket endpoints
  or a per-purpose provider choice are not supported.

Leaving `objectStore` out entirely, or setting `provider: garage`, is the
in-cluster store and needs nothing else in the block. If you fill in `endpoint`,
`region`, `forcePathStyle` or `buckets` but leave `provider` at `garage`, shdctl
warns that they are being ignored rather than quietly deploying the in-cluster
store; setting `configuration.infrastructure.components.garage` under
`provider: s3` warns the same way, since there is no in-cluster store to
size.

`buckets.postgresBackups` is the one exception to that warning. Naming it while
`provider` is `garage` **fails** `shdctl release generate` rather than warning: a
warning would leave you believing PostgreSQL's backups are off-cluster when they
are in fact still on their PersistentVolume, which is the one misreading of this
block that costs you a backup you thought you had. So moving a deployment back to
the in-cluster store means removing `postgresBackups` in the same edit that sets
`provider: garage`.

### Switching an existing deployment to your own buckets

Transformation step logs need nothing: they are transient and continuously
garbage-collected. The MongoDB backup history and the Unity Studio publications do
not move on their own. Copy both first, or the old dumps and every publication your
users made go away with the in-cluster store: Studio publications are user data, and
nothing else holds a copy. Replace `<namespace>` with your deployment's namespace,
and run every step from one shell on a workstation that can reach the cluster.

Naming `buckets.postgresBackups` doesn't move PostgreSQL's backup history either:
PostgreSQL starts a fresh backup chain in the bucket. Take a manual backup first if
you need the old chain, and keep the backup volume until you're satisfied with the
new one.

1. Copy the backup history and the Studio publications with the MinIO client, `mc`.
   The deployment keeps running meanwhile:

   ```sh
   # the in-cluster store's own keys, read out of the cluster
   export INCLUSTER_KEY=$(kubectl -n <namespace> get secret s3-api-storage-credentials -o jsonpath='{.data.GARAGE_ACCESS_KEY}' | base64 -d)
   export INCLUSTER_SECRET=$(kubectl -n <namespace> get secret s3-api-storage-credentials -o jsonpath='{.data.GARAGE_SECRET_KEY}' | base64 -d)
   # the key pair you created for your own buckets
   export MANAGED_KEY=<your-access-key-id>
   export MANAGED_SECRET=<your-secret-access-key>

   kubectl -n <namespace> port-forward svc/garage 3900:3900 &
   mc alias set incluster http://localhost:3900 "$INCLUSTER_KEY" "$INCLUSTER_SECRET"
   # your manifest's configuration.objectStore.endpoint; add --path on if it sets forcePathStyle: true
   mc alias set managed <your-object-store-endpoint> "$MANAGED_KEY" "$MANAGED_SECRET"
   mc mirror incluster/psmdb-backups managed/<your-backups-bucket>
   mc mirror incluster/studio-assets managed/<your-studio-assets-bucket>
   ```

2. Right before you switch, copy again in a quiet window: with no MongoDB backup due,
   and nobody publishing from Studio. Anything written to the in-cluster store after
   this copy is not carried over. `mc mirror` copies only what changed since the first
   run, so this one is short. Then check that the counts match:

   ```sh
   mc mirror incluster/psmdb-backups managed/<your-backups-bucket>
   mc mirror incluster/studio-assets managed/<your-studio-assets-bucket>
   mc ls --recursive incluster/psmdb-backups | wc -l
   mc ls --recursive managed/<your-backups-bucket> | wc -l
   mc ls --recursive incluster/studio-assets | wc -l
   mc ls --recursive managed/<your-studio-assets-bucket> | wc -l
   ```

3. Set `provider: s3` in your manifest, then run `shdctl secret generate` again,
   supplying your access key, and `shdctl secret deploy`, followed by
   `shdctl release generate` and `shdctl release deploy`. Between the secret
   deployment and the release deployment, step-log uploads and MongoDB backups fail:
   the running services hold your new key but still point at the in-cluster store.
   That is expected; keep the gap short, and both recover once the charts are deployed.

4. With ArgoCD, the in-cluster store's application disappears from the release and is
   pruned on the next sync. With Helm, nothing removes it, and `shdctl release uninstall`
   skips it too: it keeps running, but nothing writes to it anymore. Run the two
   `mc mirror` commands once more, to pick up anything written during the switch, then
   remove it yourself with `helm -n <namespace> uninstall garage`. Either way, stop the
   port-forward with `kill %1`.

5. The in-cluster store's PersistentVolumeClaims survive the switch, and they are the
   only remaining copy of anything you did not mirror. Keep them until you have seen a
   fresh MongoDB backup complete, a transformation run end to end, and a Studio
   publication appear in your own bucket. Then delete them:

   ```sh
   kubectl -n <namespace> get pvc | grep garage
   kubectl -n <namespace> get pvc -o name | grep garage | xargs -r kubectl -n <namespace> delete
   ```

## Custom CA bundle for a privately-signed ingress

If your Asset Manager ingress uses a TLS certificate signed by a CA that is not
in the default container trust store (for example a government or internal corporate
PKI), the .NET StorageTool containers in the built-in Argo workflows will
fail to validate downloads from your own ingress with `PartialChain`.

To fix this, create a Kubernetes Secret in the install namespace holding your
CA chain, then reference it from your manifest:

```sh
kubectl -n <install-namespace> create secret generic my-ca-bundle --from-file=ca-bundle.crt=/path/to/your-ca-chain.pem
```

```yaml
configuration:
  networking:
    trustedCaSecretName: my-ca-bundle
```

Requirements:

* Single key inside the secret: `ca-bundle.crt`.
* Value: PEM-encoded concatenation of the root and intermediate CAs needed to
  validate your ingress.
* Plain `BEGIN CERTIFICATE` blocks only — `BEGIN TRUSTED CERTIFICATE` (OpenSSL
  trust-extended) is filtered out by .NET and won't be loaded.
* Kubernetes Secret size limit is 1 MiB.

You may use any mechanism to populate the secret: `kubectl`, External Secrets
Operator, sealed-secrets, etc. shdctl does not create or read it.

The same bundle is mounted into the Unity Studio publisher, which also calls
your ingress, so a privately-issued ingress certificate needs nothing extra for
Studio. It does not cover `configuration.authentication.x509.enabled: true`,
which is a separate setting: that one turns on mutual TLS (mTLS), requiring
every client reaching your deployment to present a certificate of its own, and
Studio's publisher has none — so Studio publishing does not work while x509
client authentication is enabled in this release.
