Building an Internal Developer CLI in Go for Kubernetes Environment Management: From Idea to Distribution
Why Your Team Needs a Dedicated CLI Instead of a Bundle of Scripts
Every growing platform team eventually finds itself in the same situation: the scripts/ repository turns into a chaos of Bash files, Makefile targets, and Python one-liners. A new developer spends an entire day just figuring out which script to run and in what order. CI/CD pipelines duplicate logic that already exists locally. One engineer updates a script and breaks a colleague's environment.
Common pain points familiar to DevOps engineers and platform teams:
- No single entry point for Kubernetes environment operations
- Scripts are undocumented, flags are passed via environment variables in arbitrary order
- Impossible to test scripts without a real cluster
- No versioning — it's unclear which script version is running in production
- Terminal autocompletion is unavailable, commands must be memorized
The solution is a custom internal CLI tool built in Go. A single binary, a strict interface, built-in documentation, testability, and straightforward distribution. This is exactly the path taken by teams at Spotify, Shopify, and Netflix when they built internal PaaS CLIs on top of Kubernetes. In 2026, Go remains the best choice for such tools thanks to its compilation speed, static typing, and native cross-compilation support.
Choosing the Stack: Cobra, client-go, and kubeconfig
Building a Go CLI Kubernetes tool relies on a battle-tested stack:
- Cobra — a framework for building CLIs with nested commands, flags, and autocompletion.
kubectlitself is built on Cobra. - client-go — the official Kubernetes API Go client. Enables programmatic creation, reading, and deletion of any cluster resources.
- viper — a configuration management library compatible with Cobra. Reads YAML files, environment variables, and CLI flags.
- kubeconfig — the standard authentication mechanism supporting multiple contexts and clusters.
Project initialization:
mkdir devctl && cd devctl
go mod init github.com/yourorg/devctl
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
go get k8s.io/client-go@latest
go get k8s.io/api@latest
go get k8s.io/apimachinery@latest
Base project structure:
devctl/
├── cmd/
│ ├── root.go # root command, Cobra initialization
│ ├── namespace.go # namespace commands
│ ├── deploy.go # application deployment
│ ├── logs.go # log viewing
│ └── version.go # tool version
├── internal/
│ ├── k8s/
│ │ └── client.go # client-go initialization
│ └── config/
│ └── config.go # configuration loading
├── main.go
└── go.mod
Root command in cmd/root.go:
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var rootCmd = &cobra.Command{
Use: "devctl",
Short: "Internal CLI for managing Kubernetes environments",
Long: `devctl — a platform team tool for creating namespaces,
deploying applications, and working with logs in Kubernetes clusters.`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: $HOME/.devctl.yaml)")
rootCmd.PersistentFlags().String("context", "", "kubeconfig context")
viper.BindPFlag("context", rootCmd.PersistentFlags().Lookup("context"))
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, _ := os.UserHomeDir()
viper.AddConfigPath(home)
viper.SetConfigName(".devctl")
}
viper.AutomaticEnv()
viper.ReadInConfig()
}
Implementing Commands: Namespace, Deploy, Logs
Initializing client-go with kubeconfig Support
package k8s
import (
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
func NewClient(kubeContext string) (*kubernetes.Clientset, error) {
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
configOverrides := &clientcmd.ConfigOverrides{}
if kubeContext != "" {
configOverrides.CurrentContext = kubeContext
}
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
loadingRules,
configOverrides,
).ClientConfig()
if err != nil {
return nil, err
}
return kubernetes.NewForConfig(config)
}
Namespace Creation Command
The command devctl namespace create <name> creates an isolated developer environment:
package cmd
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/yourorg/devctl/internal/k8s"
)
var namespaceCmd = &cobra.Command{
Use: "namespace",
Short: "Namespace operations",
}
var namespaceCreateCmd = &cobra.Command{
Use: "create [name]",
Short: "Create a namespace for a developer environment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
kubeCtx := viper.GetString("context")
client, err := k8s.NewClient(kubeCtx)
if err != nil {
return fmt.Errorf("failed to connect to cluster: %w", err)
}
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Labels: map[string]string{
"managed-by": "devctl",
"environment": "dev",
},
},
}
_, err = client.CoreV1().Namespaces().Create(
context.Background(), ns, metav1.CreateOptions{},
)
if err != nil {
return fmt.Errorf("error creating namespace: %w", err)
}
fmt.Printf("✓ Namespace '%s' created\n", name)
return nil
},
}
func init() {
namespaceCmd.AddCommand(namespaceCreateCmd)
rootCmd.AddCommand(namespaceCmd)
}
Application Deployment Command
The command devctl deploy --image=myapp:v1.2.3 --namespace=dev-alice creates or updates a Deployment:
var deployCmd = &cobra.Command{
Use: "deploy",
Short: "Deploy an application to Kubernetes",
RunE: func(cmd *cobra.Command, args []string) error {
image, _ := cmd.Flags().GetString("image")
ns, _ := cmd.Flags().GetString("namespace")
replicas, _ := cmd.Flags().GetInt32("replicas")
kubeCtx := viper.GetString("context")
client, err := k8s.NewClient(kubeCtx)
if err != nil {
return err
}
dep := buildDeployment(image, ns, replicas)
_, err = client.AppsV1().Deployments(ns).Apply(
context.Background(), dep, metav1.ApplyOptions{FieldManager: "devctl"},
)
if err != nil {
return fmt.Errorf("deploy failed: %w", err)
}
fmt.Printf("✓ Application %s deployed to %s\n", image, ns)
return nil
},
}
func init() {
deployCmd.Flags().String("image", "", "Docker image to deploy (required)")
deployCmd.Flags().String("namespace", "default", "Target namespace")
deployCmd.Flags().Int32("replicas", 1, "Number of replicas")
deployCmd.MarkFlagRequired("image")
rootCmd.AddCommand(deployCmd)
}
Log Viewing with Streaming
The command devctl logs --namespace=dev-alice --app=myapp --follow implements log streaming via client-go:
var logsCmd = &cobra.Command{
Use: "logs",
Short: "View application logs",
RunE: func(cmd *cobra.Command, args []string) error {
ns, _ := cmd.Flags().GetString("namespace")
app, _ := cmd.Flags().GetString("app")
follow, _ := cmd.Flags().GetBool("follow")
kubeCtx := viper.GetString("context")
client, err := k8s.NewClient(kubeCtx)
if err != nil {
return err
}
pods, err := client.CoreV1().Pods(ns).List(context.Background(), metav1.ListOptions{
LabelSelector: fmt.Sprintf("app=%s", app),
})
if err != nil || len(pods.Items) == 0 {
return fmt.Errorf("no pods found for application '%s'", app)
}
podName := pods.Items[0].Name
req := client.CoreV1().Pods(ns).GetLogs(podName, &corev1.PodLogOptions{
Follow: follow,
})
stream, err := req.Stream(context.Background())
if err != nil {
return err
}
defer stream.Close()
_, err = io.Copy(os.Stdout, stream)
return err
},
}
CI/CD Integration: Using the CLI in GitHub Actions
One of the strongest arguments for a Go CLI Kubernetes tool is the ability to reuse the same binary both locally and in CI/CD pipelines. This eliminates the entire class of "works on my machine" errors.
Example GitHub Actions workflow using devctl:
name: Deploy to Dev
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download devctl
run: |
curl -sSL https://github.com/yourorg/devctl/releases/latest/download/devctl-linux-amd64 \
-o /usr/local/bin/devctl
chmod +x /usr/local/bin/devctl
- name: Configure kubeconfig
run: |
echo "${{ secrets.KUBECONFIG_BASE64 }}" | base64 -d > $HOME/.kube/config
- name: Create namespace if not exists
run: devctl namespace create dev-${{ github.actor }} --context=dev-cluster
- name: Deploy application
run: |
devctl deploy \
--image=ghcr.io/yourorg/myapp:${{ github.sha }} \
--namespace=dev-${{ github.actor }} \
--context=dev-cluster
- name: Verify deployment
run: devctl status --namespace=dev-${{ github.actor }} --wait=60s
This CI/CD integration ensures idempotent operations and a single point of change: to update deployment logic, you only need to update the CLI rather than all pipelines simultaneously.
Secrets and Environment Configuration Management
A production-grade internal PaaS CLI requires secure secrets management. The recommended approach is to read secrets from environment variables or Kubernetes Secrets, never storing them in configuration files.
Example integration with Kubernetes Secrets via client-go:
func GetSecret(client *kubernetes.Clientset, ns, name string) (map[string][]byte, error) {
secret, err := client.CoreV1().Secrets(ns).Get(
context.Background(), name, metav1.GetOptions{},
)
if err != nil {
return nil, fmt.Errorf("secret '%s' not found in namespace '%s': %w", name, ns, err)
}
return secret.Data, nil
}
For environment configuration, use a ~/.devctl.yaml file with profiles:
default_context: dev-cluster
environments:
dev:
context: dev-cluster
registry: ghcr.io/yourorg
default_namespace: dev
staging:
context: staging-cluster
registry: ghcr.io/yourorg
default_namespace: staging
Viper automatically picks up environment variables with the DEVCTL_ prefix, such as DEVCTL_CONTEXT=staging-cluster, which is convenient for CI/CD without modifying the configuration file.
Building and Distributing via GitHub Releases and Docker Image
Distribution is a key aspect of developer tooling. A developer should be able to install the tool with a single command. Two distribution channels are recommended: binaries via GitHub Releases and a Docker image for CI/CD environments without local installation.
GoReleaser for Automated Builds
The .goreleaser.yaml file:
project_name: devctl
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X github.com/yourorg/devctl/cmd.Version={{.Version}}
- -X github.com/yourorg/devctl/cmd.CommitHash={{.Commit}}
- -X github.com/yourorg/devctl/cmd.BuildDate={{.Date}}
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
checksum:
name_template: checksums.txt
dockers:
- image_templates:
- ghcr.io/yourorg/devctl:{{ .Tag }}
- ghcr.io/yourorg/devctl:latest
dockerfile: Dockerfile.goreleaser
A minimal Dockerfile.goreleaser for a scratch-based image:
FROM scratch
COPY devctl /devctl
ENTRYPOINT ["/devctl"]
GitHub Actions workflow for releases:
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- uses: goreleaser/goreleaser-action@v5
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Once configured, developers install the tool with a single command:
curl -sSL https://github.com/yourorg/devctl/releases/latest/download/devctl-darwin-arm64.tar.gz | tar -xz
mv devctl /usr/local/bin/
Testing CLI Commands: Mocking the Kubernetes API
Testing is the weak point of most internal tooling. client-go provides the k8s.io/client-go/kubernetes/fake package for creating a fake client without a real cluster:
package cmd_test
import (
"testing"
"context"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestNamespaceCreate(t *testing.T) {
fakeClient := fake.NewSimpleClientset()
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "dev-testuser",
Labels: map[string]string{
"managed-by": "devctl",
},
},
}
_, err := fakeClient.CoreV1().Namespaces().Create(
context.Background(), ns, metav1.CreateOptions{},
)
if err != nil {
t.Fatalf("expected successful creation, got error: %v", err)
}
got, err := fakeClient.CoreV1().Namespaces().Get(
context.Background(), "dev-testuser", metav1.GetOptions{},
)
if err != nil {
t.Fatalf("namespace not found after creation: %v", err)
}
if got.Labels["managed-by"] != "devctl" {
t.Errorf("unexpected label value: %s", got.Labels["managed-by"])
}
}
For testing Cobra commands themselves, use a dependency injection pattern: accept kubernetes.Interface instead of a concrete type, allowing you to substitute a fake client in tests. Run tests with:
go test ./... -v -race -count=1
Best Practices: Backward Compatibility, Versioning, and Documentation
Versioning and Built-in Documentation
Every release should have a semantic version accessible via devctl version:
var versionCmd = &cobra.Command{
Use: "version",
Short: "devctl version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("devctl version %s (commit: %s, built: %s)\n",
Version, CommitHash, BuildDate)
},
}
Key Principles
- Flag backward compatibility: never remove existing flags — only mark them as deprecated using
cmd.Flags().MarkDeprecated(). This is critical when the CLI is used across dozens of CI/CD pipelines. - Explicit exit codes: use
os.Exit(1)only inmain.go; in all commands, return errors viaRunE. This simplifies testing and integration. - Autocompletion: Cobra generates autocompletion scripts for bash, zsh, and fish using
devctl completion zsh > ~/.zsh/_devctl. - Structured logging: use a
--output=jsonflag for machine-readable output in CI/CD and standard text format for developers. - Changelog and migrations: document breaking changes in
CHANGELOG.mdwith every minor release. Consider adevctl migratecommand for automatic configuration file migration. - Usage monitoring: add anonymous telemetry with an explicit opt-out to understand which commands are used most frequently and where users encounter errors.
Conclusion
Building an internal Developer CLI in Go is an investment that pays off quickly. A single binary replaces script chaos, accelerates onboarding for new developers, and makes CI/CD pipelines reproducible and testable. The Cobra + client-go + viper combination covers 90% of a platform team's needs when working with Kubernetes environments.
Start with three commands — namespace create, deploy, and logs — and iteratively add functionality based on team requests. Use GoReleaser to automate distribution and the fake client-go in tests to avoid depending on a real cluster in CI/CD. Follow backward compatibility principles from day one, and your internal PaaS CLI will become a tool your team is proud of.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →