Install on Kubernetes
Install a self-hosted Botyard bot on Kubernetes: verify the signed image and chart, keep the enrollment token in a Secret, run helm install, check it is online.
This guide installs one self-hosted Botyard bot's runtime (botyardd) in your own Kubernetes
cluster with the botyard-runtime-selfhosted Helm chart. For its own control-plane traffic, the
runtime makes only outbound connections, over HTTPS and WSS, to Botyard, and listens on no network
port for Botyard. The chart creates no Service and no Ingress. This covers the runtime's own
transport only. Commands it starts through exec, and services it supervises, can open listeners
and connect on other ports. Having no Service or Ingress does not stop direct connections to the
Pod IP. Use the chart's NetworkPolicy (off by default) or
your own ingress controls.
Read the self-hosted overview first. It covers what runs where and what you need before you start.
Availability
Self-hosted bots are enabled per organization by Botyard. If your organization is not enabled yet, contact Botyard to request access. Kubernetes is the only supported install target today. Linux VM and macOS installs are not yet available.
Before you start
You need:
- A Kubernetes cluster at version 1.25 or later, on
linux/amd64orlinux/arm64nodes. - A default StorageClass, or the name of one, that can provision a ReadWriteOnce volume.
- Outbound HTTPS (TCP 443) from the pod to Botyard, either direct or through an HTTP proxy.
kubectl, Helm 3.8 or later (for OCI charts), cosign 2.x or later, andjq.- A self-hosted bot in Botyard, which you will enroll this cluster as. See the Enrollment and devices page.
The chart runs a single-replica StatefulSet. Botyard allows one active device per bot, and the
pod's volume holds the device key, so the volume is the device. There is no replicaCount.
The examples below use the namespace botyard, the release name bot and version 1.7.2414.
With release name bot, the pod is bot-botyard-runtime-selfhosted-0.
VERSION=1.7.2414
kubectl create namespace botyardThe chart's pod meets the Kubernetes restricted Pod Security Standard: a non-root user, a
read-only root filesystem, all capabilities dropped and no service-account token. You can enforce
that standard on the namespace:
kubectl label namespace botyard pod-security.kubernetes.io/enforce=restricted1. Verify what you pull
Botyard publishes the image and the chart to GitHub Container Registry. Both are public:
| Artifact | Reference |
|---|---|
| Container image (amd64, arm64) | ghcr.io/botyard-ai/runtime-selfhosted:<version> |
| Helm chart (OCI) | oci://ghcr.io/botyard-ai/charts/botyard-runtime-selfhosted, --version <version> |
Each release is signed keyless with cosign. The signing certificate is issued to Botyard's GitHub Actions release workflow, and every signature is recorded in the public Rekor transparency log. The image also carries a signed SPDX SBOM attestation. Botyard holds no long-lived signing key, so you verify the signer's identity, not a key:
IDENTITY='^https://github\.com/Botyard-AI/botyard/\.github/workflows/release-runtime-selfhosted\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$'
ISSUER=https://token.actions.githubusercontent.comIDENTITY accepts only the release-runtime-selfhosted.yml workflow in the Botyard-AI/botyard
repository, run from a release tag such as v1.7.2414. ISSUER is GitHub Actions' OIDC issuer.
Verify the image by tag, and keep the digest that cosign verified. The block takes the digest
only from a successful cosign verify, and accepts it only if it is a well-formed sha256
digest:
IMAGE_DIGEST=
if IMAGE_VERIFIED=$(cosign verify "ghcr.io/botyard-ai/runtime-selfhosted:${VERSION}" \
--certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER"); then
IMAGE_DIGEST=$(printf '%s' "$IMAGE_VERIFIED" | jq -r '.[0].critical.image."docker-manifest-digest"')
fi
if printf '%s' "$IMAGE_DIGEST" | grep -Eqx 'sha256:[0-9a-f]{64}'; then
echo "image verified: $IMAGE_DIGEST"
else
IMAGE_DIGEST=
echo "STOP: image verification failed. Do not install." >&2
fiFrom here on, refer to the image by that digest. A tag can be moved; a digest cannot. Verify the SBOM attestation on the same digest:
if [ -n "$IMAGE_DIGEST" ] && cosign verify-attestation --type spdxjson \
"ghcr.io/botyard-ai/runtime-selfhosted@${IMAGE_DIGEST}" \
--certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER" > /dev/null; then
echo "SBOM attestation verified"
else
echo "STOP: SBOM attestation verification failed. Do not install." >&2
fiVerify the chart the same way, then pull it and check that the pulled chart has the digest you verified:
CHART_DIGEST=
if CHART_VERIFIED=$(cosign verify "ghcr.io/botyard-ai/charts/botyard-runtime-selfhosted:${VERSION}" \
--certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER"); then
CHART_DIGEST=$(printf '%s' "$CHART_VERIFIED" | jq -r '.[0].critical.image."docker-manifest-digest"')
fi
if ! printf '%s' "$CHART_DIGEST" | grep -Eqx 'sha256:[0-9a-f]{64}'; then
echo "STOP: chart verification failed. Do not install." >&2
elif ! PULL_OUTPUT=$(helm pull oci://ghcr.io/botyard-ai/charts/botyard-runtime-selfhosted \
--version "$VERSION" 2>&1); then
printf '%s\n' "$PULL_OUTPUT" >&2
echo "STOP: helm pull failed." >&2
elif printf '%s\n' "$PULL_OUTPUT" | grep -Fqx "Digest: ${CHART_DIGEST}"; then
echo "chart verified and pulled: $CHART_DIGEST"
else
printf '%s\n' "$PULL_OUTPUT" >&2
echo "STOP: the pulled chart is not the chart you verified. Delete the .tgz. Do not install." >&2
fihelm pull saves botyard-runtime-selfhosted-<version>.tgz in the current directory. You install
from that file in step 3, so Helm uses exactly the chart you verified.
Each block above ends by printing either a success line or a line starting with STOP. If you see
STOP, or any cosign, jq or helm error, for any reason (not only no matching signatures),
do not install. Fix the cause and run the whole block again.
A 403 from ghcr.io on a public package
cosign, Helm and Docker read registry credentials from ~/.docker/config.json. An expired
ghcr.io login there makes even anonymous pulls of public packages fail with 403 Forbidden.
Run docker logout ghcr.io (or helm registry logout ghcr.io) and retry, or run the commands
with DOCKER_CONFIG pointing at an empty directory.
Mirroring into a private registry
You can mirror both artifacts into your own registry, for example Amazon ECR, and install from
there. cosign copy copies the image or chart together with its signatures and attestations, so
the same cosign verify commands, pointed at your mirror, still check Botyard's identity:
MIRROR=registry.example.com/botyard # your registry and path
cosign copy "ghcr.io/botyard-ai/runtime-selfhosted@${IMAGE_DIGEST}" \
"${MIRROR}/runtime-selfhosted:${VERSION}"
cosign copy "ghcr.io/botyard-ai/charts/botyard-runtime-selfhosted:${VERSION}" \
"${MIRROR}/charts/botyard-runtime-selfhosted:${VERSION}"Mirroring does not change the image digest. Then set image.repository to your mirror. If your
nodes need credentials to pull from it, also set imagePullSecrets:
image:
repository: registry.example.com/botyard/runtime-selfhosted
digest: sha256:<IMAGE_DIGEST>
imagePullSecrets:
- name: my-registry-credentials2. Store the enrollment token in a Secret
The pod enrolls itself as the bot's device on first start, using a single-use enrollment token that is valid for 7 days. Mint it right before you install:
- In Botyard, open User menu › Settings › Bots › ‹your bot› › Device.
- Select Generate install command.
- Botyard shows a one-line install command for a workstation, ending in
--token <token>. Do not run that command. Copy only the token, the value after--token.
The Enrollment and devices page covers tokens, revocation and re-enrollment in detail.
Put the token in a Kubernetes Secret. Don't put it in a values file, and don't type it on a command line where it would land in your shell history. This reads it from a hidden prompt:
read -rs BOTYARD_TOKEN # paste the token, then press Enter
printf '%s' "$BOTYARD_TOKEN" | kubectl -n botyard create secret generic botyard-enrollment \
--from-file=token=/dev/stdin
unset BOTYARD_TOKENIf you manage Secrets with a secret store (for example External Secrets Operator), create a
Secret named botyard-enrollment with the token under the key token instead.
What happens to the token:
- On the first start, the container enrolls with it, then removes it from its environment before the daemon starts. Neither the container's init process nor the daemon holds it.
- A shell you open with
kubectl execdoes seeBOTYARD_ENROLLMENT_TOKEN. Kubernetes sets a container's environment once, when the container starts, and does not update it later. So everykubectl execinto that container sees the token it started with, even after you delete or change the Secret. - On every later start, the volume is already enrolled, so the token is not used again. A token that was already used or has expired is inert, so you can leave the Secret in place.
To remove the token from the running pod, wait until the bot is online (step 4), then delete the Secret and replace the pod:
kubectl -n botyard delete secret botyard-enrollment
kubectl -n botyard rollout restart statefulset/bot-botyard-runtime-selfhostedThe chart marks the Secret reference optional, so the new pod starts without the variable. Replacing the pod keeps its PersistentVolumeClaim, so the bot comes back already enrolled. Do the same after any later change to the Secret.
3. Install the chart
Create a minimal values file. It pins the image by the digest you verified, points at the Secret, and turns on the chart's egress-only NetworkPolicy:
image:
digest: sha256:<IMAGE_DIGEST> # from step 1
enrollment:
existingSecret: botyard-enrollment
networkPolicy:
enabled: trueInstall from the chart file you pulled and verified:
helm install bot "./botyard-runtime-selfhosted-${VERSION}.tgz" -n botyard -f values.yamlWhen image.digest is set, the chart pulls the image by digest and ignores image.tag.
You can also install straight from the registry with
helm install bot oci://ghcr.io/botyard-ai/charts/botyard-runtime-selfhosted --version "$VERSION" -n botyard -f values.yaml.
Helm then resolves the version tag again at install time, rather than using the chart file whose
digest you checked.
The pod needs no registry credentials: the image and chart are public. The NetworkPolicy only takes effect if your cluster's network plugin enforces NetworkPolicy. On Amazon EKS, that means the VPC CNI with network policy enabled.
4. Check that it worked
Wait for the pod to be ready. The chart defines no probes: the pod is ready once botyardd is
running, and the daemon reconnects to Botyard by itself.
kubectl -n botyard rollout status statefulset/bot-botyard-runtime-selfhostedRead the logs:
kubectl -n botyard logs statefulset/bot-botyard-runtime-selfhostedOn a first start, look for these lines:
enrolled this device device_id=<id> custody_tier=file …, thenbotyard-selfhosted: enrollment complete. The token was accepted, and this volume is now the bot's device.connected to the Botyard runtime gateway gateway=wss://runtime.botyard.io …. The daemon is connected.
Some INFO and WARN lines about hardware key storage are expected. They say that no Secure
Enclave, TPM or similar is available, so the device key is kept on the volume
(custody_tier=file).
On every later start, such as after the pod is deleted or rescheduled, these lines are expected and are not an error:
refusing to enroll: this machine is already bound and --replace was not given
botyard-selfhosted: state directory already enrolled; the enrollment token was not usedThe volume is already this bot's device, so the token is left unused and the daemon starts and reconnects.
Ask the daemon directly:
kubectl -n botyard exec bot-botyard-runtime-selfhosted-0 -- botyardd statusThe Botyard line should read connected. kubectl may also print a line like
Unknown stream id 1, discarding message. That message comes from kubectl itself and is harmless.
Finally, in Botyard, User menu › Settings › Bots › ‹your bot› › Device should show the device as Online. The device is
named after the pod unless you set enrollment.deviceLabel.
Before the bot can answer a message, it needs a model credential. Assign one with the llm scope
on User menu › Settings › Bots › ‹your bot› › Credentials. Without one, a turn fails with
No credential is configured for provider scope 'llm'.
If the pod is running but the bot stays offline:
this machine is not enrolledin the logs means no token reached a new volume. Check thatenrollment.existingSecretnames your Secret, then restart the pod.enrollment failedmeans the token was already used or has expired. Mint a new one, replace the Secret's value, and restart the pod.- No
connected to the Botyard runtime gatewayline and no error usually means the pod can't reach Botyard: a NetworkPolicy, firewall or proxy is in the way, or the proxy URL uses an unsupported scheme. Today the daemon logs failed connection attempts only at debug level, so they don't appear in the default logs. Check outbound TCP 443 from the pod and the proxy settings.
The Operations and troubleshooting page covers crashloops, upgrades and logs.
Values that matter
The chart's
values.yaml
is the source of truth for every value and its default. These are the ones most installs touch.
Add them to values.yaml and run helm upgrade bot ./botyard-runtime-selfhosted-${VERSION}.tgz -n botyard -f values.yaml.
Host policy (hostPolicy)
hostPolicy is this machine's own limit on what the bot may do on the pod: read and write files,
run commands, and so on. The bot can only use an operation that Botyard requests and this
policy allows.
Empty, the default, means the runtime performs no host operation. No command runs and no file is read or written. The policy does not decide which tools the model is offered. That depends on what the bot is assigned in Botyard, and Botyard does not use your local policy to filter the assignment. So under an empty policy:
- a host tool the bot is not assigned is not offered. The bot may answer that the tool (for
example
process_start) isn't available; - a host tool the bot is assigned can still be offered. If the bot calls it, the call reaches the
runtime, which refuses it and writes an audit line with
decisiondenyandreasonlocal_policy.
To let the bot use host tools, list the operations in hostPolicy.enabled:
hostPolicy:
enabled: [read, write, patch]
root: /var/lib/botyard/workspacesrootmust be on the pod's volume (under/var/lib/botyard/) or under/tmp. It must not be the daemon's state directory,/var/lib/botyard/state, which holds the device key. The chart refuses those paths at install time and creates the root for you.rootlimits the file operations. It does not confineexec: a command runs as the same user and can read the device key. Enableexeconly for a bot you would trust with this device's identity.- Changing
hostPolicyrestarts the pod. - To manage the policy yourself, set
hostPolicyConfigMap.nameto an existing ConfigMap instead. The root directory must then already exist.
The Host policy and security page explains each operation and the trust model.
Outbound proxy (proxy)
If the pod must reach the internet through a proxy, it must be an HTTP proxy (HTTP
CONNECT) with an http:// URL. https:// and socks5:// proxies are not supported. The URL
needs a real host and either no port (meaning 80) or a port from 1 to 65535.
Set one of these, not both:
proxy.url— a proxy without credentials, such ashttp://proxy.corp.example:3128. The chart checks the URL against the rules above when you install, and refuses it with an error namingproxy.url. It may not contain a user name or password.proxy.existingSecret— the name of a Secret whoseHTTPS_PROXYkey holds the URL. Use this for a proxy that needs credentials. The chart can't read Secrets, so nothing checks this value at install time. Check the URL before you store it. A wrong scheme, such ashttps://orsocks5://, does not stop the pod: it starts and stays running, but never comes online in Botyard, and the failed connections are not logged at the default log level. See the Operations and troubleshooting page if the pod is running but the bot stays offline.
proxy.noProxy lists hosts to reach directly, with curl semantics: domains, .suffix, IP
addresses, CIDRs or *.
proxy:
url: http://proxy.corp.example:3128
noProxy: .svc,.cluster.localExtra CA bundle (extraCABundle)
If your proxy inspects TLS, give the pod your proxy's CA certificate. The bundle is added to the built-in public roots; it doesn't replace them. Put the PEM bundle in a ConfigMap or a Secret and set exactly one of these:
extraCABundle:
configMapName: corp-ca # or secretName: corp-ca
key: ca.pemIf the bundle can't be read or isn't valid PEM, the daemon refuses to start.
Egress NetworkPolicy (networkPolicy)
The chart can create an egress-only NetworkPolicy. It denies all ingress, and allows DNS plus TCP 443. Your cluster's network plugin must enforce NetworkPolicy.
networkPolicy:
enabled: true
# Optional: allow 443 only to these CIDRs, for example your proxy.
egressCIDRs: [10.20.0.15/32]
# Optional: extra ports, for example your proxy's port.
extraEgressPorts:
- port: 3128
protocol: TCPegressCIDRs restricts only the 443 and extra-port rule. DNS stays allowed to any destination.
Storage (persistence)
The pod keeps its state on a ReadWriteOnce volume: the device key, the enrollment and the bot's workspaces.
persistence:
storageClassName: gp3 # empty = the cluster default
size: 10Gipersistence.existingClaimreuses an existing PVC, for example to restore a device after recreating the release.helm uninstallkeeps the volume. Deleting the PVC deletes the device's identity: you would then revoke the device in Botyard and enroll again with a new token.
Resources (resources)
The defaults request 250m CPU and 512Mi memory, with a 2Gi memory limit. Raise them if the bot runs heavy commands on the pod:
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 2GiOther values
enrollment.deviceLabel— the device's name in Botyard. Defaults to the pod name.extraEnv— extra, non-secret environment variables. For example, setBOTYARD_HOST_TOOL_AUDITtooffto turn off the per-call audit lines. The chart refuses variables it manages itself, and the identity variables described below.podLabels,podAnnotations,nodeSelector,tolerations,affinity,priorityClassNameandterminationGracePeriodSecondswork as in most charts.
Don't connect the pod to a service mesh identity
Don't inject SPIFFE_ENDPOINT_SOCKET into this pod, and don't run your own SPIRE agent or
SPIFFE-based mesh identity for it.
A self-hosted bot proves who it is with the device key it enrolled with. Botyard's own hosted bots use a different, Botyard-run identity system, which is found through that variable. If the variable is present, today's daemon assumes it is running inside Botyard's hosted platform, tries that identity path instead of its device key, and will not connect.
The chart refuses to set SPIFFE_ENDPOINT_SOCKET (and the related hosted-identity variables)
through extraEnv. A mesh admission webhook can still inject it, so exclude this namespace or
pod from any SPIFFE/SPIRE injection.
Known limitations
- One replica. One active device per bot. There is no high-availability mode; restarts are covered by the daemon reconnecting and resuming.
- Enrollment is by token only. Workload-identity federation (such as IRSA or a service account's OIDC token) is not supported yet.
- The device key is stored on the volume. KMS- and TPM-backed key storage are not supported yet.
- No customer SPIRE or SPIFFE mesh identity on this pod, as explained above.
- No self-update. You upgrade by installing a newer, verified chart and image version.
Self-hosted runtime
Run a Botyard bot's runtime in your own Kubernetes cluster: how it connects, what it needs, what happens while it is offline, and who can use it.
Host policy and security
What a self-hosted Botyard runtime can and cannot do in your cluster: trust boundaries, the local host policy, sudo, the host-tool audit log and network controls.