Single-group layout (default):
cmd/main.go Manager entry (registers controllers/webhooks)
api/<version>/*_types.go CRD schemas (+kubebuilder markers)
api/<version>/zz_generated.* Auto-generated (DO NOT EDIT)
internal/controller/* Reconciliation logic
internal/webhook/* Validation/defaulting (if present)
config/crd/bases/* Generated CRDs (DO NOT EDIT)
config/rbac/role.yaml Generated RBAC (DO NOT EDIT)
config/samples/* Example CRs (edit these)
Makefile Build/test/deploy commands
PROJECT Kubebuilder metadata Auto-generated (DO NOT EDIT)
Multi-group layout (for projects with multiple API groups):
api/<group>/<version>/*_types.go CRD schemas by group
internal/controller/<group>/* Controllers by group
internal/webhook/<group>/<version>/* Webhooks by group and version (if present)
Multi-group layout organizes APIs by group name (e.g., batch, apps). Check the PROJECT file for multigroup: true.
To convert to multi-group layout:
kubebuilder edit --multigroup=truemkdir -p api/<group> && mv api/<version> api/<group>/mkdir -p internal/controller/<group> && mv internal/controller/*.go internal/controller/<group>/mkdir -p internal/webhook/<group> && mv internal/webhook/<version> internal/webhook/<group>/path in PROJECT file for each resource.. to relative paths)config/crd/bases/*.yaml - from make manifestsconfig/rbac/role.yaml - from make manifestsconfig/webhook/manifests.yaml - from make manifests**/zz_generated.*.go - from make generatePROJECT - from kubebuilder [OPTIONS]Do NOT delete // +kubebuilder:scaffold:* comments. CLI injects code at these markers.
Do not move files around. The CLI expects files in specific locations.
Always use kubebuilder create api and kubebuilder create webhook to scaffold. Do NOT create files manually.
The e2e tests are designed to validate the solution in an isolated environment (similar to GitHub Actions CI). Ensure you run them against a dedicated Kind cluster (not your “real” dev/prod cluster).
After editing *_types.go or markers:
make manifests # Regenerate CRDs/RBAC from markers
make generate # Regenerate DeepCopy methods
After editing *.go files:
make lint-fix # Auto-fix code style
make test # Run unit tests
kubebuilder create api --group <group> --version <version> --kind <Kind>
Generate a controller that deploys and manages a container image (nginx, redis, memcached, your app, etc.):
# Example: deploying memcached
kubebuilder create api --group example.com --version v1alpha1 --kind Memcached \
--image=memcached:alpine \
--plugins=deploy-image.go.kubebuilder.io/v1-alpha
Scaffolds good-practice code: reconciliation logic, status conditions, finalizers, RBAC. Use as a reference implementation.
# Validation + defaulting
kubebuilder create webhook --group <group> --version <version> --kind <Kind> \
--defaulting --programmatic-validation
# Conversion webhook (for multi-version APIs)
kubebuilder create webhook --group <group> --version v1 --kind <Kind> \
--conversion --spoke v2
# Watch Pods
kubebuilder create api --group core --version v1 --kind Pod \
--controller=true --resource=false
# Watch Deployments
kubebuilder create api --group apps --version v1 --kind Deployment \
--controller=true --resource=false
Watch resources from external APIs (cert-manager, Argo CD, Istio, etc.):
# Example: watching cert-manager Certificate resources
kubebuilder create api \
--group cert-manager --version v1 --kind Certificate \
--controller=true --resource=false \
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
--external-api-domain=io \
--external-api-module=github.com/cert-manager/cert-manager
Note: Use --external-api-module=<module>@<version> only if you need a specific version. Otherwise, omit @<version> to use what's in go.mod.
# Example: validating external resources
kubebuilder create webhook \
--group cert-manager --version v1 --kind Issuer \
--defaulting \
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
--external-api-domain=io \
--external-api-module=github.com/cert-manager/cert-manager
make test # Run unit tests (uses envtest: real K8s API + etcd)
make run # Run locally (uses current kubeconfig context)
Tests use Ginkgo + Gomega (BDD style). Check suite_test.go for setup.
# 1. Regenerate manifests
make manifests generate
# 2. Build & deploy
export IMG=<registry>/<project>:tag
make docker-build docker-push IMG=$IMG # Or: kind load docker-image $IMG --name <cluster>
make deploy IMG=$IMG
# 3. Test
kubectl apply -k config/samples/
# 4. Debug
kubectl logs -n <project>-system deployment/<project>-controller-manager -c manager -f
Key markers for api/<version>/*_types.go:
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=".status.conditions[?(@.type=='Ready')].status"
// On fields:
// +kubebuilder:validation:Required
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:MaxLength=100
// +kubebuilder:validation:Pattern="^[a-z]+$"
// +kubebuilder:default="value"
metav1.Condition for status (not custom string fields)metav1.Time instead of string for datesspec, status, metadata)RBAC markers in internal/controller/*_controller.go:
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/finalizers,verbs=update
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
Implementation rules:
r.Get(ctx, req.NamespacedName, obj) before r.Update to avoid conflictslog := log.FromContext(ctx); log.Info("msg", "key", val)SetControllerReference).Owns() or .Watches(), not just RequeueAfterFollow Kubernetes logging message style guidelines:
"Deployment could not create Pod") or omitted ("Could not create Pod")"Could not delete Pod" not "Cannot delete Pod""Deleted Pod" not "Deleted"log.Info("Starting reconciliation")
log.Info("Created Deployment", "name", deploy.Name)
log.Error(err, "Failed to create Pod", "name", name)
--defaulting --programmatic-validation --conversion--forceis used: Backup custom logic first, then restore after scaffolding--conversion --spoke v2)
--group crew --version v1 --kind Captain --conversion --spoke v2 (v1 is hub, v2 is spoke)The deploy-image plugin scaffolds a complete controller following good practices. Use it as a reference implementation:
kubebuilder create api --group example --version v1alpha1 --kind MyApp \
--image=<your-image> --plugins=deploy-image.go.kubebuilder.io/v1-alpha
Generated code includes: status conditions (metav1.Condition), finalizers, owner references, events, idempotent reconciliation.
# Generate dist/install.yaml from Kustomize manifests
make build-installer IMG=<registry>/<project>:tag
Key points:
dist/install.yaml is generated from Kustomize manifests (CRDs, RBAC, Deployment)kubectl to install (no additional tools required)Example: Users install with a single command:
kubectl apply -f https://raw.githubusercontent.com/<org>/<repo>/<tag>/dist/install.yaml
kubebuilder edit --plugins=helm/v2-alpha # Generates dist/chart/ (default)
kubebuilder edit --plugins=helm/v2-alpha --output-dir=charts # Generates charts/chart/
For development:
make helm-deploy IMG=<registry>/<project>:<tag> # Deploy manager via Helm
make helm-deploy IMG=$IMG HELM_EXTRA_ARGS="--set ..." # Deploy with custom values
make helm-status # Show release status
make helm-uninstall # Remove release
make helm-history # View release history
make helm-rollback # Rollback to previous version
For end users/production:
helm install my-release ./<output-dir>/chart/ --namespace <ns> --create-namespace
Important: If you add webhooks or modify manifests after initial chart generation:
<output-dir>/chart/values.yaml and <output-dir>/chart/manager/manager.yamlkubebuilder edit --plugins=helm/v2-alpha --force (use same --output-dir if customized)export IMG=<registry>/<project>:<version>
make docker-build docker-push IMG=$IMG