Declarative cost guardrails for DigitalOcean Kubernetes node pools. Set a monthly budget as a Kubernetes resource, and get Slack alerts before the invoice surprises you.
Kubernetes spend is invisible right up until the invoice lands. A node pool quietly scales up under load, stays up, and the first time anyone notices is on the first of the month when finance asks why the DigitalOcean bill doubled.
The DOKS Cost Enforcer Operator makes that spend visible β as code, in your cluster, in real time.
You declare a budget for a node pool as a Kubernetes resource. The operator polls DigitalOcean every five minutes, projects the pool's monthly run-rate, and posts to Slack the moment you cross 80% (warning) or 100% (cap reached) of that budget β plus a daily digest so the number is never a mystery. Everything also shows up in kubectl get nodepoolcostpolicies.
Think of it as a smoke detector for your node-pool bill.
- π Budgets as code β a
NodePoolCostPolicyCRD lives next to your manifests, version-controlled and reviewable in PRs. - π Live run-rate projection β
nodeCount Γ hourlyCostPerNode Γ 730recomputed every poll, surfaced in status andkubectl. - π Tiered Slack alerts β a
β οΈ warning at β₯80% and a π¨ critical at β₯100% of the cap. - π Daily digest β a once-a-day cost summary to Slack regardless of thresholds, so the number stays top of mind.
- π€« No alert spam β a small state machine only fires on threshold transitions, then re-arms when cost recovers.
- π kubectl-native β printer columns show Cap, Est. Cost, % of Cap and Alert level at a glance.
- π Hardened by default β runs non-root with a read-only root filesystem and all Linux capabilities dropped.
- πͺΆ Featherweight β
10mCPU /64Mimemory requested. It mostly sleeps. - π Secrets stay secret β DO token and Slack webhook are injected from Kubernetes Secrets; a
kubesealscript ships for GitOps-safe SealedSecrets.
flowchart TD
A["NodePoolCostPolicy CR<br/>clusterID Β· poolID Β· nodeSize<br/>hourlyCostPerNode Β· monthlyCap"] -->|"poll every 5 min"| B[Cost Enforcer]
B --> C{"DO_TOKEN &<br/>SLACK_WEBHOOK_URL set?"}
C -->|No| E1["Condition: MissingEnv β requeue"]
C -->|Yes| D["GET node pool from DigitalOcean API"]
D -->|error| E2["Condition: DOAPIError β requeue"]
D -->|ok| F["cost = nodes Γ hourlyRate Γ 730<br/>pct = cost Γ· cap Γ 100"]
F --> G{"pct vs thresholds"}
G -->|"β₯100% & not already critical"| H1["Slack π¨ critical"]
G -->|"80β100% & previously clear"| H2["Slack β οΈ warning"]
G -->|"< 80%"| H3["reset alert level β re-arm"]
F --> I{"β₯ 24h since<br/>last digest?"}
I -->|Yes| J["Slack π daily digest"]
H1 --> K["update .status + Ready condition"]
H2 --> K
H3 --> K
J --> K
K --> L["requeue after 5 min"]
Every five minutes, for each policy, the operator:
- Loads credentials β
DO_TOKENandSLACK_WEBHOOK_URLfrom the environment (injected from Secrets). If either is missing it records aMissingEnvcondition and tries again later instead of crash-looping. - Polls DigitalOcean β fetches the node pool by
clusterID+poolID. On API failure it recordsDOAPIErrorand requeues. - Projects the cost β
currentMonthlyCost = nodeCount Γ hourlyCostPerNode Γ 730(730 β hours in an average month), andpercentOfCap = cost / monthlyCap Γ 100. - Decides on alerts β see the state machine below.
- Sends the daily digest β if it's been β₯24h since the last one.
- Writes status β node count, projected cost, % of cap, alert level, timestamps, and a
Readycondition with a human summary likeest. $584.00 (83.4% of cap).
lastAlertLevel remembers what was last announced β "" (clear), warning, or critical:
| Current cost | Last level | Action |
|---|---|---|
| β₯ 100% | not critical |
send π¨ critical, set level β critical |
| 80β100% | "" (clear) |
send warning |
| < 80% | any | reset level β "" (re-arms both alerts) |
The upshot: each threshold announces itself once when you cross into it, not on every poll. Dropping from critical back into the warning band stays quiet (no "downgrade" noise), and once cost falls below 80% the alarms re-arm for the next climb. The daily digest is independent and always sends every 24h.
- A running DOKS cluster
- A DigitalOcean API token with read access to Kubernetes
- A Slack incoming webhook URL
kubectlpointed at the cluster, plusGo 1.22+,Docker, andmaketo build- (optional, for GitOps) the sealed-secrets controller +
kubeseal
make docker-build docker-push IMG=<your-registry>/doks-cost-enforcer:0.1.0make installkubectl create namespace doks-cost-enforcer-system
# DigitalOcean token β key MUST be 'token'
kubectl create secret generic do-token \
--namespace doks-cost-enforcer-system \
--from-literal=token="<YOUR_DO_API_TOKEN>"
# Slack webhook β key MUST be 'url'
kubectl create secret generic slack-webhook \
--namespace doks-cost-enforcer-system \
--from-literal=url="https://hooks.slack.com/services/XXX/YYY/ZZZ"π‘ GitOps tip: prefer not to hand a plaintext token to
kubectl? Run the bundledhack/seal.shto produce committable SealedSecrets withkubeseal. Point--controller-namespaceat wherever your sealed-secrets controller runs (commonlykube-system).
make deploy IMG=<your-registry>/doks-cost-enforcer:0.1.0# config/samples/nodepoolcostpolicy.yaml
apiVersion: platform.mahy.love/v1alpha1
kind: NodePoolCostPolicy
metadata:
name: chat-pool-cost
spec:
clusterID: "your-cluster-uuid"
poolID: "your-pool-uuid"
nodeSize: "s-8vcpu-16gb" # label only β shown in Slack for context
hourlyCostPerNode: 0.20 # USD/hr β look up the live rate for your size
monthlyCap: 700 # USD/mo budgetkubectl apply -f config/samples/nodepoolcostpolicy.yamlkubectl get nodepoolcostpoliciesNAME POOL CAP ($) EST. COST ($) % OF CAP ALERT AGE
chat-pool-cost a1b2c3d4-...-pool 700 584.00 83.4 warning 2d
β¦and the matching message lands in your Slack channel.
spec fields of a NodePoolCostPolicy (all required):
| Field | Type | Description |
|---|---|---|
clusterID |
string | DigitalOcean Kubernetes cluster UUID. |
poolID |
string | DigitalOcean node pool UUID to monitor. |
nodeSize |
string | Human-readable size label (e.g. s-8vcpu-16gb). Used only for context in Slack messages. |
hourlyCostPerNode |
number | USD/hour for a single node of this size. You set this β see the caveat below. |
monthlyCap |
number | Monthly budget in USD. Warning fires at 80%, critical at 100%. |
| Status field | Meaning |
|---|---|
currentNodeCount |
Nodes reported by DO on the last poll. |
currentMonthlyCost |
nodeCount Γ hourlyCostPerNode Γ 730, rounded to cents. |
percentOfCap |
currentMonthlyCost Γ· monthlyCap Γ 100. |
lastAlertLevel |
"" (none) Β· warning (β₯80%) Β· critical (β₯100%). |
lastAlertSentAt / lastDigestSentAt / lastPollTime |
Timestamps for the last alert, digest, and successful poll. |
conditions[Ready] |
Overall health + a human summary string. |
| Slack message | When it fires | Colour |
|---|---|---|
| crossing into 80β100% of cap from a clear state | orange | |
| π¨ Critical | crossing into β₯100% of cap | red |
| π Daily digest | every 24h, regardless of threshold | green / orange / red by level |
Wait on a policy in scripts via the standard condition:
kubectl wait nodepoolcostpolicy/chat-pool-cost \
--for=condition=Ready --timeout=120sexport DO_TOKEN="<YOUR_DO_API_TOKEN>"
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ"
make install # CRD into the cluster
make run # run the manager locally against your kubeconfigOther targets:
make manifests # regenerate CRD + RBAC from kubebuilder markers
make generate # regenerate deepcopy code
make test # unit tests
make build # compile to ./bindoks-cost-enforcer-operator/
βββ api/v1alpha1/ # CRD types (NodePoolCostPolicySpec/Status)
βββ internal/
β βββ controller/ # reconcile loop + thresholds/constants
β βββ do/ # thin godo wrapper: GetNodePool
β βββ slack/ # webhook payload builders + senders
βββ config/ # kustomize bases (CRD, RBAC, manager, samples)
β βββ deploy/ # generated SealedSecrets land here
βββ hack/seal.sh # kubeseal helper for do-token + slack-webhook
βββ Dockerfile
βββ Makefile
- It's an estimate, not your invoice. The projection covers node-pool compute only. It does not include load balancers, block storage / snapshots, bandwidth, container registry, or HA control-plane fees. Treat it as a run-rate signal, not an accounting figure.
hourlyCostPerNodeis hand-entered. The operator doesn't fetch live pricing β if DigitalOcean changes rates or you resize the pool, update the policy. Keeping it accurate is on you.- It alerts; it doesn't (yet) enforce. Despite the name, today it warns β it won't cap, cordon, or scale a pool down on its own. Automated enforcement is a natural next step, not a current feature.
- Linear run-rate. Cost = current node count projected across a full month (Γ730h). A temporary spike inflates the projection; a quiet stretch deflates it. That's intentional for "where are we trending right now," but it's not a month-end forecast.
- One pool per policy. Create a
NodePoolCostPolicyper pool you want watched.
Pairs naturally with the DOKS Capacity Operator β that one raises a pool's autoscale ceiling as demand grows; this one watches the bill that growth produces. Capacity and cost, as two small declarative controllers.
Issues and PRs welcome. Run make test and make manifests before opening a PR so generated artifacts stay in sync.
Apache License 2.0.
Built with β€οΈ and kubebuilder Β· github.com/tabed23/doks-cost-enforcer-operator