Deploys one deCDN node (decdn-node) on Kubernetes. It is the Kubernetes
counterpart of the Ansible decdn_node role
and keeps the same guarantees:
- no secrets in git or in values
- missing required values, secret-bearing keys and chart-managed keys fail at render time
- one public hole (QUIC udp/4433)
- no baked-in protocol facts
One release is one node identity: one keystore and one data dir. To run a fleet, install one release per node. The StatefulSet is fixed at one replica, because two pods sharing a keystore would double-sign.
node.toml renders against upstream crates/common/src/config/types.rs. Every section
there is deny_unknown_fields, so an unknown key crash-loops the pod. CI renders the
chart's own fixtures (ci/*.yaml) and checks every emitted key against the same
committed inventory the Ansible schema molecule scenario uses
(schema-keys.txt). Re-sync
it when bumping the decdn version. Keys and value types you add to config are
validated only by the daemon at startup (a typo is a crash-loop, not a render error),
and unlike the Ansible role there is no in-cluster decdn config validate, because the
image has no CLI.
| Object | Purpose |
|---|---|
StatefulSet (1 replica) |
decdn-node run under a non-root, read-only-rootfs, all-caps-dropped pod. It includes a prepare init container. |
PersistentVolumeClaim (data) |
The data dir: node identity, voucher state, receipts, and the cache. |
ConfigMap |
The rendered node.toml. Its checksum rolls the pod on any change. |
Service (quic) |
Public UDP. The type is LoadBalancer, NodePort or ClusterIP, with externalTrafficPolicy: Local. |
Service (metrics) |
ClusterIP only, never public. |
NetworkPolicy |
Ingress allows QUIC from anywhere and metrics only from metrics.networkPolicy.from. Egress is open unless networkPolicy.egress is set. |
ServiceMonitor |
Optional (metrics.serviceMonitor.enabled). Adds the job, region, deployment_environment and instance target labels the dashboards and alerts in files/monitoring/ select on. |
PrometheusRule |
Optional (metrics.prometheusRule.enabled). The deCDN reference alert rules (files/monitoring/). |
ConfigMap × 4 (dashboards) |
Optional (metrics.grafanaDashboards.enabled). The deCDN Grafana dashboards (files/monitoring/), labelled for the Grafana sidecar. |
What it does not do:
- Create Secrets. The operator provisions them.
- Generate keys. The image is daemon-only, with no
decdnCLI. - Stake or register on-chain (ADR 019 Phase 2). Run
decdn setupoff-cluster.
- Kubernetes ≥ 1.25, with a CNI that enforces NetworkPolicy (the chart relies on it
to keep metrics private), and a StorageClass that can provision a volume larger than
config.cache.cache_size_mb+config.cache.disk_headroom_mb(daemon default 8192 MiB). With the defaults (10240 + 8192 MiB), the 20Gipersistence.sizeis tight. - UDP reachability. Either a load balancer that supports UDP Services, or
quic.hostPort.enabledon nodes with a public IP. - An image. Upstream has not published
ghcr.io/decdn/decdn-nodeyet. The chart refuses to render untilimage.tagorimage.digestis set. To build one yourself, follow the header of the upstreamdecdn/Dockerfile:cargo build --release -p decdn-node, copy the binary todist/<arch>/decdn-node, thendocker build. The Dockerfile only packages that binary, ondebian:bookworm-slim(glibc 2.36), so build on a system with glibc ≤ 2.36 or the binary will not start. - The
decdnCLI on your workstation, forkey-gen,setup, andnode healththrough a port-forward.
The chart only references Secrets. Create them out of band and keep the source files off shared disks.
# 1. Node identity, generated off-cluster.
umask 077
mkdir -p ./node-1 && openssl rand -base64 32 > ./node-1/keystore.password
decdn key-gen --output-dir ./node-1 --keystore-password-file ./node-1/keystore.password
kubectl -n decdn create secret generic decdn-node-1-keys \
--from-file=keystore.json=./node-1/keystore.json \
--from-file=node.secret=./node-1/node.secret \
--from-file=keystore.password=./node-1/keystore.password
# 2. The RPC endpoint (it may embed an API key): the k8s equivalent of
# /etc/decdn/decdn.env. Use one key per variable (--from-literal), not
# --from-file=decdn.env.
kubectl -n decdn create secret generic decdn-node-1-env \
--from-literal=DECDN_RPC_URL='https://…'Only named keys from the env Secret reach the node: secrets.env.rpcUrlKey (default
DECDN_RPC_URL) and anything in secrets.env.passthroughKeys (for example
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for an S3 origin). The chart deliberately
does not use envFrom: upstream lets every DECDN_* env var override node.toml, so a
stray DECDN_BIND_PORT, DECDN_METRICS_BIND, DECDN_ETH_KEYSTORE, DECDN_CHAIN_ID or
address variable would silently bypass the managed keys and the schema checks. For the
same reason DECDN_* names in passthroughKeys are refused.
If a Secret or a named key is missing, the pod does not start: it stays in
ContainerCreating (a FailedMount event, for the keystore Secret) or
CreateContainerConfigError (for an env key). Check kubectl describe pod.
Back up ./node-1 offline. It is the node's on-chain identity. The data dir is not a
backup, because the init container overwrites the key files from the Secret on every
pod start.
Why the init container? Upstream refuses keystore.json and node.secret if they are
symlinks or carry any group or world permission bit, and a data dir with group or world
bits. Secret volume files are symlinks (and fsGroup adjusts their modes), so they can't
be used in place. On every pod start, prepare creates data_dir as a subdirectory of
the PVC (the PVC root carries fsGroup bits), installs those two files into it at 0600,
and installs keystore.password into an in-memory volume instead. The password is never
written to the PVC, so a volume snapshot or backup does not hold both the keystore and the
password that unlocks it. The Secret itself is mounted only in the init container, never
in the daemon.
| Value | Notes |
|---|---|
image.tag or image.digest |
Required until upstream publishes a release. Prefer a digest. |
secrets.keystore.existingSecret |
Holds the keys keystore.json, node.secret and keystore.password. Rename them with secrets.keystore.keys.*. |
secrets.env.existingSecret |
Holds DECDN_RPC_URL (key name: secrets.env.rpcUrlKey). |
config.identity.region |
ISO 3166-1 alpha-2, uppercase. |
config.blockchain.chain_id |
For example, 421614 (Arbitrum Sepolia). |
config.blockchain.{payment_pool,capacity_bond,slash_judge,content_blacklist}_address |
Non-zero 0x addresses. |
Take chain IDs and contract addresses from the deployment manifest for your chain. Never
make them up. The quickest correct source is the CLI itself: decdn config init --chain arbitrum-sepolia --output /tmp/node.toml writes the manifest's [blockchain] section,
and the Ansible role's mirror of the same manifest is
networks.yml. (The chart has
no network shortcut yet; the addresses stay explicit in values.) Values are validated by values.schema.json plus
template guards, so a missing or malformed value fails helm install with a message that
names it.
# values-node-1.yaml
image:
digest: sha256:…
secrets:
keystore: {existingSecret: decdn-node-1-keys}
env: {existingSecret: decdn-node-1-env}
config:
identity: {region: DE}
blockchain:
chain_id: 421614
payment_pool_address: "0x…"
capacity_bond_address: "0x…"
slash_judge_address: "0x…"
content_blacklist_address: "0x…"# From a checkout of this repo:
helm install decdn-node-1 charts/decdn-node -n decdn -f values-node-1.yaml
# From the OCI registry, once the first release is published (RELEASING.md):
helm install decdn-node-1 oci://ghcr.io/decdn/charts/decdn-node --version 0.1.0 \
-n decdn -f values-node-1.yamlPublished charts are signed with cosign; RELEASING.md shows how to verify one.
config mirrors node.toml section for section. The role's variable names map onto it
directly: decdn_cache_size_mb becomes config.cache.cache_size_mb, and
decdn_tinylfu_sketch_bytes becomes config.cache.tinylfu.sketch_bytes. The role's
README documents each knob. Any key you do not
set takes the daemon default, and a null value removes a chart default.
Chart defaults that differ from the daemon's are the same as the role's:
cache.max_blob_size_mb: 1024: a deliberate cap at one tenth of the default cache (the daemon default ismin(51200, cache_size_mb)). It must be at least 1: the cache engine's admit gate has no zero special-case, despite the CLI help, so0rejects every blob.observability.log_format: json(the daemon default ispretty).cache.cache_size_mb: 10240andpayment.rate_per_mb: 10are pinned explicitly, at the daemon's own values.cache.node_to_node_pull_through_enabled: derived when unset. It istruewith nocache.originorcache.origins, andfalsewhen an origin is configured.
Managed keys. The chart sets these to match the pod spec. Setting any of them in
config fails the render. (Their DECDN_* env equivalents cannot be injected either;
see Secrets.)
| Key | Set from |
|---|---|
identity.data_dir, cache.cache_dir, blockchain.eth_keystore |
Fixed PVC paths (/var/lib/decdn/node, /var/lib/decdn/node/cache, and keystore.json inside the data dir) |
network.bind_port |
quic.port |
observability.metrics_port |
metrics.port |
observability.metrics_bind |
Always 0.0.0.0; see Network |
Forbidden keys. rpc_url, access_key_id, session_token, and any key containing
password or secret (dashes count as underscores) are refused anywhere in config.
config renders to a ConfigMap, which is readable by anyone with configmap read access in
the namespace. The guard checks key names only: never embed a credential in a value
either (such as https://user:pass@… in an origin URL). Put secrets in the env Secret.
Pod overrides. podLabels cannot re-set a chart label (that would detach the pod from
its StatefulSet selector and Services), podAnnotations cannot re-set checksum/config,
and podSecurityContext / securityContext can be changed (for example, a different
non-root uid) but the render fails if the result runs as root, allows privilege escalation
or privileged mode, makes the root filesystem writable, adds capabilities, drops fewer than
ALL, or disables seccomp.
Other render-time checks. Every top-level config entry must be a table; a null
inside a list element is refused (omit the key instead); and whole numbers of 2^53 or more
are refused, because values files decode numbers as float64 and they would render rounded.
Origins. Use either config.cache.origin (one origin) or config.cache.origins (an
ordered, non-empty list), not both. For S3 credentials, use
credentials: {source: default-chain} and either pass the AWS variables through
secrets.env.passthroughKeys, or use IRSA (the role-ARN annotation in
serviceAccount.annotations) or an EKS Pod Identity association. Both webhooks inject
their own token volume, so they should work with the chart's
automountServiceAccountToken: false (not yet tested in a cluster). If you set
networkPolicy.egress, allow STS (or the Pod Identity agent). Never put credentials in
config.
-
QUIC (
quic.port, default 4433/udp) is the only public port.service.type: LoadBalancer(the default) gives the node a stable public UDP address, andexternalTrafficPolicy: Localpreserves client source IPs for per-source rate limiting.- For bare-metal-style nodes, set
quic.hostPort.enabled: trueandservice.enabled: false. - Without direct reachability, the node still works through iroh relays, at a latency cost.
-
Metrics bind
0.0.0.0inside the pod rather than loopback, which the Ansible role requires. That is how kubelet probes and Prometheus reach/metrics. Metrics are exposed only by a ClusterIP Service, and the NetworkPolicy admits TCP to that port only frommetrics.networkPolicy.from. That list is empty by default, so no scraper is allowed. Kubelet probes are unaffected: the NetworkPolicy spec always allows traffic between a pod and the node it runs on.- The NetworkPolicy is what keeps metrics private, so
networkPolicy.enabled: falsefails the render unlessnetworkPolicy.allowUnrestrictedMetrics: trueaccepts that every pod in the cluster can reach them. - With the policy enabled,
metrics.serviceMonitor.enabledwithout anymetrics.networkPolicy.fromentry fails the render. The chart cannot check that your entries actually select Prometheus.
- The NetworkPolicy is what keeps metrics private, so
-
The admin RPC is hardcoded to
127.0.0.1upstream and cannot be reached from the pod network. Use a port-forward:kubectl -n decdn port-forward pod/decdn-node-1-0 9191 DECDN_ADMIN_URL=http://127.0.0.1:9191 decdn node health
The chart ships the deCDN Grafana dashboards and Prometheus alert rules in
files/monitoring/, which in this repo links to
monitoring/decdn-node/. With the Prometheus
Operator and a Grafana sidecar (kube-prometheus-stack), turn them on next to the
ServiceMonitor:
metrics:
networkPolicy:
from:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: monitoring}}
serviceMonitor:
enabled: true
deploymentEnvironment: prod # feeds the dashboards' $env variable
labels: {release: kube-prometheus-stack} # whatever your serviceMonitorSelector needs
prometheusRule:
enabled: true
labels: {release: kube-prometheus-stack} # ruleSelector
grafanaDashboards:
enabled: true # label/labelValue must match the sidecar's- The alert rules and dashboards select across every node Prometheus scrapes, and
the dashboards have fixed uids. Enable
prometheusRuleandgrafanaDashboardson one release per Prometheus/Grafana, or alerts fire once per release and dashboards collide. metrics.serviceMonitor.jobLabel(defaultdecdn-node) is thejobthe dashboards and theDecdnNodeDownalert expect.regioncomes fromconfig.identity.region.- Loki and Tempo panels stay empty unless the node's logs and traces reach those backends; the chart ships neither.
/metricsis the only HTTP route suitable for probes; there is no/healthendpoint. It binds only after startup completes: RPC preflight, identity and keystore load, cache and endpoint build, then the chain bring-up (CapacityBond registry and slash-watcher enumerations, blacklist and DHT bootstrap).- The default
startupProbeallows 5 minutes (5s × 60). A slow RPC delays readiness. - A failed bring-up, such as an unreachable RPC, exits the container and Kubernetes restarts it.
- Ready is not the same as serving. The paid-delivery QUIC listeners open only after
the first ContentBlacklist sync, which comes after
/metricsbinds. Check the logs before counting on traffic.
- The default
- SIGTERM runs a graceful drain.
terminationGracePeriodSecondsdefaults to 300, the role'sTimeoutStopSec. Killing a pod mid-drain loses paid deliveries, so do not force-delete it. - Config changes roll the pod through the ConfigMap checksum. Secret changes do not:
after rotating the keystore or RPC URL, run
kubectl rollout restart statefulset/<release>. The daemon's partial SIGHUP hot reload (log_level,pinned_hashes,[security],[content],[load_shed]) is not used, because a ConfigMap update reaches the mounted file with a delay and a restart is deterministic.
The node serves paid traffic only after it is staked and registered (ADR 019 Phase 2).
That is a manual operator step, as it is for the Ansible path. Run decdn setup, which
supports --dry-run, from your workstation against the same keystore you put in the
Secret.
make lint-helm # helm lint --strict, positive/negative render tests, kubeconform, schema keys
make security-helm # KICS over the rendered manifests (fail on HIGH)
DECDN_CLI=../decdn/target/release/decdn make lint-helm
# also run the real `decdn config validate` on every CI renderCI has no decdn binary, so it reports SKIPPED: decdn config validate. Run the
DECDN_CLI form locally whenever you bump the decdn version or change the renderer.
The ci/*.yaml values files mirror the three plays of the molecule schema scenario:
every knob with an S3 origin, the multi-origin list, and resolve-only discovery. When you
add a knob to one, add it to the other.