# Private or self-signed gateway certificate

Your applications reach a self-managed region through its [gateway](./system-architecture.md#gateway). The gateway serves TLS for every project endpoint, `http-<project-id>.<region-domain>` and `grpc-<project-id>.<region-domain>`, with the certificate you give it in `gateway.tls`.

When a public CA signed that certificate, nothing more is needed. When your corporate CA, an internal PKI, or a self-signed CA signed it, three things have to know the CA:

- **The gateway secret** carries the CA next to the certificate and key.
- **The Catalyst agent** reads the CA from that secret and passes it to every sidecar in the region.
- **Your applications** trust the CA, because they call your project endpoints through the gateway.

If an application doesn't trust the CA, every call fails certificate verification with an error such as `CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate` or `self signed certificate in certificate chain`.

## Put the CA in the gateway secret

The gateway secret holds the CA under a third key, `ca.crt`, next to `tls.crt` and `tls.key`. Only a secret you create can do that: the secret the chart creates from inline `gateway.tls.cert` and `gateway.tls.key` has no CA key.

For a certificate from your corporate CA, create the secret as a generic secret of type `kubernetes.io/tls`, so it can carry the third key:

```bash
kubectl -n cra-agent create secret generic my-gateway-tls \
  --type=kubernetes.io/tls \
  --from-file=tls.crt=path/to/server.crt \
  --from-file=tls.key=path/to/server.key \
  --from-file=ca.crt=path/to/ca.crt
```

`server.crt` is the wildcard certificate for `*.<region-domain>`, followed by any intermediate certificates. `ca.crt` is the root.

If cert-manager issues the certificate from a CA issuer, it writes `ca.crt` into the secret for you.

## Point the chart at the CA

Reference the secret and name the key that holds the CA:

```yaml
gateway:
  tls:
    enabled: true
    existingSecret: my-gateway-tls
    certificates:
      caFile: ca.crt
```

The chart copies `caFile` into the agent setting `agent.config.host.ingress.tls.secret.ca_file`. If you set both, the agent setting wins. The agent reads the CA only when it starts, so restart it after you set `caFile` or change the CA:

```bash
kubectl -n cra-agent rollout restart deploy/agent
kubectl -n cra-agent logs deploy/agent | grep 'ingress TLS'
```

You should see `loaded ingress TLS CA certificate from secret cra-agent/<secret> (key: ca.crt)`. A `failed to read ingress TLS secret` line means the agent couldn't read the secret. No line at all means `caFile` isn't set, or the secret has no key with that name.

See the [Helm reference](./reference.md#gateway-tls) for every `gateway.tls` value. For the sidecars' own outbound calls to services signed by a private CA, such as an MCP server, see [Sidecar outbound TLS trust](./reference.md#sidecar-outbound-tls-trust).

## Make your applications trust the CA

Export the CA from the secret and mount it into each application pod, for example from a ConfigMap:

```bash
kubectl -n cra-agent get secret my-gateway-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > ca.crt
kubectl -n <app-namespace> create configmap catalyst-ca --from-file=ca.crt=ca.crt
```

Then point the application's runtime at it. Most runtimes replace their default trust with the file you give them, so if the application also calls public endpoints, such as an LLM provider, build a bundle that holds the system roots followed by your CA, and point at that bundle.

A Python application needs three variables, because its HTTP and gRPC clients read different settings. `grpcio` ignores the operating system's trust store and `SSL_CERT_FILE`, so without `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH`, workflows and agents fail to connect even when HTTP calls succeed:

```yaml
env:
  - name: GRPC_DEFAULT_SSL_ROOTS_FILE_PATH # gRPC: workflows, agents, the Dapr gRPC client
    value: /etc/catalyst-ca/ca-bundle.pem
  - name: SSL_CERT_FILE                    # Python's ssl module and the clients built on its defaults
    value: /etc/catalyst-ca/ca-bundle.pem
  - name: REQUESTS_CA_BUNDLE               # requests, which reads certifi instead of SSL_CERT_FILE
    value: /etc/catalyst-ca/ca-bundle.pem
```

For other runtimes:

| Runtime | Setting |
|---------|---------|
| Go | `SSL_CERT_FILE`, on Linux. It replaces the system bundle. |
| Node.js | `NODE_EXTRA_CA_CERTS`, which adds to the built-in roots, so the CA file alone is enough. |
| Java | Import the CA into a truststore and pass it with `-Djavax.net.ssl.trustStore`. |
| .NET | Add the CA to the container image's system trust store with `update-ca-certificates`. |

Adding the CA to the image's system trust store doesn't cover a Python gRPC client: set `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH` as well.

## Check it

From a machine that resolves the region's domain, ask the gateway for a project endpoint and verify it against the CA:

```bash
openssl s_client -connect grpc-<project-id>.<region-domain>:443 \
  -servername grpc-<project-id>.<region-domain> \
  -CAfile ca.crt -showcerts </dev/null
```

A good answer ends with `Verify return code: 0 (ok)`. If it fails with `unable to get local issuer certificate`, compare the chain the gateway sends with what's in `ca.crt`: the secret's `tls.crt` must include any intermediate certificates.

## Next steps

- [Architecture](./system-architecture.md) — the gateway, the region's other components, and its PKI.
- [Helm reference](./reference.md#gateway-tls) — the `gateway.tls` values.
- [Troubleshooting](https://docs.diagrid.io/references/troubleshooting#a-self-managed-regions-certificate-isnt-trusted) — certificate errors when connecting an app.
