offhours-schedule-operator is a Kubernetes controller that reduces selected
Deployment replicas outside configured business hours and restores their
previous replica counts when business hours resume.
For the design story and the reasoning behind the final architecture, read From a Simple Schedule to a Memory-Conscious Kubernetes Operator.
Each OffHoursSchedule defines:
- an IANA timezone and business-hours window;
- the weekdays on which the window applies;
- the namespaces the controller may inspect;
- a Kubernetes label selector used to opt Deployments in;
- the replica count used during off-hours;
- Helm releases and individual workloads to exclude.
For every namespace in spec.namespaces, the controller sends a direct API
request for Deployments matching spec.deploymentSelector. It then skips:
- Deployments labeled
offhours/skip: "true"; - Deployments with a future RFC 3339
offhours/skip-untilannotation; - Deployments belonging to a Helm release in
spec.excludeHelmReleases.
During off-hours, the controller stores the current replica count in the
offhours.erickyataco.github.io/previous-replicas annotation and applies
spec.replicasOffHours. During business hours, it restores the saved value and
removes the annotation.
The controller does not watch or cache Deployments or ReplicaSets. Workload
discovery uses an uncached APIReader, scoped by namespace and label selector.
Only OffHoursSchedule resources use a controller informer. Kubernetes' built-in
Deployment controller manages the resulting ReplicaSets and Pods.
- Go 1.25.3 or newer
- Docker or another compatible container builder
kubectl- access to a Kubernetes cluster
- kind, when running the isolated end-to-end tests
Confirm the cluster targeted by your active kubeconfig before running the operator:
kubectl config current-contextmake install
make runmake install installs the OffHoursSchedule CRD into the active cluster.
make run compiles and starts the controller manager as a process on your local
machine. Keep that terminal open while testing.
Important: make run does not create an operator Pod. The local process
connects to the active Kubernetes cluster using your kubeconfig, so it will not
appear in kubectl get pods output.
Pods such as CoreDNS, etcd, kube-apiserver, kube-scheduler, and
kube-controller-manager are cluster system components. They are not this
operator.
Build and push an image that the cluster can pull:
export IMG=<registry>/offhours-schedule-operator:<tag>
make docker-build docker-push IMG="$IMG"
make deploy IMG="$IMG"The deployment installs the CRD, RBAC resources, service account, and controller
manager in the offhours-schedule-operator-system namespace.
Verify the operator Pod:
kubectl get pods -n offhours-schedule-operator-system
kubectl logs \
-n offhours-schedule-operator-system \
deployment/offhours-schedule-operator-controller-manager \
-c manager -fFor a kind cluster named offhours, build and load the image locally:
export IMG=offhours-schedule-operator:dev
make docker-build IMG="$IMG"
kind load docker-image "$IMG" --name offhours
make deploy IMG="$IMG"If the Deployment attempts to pull the local image instead of using the image
loaded into kind, set a non-Always image pull policy in the manager manifest.
The sample schedule manages opted-in Deployments in dev and staging:
kubectl apply -k config/samplesEquivalent resource:
apiVersion: offhours.erickyataco.github.io/v1alpha1
kind: OffHoursSchedule
metadata:
name: nonprod-offhours
spec:
timezone: America/Lima
businessStart: "08:00"
businessEnd: "18:00"
businessDays: [Mon, Tue, Wed, Thu, Fri]
namespaces:
- dev
- staging
deploymentSelector: "offhours/enabled=true"
scaleDownOffHours: true
replicasOffHours: 0
excludeHelmReleases:
- argocd
- external-secretsInspect reconciliation status with:
kubectl get offhoursschedules
kubectl get offhoursschedule nonprod-offhours -o yamlStatus reports the current mode, the next transition, the observed generation,
matched/skipped/failed Deployment counts, and a standard Ready condition.
The scenario in
config/test-scenarios/all-cases
covers:
- matching Deployments in configured namespaces;
- a Deployment that does not match the selector;
- an excluded Helm release;
- permanent and temporary workload opt-outs;
- a matching Deployment outside the configured namespaces;
- off-hours scale-down and business-hours restoration.
Install the CRD and start or deploy the operator first, then run:
kubectl apply -k config/test-scenarios/all-casesInspect the scenario:
kubectl get offhoursschedule -n offhours-system all-cases -o yaml
kubectl get deployments -n offhours-test-dev
kubectl get deployments -n offhours-test-staging
kubectl get deployments -n offhours-test-outsideGenerate manifests and code after changing API types or Kubebuilder markers:
make manifests generateRun formatting, linting, and tests:
make lint-fix
make testRun isolated end-to-end tests with kind:
make test-e2e
make cleanup-test-e2eBuild the manager binary:
make buildGenerate a consolidated installation bundle:
make build-installer IMG=<registry>/offhours-schedule-operator:<tag>Run make help to list all supported targets.
Remove sample and test resources before deleting the CRD:
kubectl delete -k config/samples --ignore-not-found
kubectl delete -k config/test-scenarios/all-cases --ignore-not-foundRemove the in-cluster controller and CRD:
make undeploy
make uninstallCopyright 2026.
Licensed under the Apache License, Version 2.0. See the license text for details.