# Setting up a local Kubernetes cluster with minikube

*This is part of the* ***Platform Engineering with Go*** *series.* [*View all posts in the series*](https://ferztyle.me/series/platform-engineering-with-go)

* * *

# Every post in this series assumes you have a Kubernetes cluster

And that's a problem, because most engineers don't have one sitting around they can freely experiment on. Spinning up an EKS cluster just to follow a tutorial costs money, requires an AWS account, and takes longer than you want. Using your production cluster is obviously off the table.

minikube fixes this. It gives you a real, full Kubernetes cluster running on your laptop in under 5 minutes. Same API as production. Free. No cloud account needed. When you're done for the day, you stop it. When things get messy, you delete it and start fresh.

By the end of this post you'll have a local cluster running with metrics-server enabled and a Go app deployed inside it.

* * *

# What you'll learn

*   What minikube is and how it relates to a real production cluster
    
*   How to install minikube on Mac and Linux
    
*   How to start, stop, and manage a local cluster
    
*   How minikube connects to kubectl and your kubeconfig
    
*   How to enable the metrics-server addon
    
*   How to deploy a real Go app to your local cluster
    

* * *

# Prerequisites

*   Mac or Linux, this post does not cover Windows
    
*   Docker installed and running, minikube uses it as the default driver
    

We'll install kubectl as part of this post, so you don't need it yet.

* * *

# What minikube actually is

Before installing anything, it's worth understanding what minikube does, and what it doesn't do.

minikube runs a single-node Kubernetes cluster locally, inside a Docker container (or VM) on your machine. That cluster runs the exact same Kubernetes API as the production clusters you'd find on EKS, GKE, or AKS. The same `kubectl` commands work. The same YAML manifests work. The same Go code works, which is exactly why it's the right tool for this series.

What minikube is not: a production cluster. It's a single node with no high availability, no cloud integrations, and no autoscaling. It's a development and learning environment, not something you'd run workloads on.

## How it compares to alternatives

There are several tools for running Kubernetes locally. Here's how they compare so you understand why we're using minikube:

| Tool | Best for | Why we chose minikube |
| --- | --- | --- |
| minikube | Learning and development | Addons system, excellent docs, most widely used |
| kind | CI pipelines | Lighter than minikube, less convenient for local dev |
| k3s | Lightweight production | More complex to set up locally |
| Docker Desktop K8s | Quick start | Heavier, less control over the cluster |

The addons system is the main reason we use minikube here. Enabling metrics-server is a single command with minikube. Other tools require manual installation and configuration.

* * *

# Installing minikube

## Mac

```shell
# Install minikube via Homebrew
brew install minikube

# Verify the install
minikube version
# minikube version: v1.38.1
```

## Linux

```shell
# Download the minikube binary
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64

# Make it executable and move it to your PATH
chmod +x minikube-linux-amd64
sudo mv minikube-linux-amd64 /usr/local/bin/minikube

# Verify the install
minikube version
# minikube version: v1.32.0
```

* * *

# Installing kubectl

kubectl is the command-line tool for interacting with Kubernetes clusters. minikube configures it automatically, but you need to install it separately.

## Mac

```shell
brew install kubectl

# Verify
kubectl version --client
# Client Version: v1.36.2
```

## Linux

```shell
# Download the latest stable kubectl binary
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"

# Make it executable and move to PATH
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl

# Verify
kubectl version --client
# Client Version: v1.29.0
```

* * *

# Starting your first cluster

With minikube and kubectl installed, starting a cluster is one command:

```shell
minikube start
```

You'll see output like this as minikube works through the startup sequence:

```plaintext
😄  minikube v1.38.1 on Darwin 26.5.1 (arm64)
✨  Automatically selected the docker driver
❗  Starting v1.39.0, minikube will default to "containerd" container runtime. See #21973 for more info.
📌  Using Docker Desktop driver with root privileges
👍  Starting "minikube" primary control-plane node in "minikube" cluster
🚜  Pulling base image v0.0.50 ...
🔥  Creating docker container (CPUs=2, Memory=8100MB) ...
🐳  Preparing Kubernetes v1.35.1 on Docker 29.2.1 ...
🔗  Configuring bridge CNI (Container Networking Interface) ...
🔎  Verifying Kubernetes components...
    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🌟  Enabled addons: storage-provisioner, default-storageclass
🏄  Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default
```

What happened in those few seconds:

*   minikube pulled a Docker image containing a full Kubernetes cluster.
    
*   It started a Docker container running that image.
    
*   It generated TLS certificates for the API server.
    
*   It configured kubectl to talk to this new cluster by writing a new context to `~/.kube/config`
    

## Configuring resources

The default CPUs and memory are sometimes not enough, especially once you have several pods running. We recommend starting with a bit more:

```shell
# Delete any existing cluster first, then start with more resources
minikube delete
minikube start --cpus=4 --memory=4096
```

## Pinning the Kubernetes version

If you want to match your production cluster's Kubernetes version exactly, (I'm not going to run this; I want the last version 1.35.1):

```shell
minikube start --kubernetes-version=v1.28.3
```

## Verifying the cluster is running

```shell
# Check the node is Ready
kubectl get nodes

# NAME       STATUS   ROLES           AGE     VERSION
# minikube   Ready    control-plane   4m59s   v1.35.1

# Check the system pods are all running
kubectl get pods -A

# NAMESPACE     NAME                               READY   STATUS    RESTARTS        AGE
# kube-system   coredns-7d764666f9-pzqlt           1/1     Running   0               5m33s
# kube-system   etcd-minikube                      1/1     Running   0               5m39s
# kube-system   kube-apiserver-minikube            1/1     Running   0               5m39s
# kube-system   kube-controller-manager-minikube   1/1     Running   0               5m39s
# kube-system   kube-proxy-2l7h5                   1/1     Running   0               5m33s
# kube-system   kube-scheduler-minikube            1/1     Running   0               5m39s
# kube-system   storage-provisioner                1/1     Running   1 (5m32s ago)   5m38s
```

If all pods show `Running`, your cluster is healthy.

You can also see the docker container running on behalf our minikube cluster

```shell
docker ps -a                                                                                                                                                     
# CONTAINER ID   IMAGE                                 COMMAND                  CREATED          STATUS          PORTS                                                                                                                                  NAMES
# de39b5440365   gcr.io/k8s-minikube/kicbase:v0.0.50   "/usr/local/bin/entr…"   10 minutes ago   Up 10 minutes   127.0.0.1:60921->22/tcp, 127.0.0.1:60923->2376/tcp, 127.0.0.1:60922->5000/tcp, 127.0.0.1:60925->8443/tcp, 127.0.0.1:60924->32443/tcp   minikube
```

* * *

## Managing your cluster

You'll use these commands constantly throughout the series:

```shell
# Pause the cluster — preserves all state, frees up resources
minikube stop

# Resume where you left off
minikube start

# Delete everything and start completely fresh
# Use this when things get into a bad state
minikube delete

# Check what's currently running
minikube status

# Open the Kubernetes dashboard in your browser
# Useful for getting a visual overview of your cluster
minikube dashboard

# SSH into the minikube node
# Useful for low-level debugging
minikube ssh
```

A practical workflow: `minikube stop` at the end of every session, `minikube start` when you come back. `minikube delete && minikube start` When something is broken, and you want a clean slate.

* * *

# What is metrics-server?

Before we enable it, it's worth understanding what metrics-server actually is and where it fits in the Kubernetes ecosystem.

metrics-server is a lightweight, in-memory component that runs inside your Kubernetes cluster and collects resource usage data, including CPU and memory, from every node and pod. It does this by talking to the kubelet (the agent that runs on each node) and scraping the resource stats it exposes.

```plaintext
┌─────────────────────────────────────────┐
│           Kubernetes cluster            │
│                                         │
│  ┌──────────┐     scrapes    ┌────────┐ │
│  │  kubelet │ ◄────────────  │metrics │ │
│  │ (node 1) │               │ server │ │
│  └──────────┘               └───┬────┘ │
│                                 │      │
│  ┌──────────┐                   │ exposes
│  │  kubelet │ ◄─────────────────┘      │
│  │ (node 2) │      Metrics API         │
│  └──────────┘           ▲             │
│                          │             │
└──────────────────────────┼─────────────┘
                           │
              kubectl top / your Go code
```

The data metrics-server collects is exposed through the **Metrics API**, a standard Kubernetes API endpoint that both `kubectl top` and, client-go can query. When you run `kubectl top pods`, kubectl is calling the Metrics API.

## What metrics-server is not

It's worth being clear about what metrics-server doesn't do, because it's a common source of confusion:

*   It does **not** store historical data. It only keeps the most recent sample in memory. If you want trends over time, you need Prometheus.
    
*   It does **not** replace Prometheus or Grafana. It's a lightweight source of current usage data, not a full monitoring stack.
    
*   It does **not** run by default on most clusters. On EKS, GKE, and AKS it's usually pre-installed, but on self-managed clusters, and on minikube, you have to enable it yourself.
    

Think of it as the simplest possible answer to the question: "what is my cluster using right now?" For anything more sophisticated, alerting, historical analysis, dashboards, you'd reach for Prometheus.

* * *

# Enabling metrics-server

minikube has an add-ons system, a curated list of optional components you can enable with a single command. metrics-server is one of them:

```shell
# Enable metrics-server
minikube addons enable metrics-server

# Verify it's running
kubectl get deployment metrics-server -n kube-system

# NAME             READY   UP-TO-DATE   AVAILABLE
# metrics-server   1/1     1            1
```

Wait about 60 seconds, then verify it's collecting data:

```shell
# Check node resource usage
kubectl top nodes

# NAME       CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
# minikube   185m         4%     1042Mi          26%

# Check pod resource usage across all namespaces
kubectl top pods -A
```

If `kubectl top` returns `error: metrics not available yet`, just wait another 30 seconds and try again. metrics-server takes a moment to scrape its first round of data.

* * *

# Deploying a real Go app to your local cluster

An empty cluster is fine for testing connections, but what we actually want is a cluster with real pods running, pods we can query with the health reporter.

Let's deploy a simple Go HTTP server properly, built with a multi-stage Dockerfile, loaded into minikube, and deployed with real resource requests and limits.

## Why we use a Dockerfile

You might wonder why we don't just use `go run` inside the container for simplicity. The answer ties directly back to post 1: resource management.

`go run` compiles your code at container start, and the Go compiler needs over 1GB of memory just to compile a hello world app. That forces you to set absurdly high memory limits for a tiny service, which defeats the entire purpose of a post about resource management. It also causes OOMKill on any reasonable memory limit, and makes startup slow.

The right approach, and the one every real Go service uses, is to compile the binary at image build time. The running container only needs the binary, not the compiler. Memory usage drops from 1GB to under 20MB:

```plaintext
go run approach:     limits.memory: 1024Mi  ← embarrassing for a hello world
Dockerfile approach: limits.memory: 128Mi    ← honest and production-realistic
```

## The project structure

Create a new directory for this example:

```shell
mkdir go-api && cd go-api
```

Create three files:

```plaintext
go-api/
├── main.go        ← the Go HTTP server
├── Dockerfile     ← multi-stage build
└── deployment.yaml ← Kubernetes manifests
```

## The application

```go
// main.go
package main

import (
    "fmt"
    "log/slog"
    "net/http"
    "os"
)

func main() {
    // Handle the root endpoint
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "hello from Go on Kubernetes!")
    })

    // Health endpoint — used by readiness and liveness probes.
    // Always returns 200 OK as long as the server is running.
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    slog.Info("server starting", "port", port)
    if err := http.ListenAndServe(":"+port, nil); err != nil {
        slog.Error("server failed", "error", err)
        os.Exit(1)
    }
}
```

## The Dockerfile

This is a multi-stage build, one stage compiles the binary, a second minimal stage runs it. The final image contains only the binary and nothing else, no Go toolchain, no compiler, no source code:

```shell
# Stage 1: builder
# Uses the full Go image to compile the binary.
# This stage is discarded after the build — it never ships.
FROM golang:1.26-alpine AS builder

WORKDIR /app

# Copy the source code
COPY main.go .

# Initialize a Go module — needed for go build
RUN go mod init github.com/yourname/go-api

# Build a statically linked binary.
# CGO_ENABLED=0 produces a fully static binary with no external dependencies.
# -ldflags="-s -w" strips debug symbols to reduce binary size.
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .

# Stage 2: runtime
# Uses a minimal Alpine image — no Go toolchain, no compiler.
# The final image is typically under 10MB.
FROM alpine:3.24

# Run as a non-root user — a basic security best practice.
RUN addgroup -S app && adduser -S app -G app
USER app

WORKDIR /app

# Copy only the compiled binary from the builder stage.
COPY --from=builder /app/server .

EXPOSE 8080

CMD ["./server"]
```

## Build the image and load it into minikube

minikube runs its own Docker daemon, separate from your laptop's Docker. To use a locally built image in minikube, you need to build it inside minikube's Docker environment:

```shell
# Point your terminal's Docker CLI at minikube's Docker daemon
eval $(minikube docker-env)

# Build the image — this builds directly inside minikube
docker build -t go-api:latest .

# Verify the image is available inside minikube
docker images | grep go-api
# go-api   latest   a1b2c3d4e5f6   10 seconds ago   14.3MB
```

Notice the image size, 14.3MB. Compare that to the `golang:1.26-alpine` base image which is ~300MB. **Multi-stage builds make a real difference.**

When you're done working and want to restore your terminal's Docker CLI to your laptop's Docker:

```shell
eval $(minikube docker-env -u)
```

## The deployment manifest

```yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: go-api
  namespace: default
  labels:
    app: go-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go-api
  template:
    metadata:
      labels:
        app: go-api
    spec:
      containers:
      - name: server
        image: go-api:latest

        # imagePullPolicy: Never tells Kubernetes to use the local image
        # and never try to pull it from a registry.
        # Required when using images built inside minikube's Docker daemon.
        imagePullPolicy: Never

        ports:
        - containerPort: 8080

        # Resource requests and limits — from post 1.
        # These are now realistic for a pre-compiled Go binary.
        # A running Go binary uses ~10-20Mi — we give it 64Mi to be safe.
        resources:
          requests:
            cpu: "50m"
            memory: "32Mi"
          limits:
            cpu: "100m"
            memory: "64Mi"

        # Readiness probe — Kubernetes only sends traffic once this passes.
        # initialDelaySeconds is short because a pre-compiled binary
        # starts in milliseconds — no compilation step.
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

        # Liveness probe — Kubernetes restarts the container if this fails.
        # Always set higher than readinessProbe.initialDelaySeconds.
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: go-api
  namespace: default
spec:
  # ClusterIP is the default service type.
  # It gives the service a stable internal IP address only reachable
  # from inside the cluster. This is correct for internal services —
  # we access it locally using kubectl port-forward, not via a public IP.
  type: ClusterIP
  selector:
    app: go-api
  ports:
  - port: 80
    targetPort: 8080
```

## Deploy it

```shell
# Apply both the Deployment and Service
kubectl apply -f deployment.yaml

# Watch the pods come up — should be fast since the binary starts instantly
kubectl get pods -w

# NAME                      READY   STATUS    RESTARTS   AGE
# go-api-7d6b9f8c4-xk2pq   1/1     Running   0          8s
# go-api-7d6b9f8c4-mn9rt   1/1     Running   0          8s
# go-api-7d6b9f8c4-p8wvz   1/1     Running   0          9s
```

No `ContainerCreating` delays. No OOMKill. The binary starts in milliseconds.

## Verify it's working

```shell
# Check resource usage — these are the honest numbers for a Go binary
kubectl top pods

# NAME                      CPU(cores)   MEMORY(bytes)
# go-api-7d6b9f8c4-xk2pq   2m           11Mi
# go-api-7d6b9f8c4-mn9rt   2m           10Mi
# go-api-7d6b9f8c4-p8wvz   2m           11Mi
```

To hit the service from your terminal, use `kubectl port-forward`, it works the same way on both Mac and Linux regardless of which minikube driver you're using:

```shell
# Forward local port 8080 to the go-api service
# Keep this running in one terminal
kubectl port-forward service/go-api 8080:80

# Forwarding from 127.0.0.1:8080 -> 8080
# Forwarding from [::1]:8080 -> 8080
```

In a second terminal:

```shell
curl http://localhost:8080
# hello from Go on Kubernetes!
```

You should see `hello from Go on Kubernetes!`, confirming the service is running and routing traffic to your pods correctly. You now have three running pods with real, honest resource usage data available from metrics-server.

* * *

# How Go connects to this cluster

This section previews what happens in the next post, so that when you see the first line of Go code, it already makes sense.

When `minikube start` ran, it wrote a new entry to your `~/.kube/config` file. Here's the relevant section it added:

```yaml
# ~/.kube/config (relevant section)
contexts:
- context:
    cluster: minikube
    namespace: default
    user: minikube
  name: minikube       # ← the context name

current-context: minikube  # ← currently active context
```

This is the same file kubectl reads when you run any `kubectl` command. It's also the file our Go code will read using `BuildConfigFromFlags`:

```go
// This is how our Go program will connect to minikube
// in the next post — reads the same ~/.kube/config file
config, err := clientcmd.BuildConfigFromFlags("", os.Getenv("HOME")+"/.kube/config")
```

No extra setup. No credentials to manage. The Go program connects to exactly the same cluster kubectl connects to, because both read the same kubeconfig.

## Verifying the context before running Go code

Before running any Go code that talks to Kubernetes, always verify which cluster you're pointing at:

```shell
# Which context is currently active?
kubectl config current-context
# minikube

# List all available contexts (useful if you have multiple clusters)
kubectl config get-contexts

# CURRENT   NAME       CLUSTER    AUTHINFO   NAMESPACE
# *         minikube   minikube   minikube   default
#           prod-eks   eks-prod   aws-user   production
```

The `*` marks the active context. If you ever accidentally point at a production cluster while running your Go code, the `get-contexts` output makes it obvious.

To switch between contexts:

```shell
# Switch to a different context
kubectl config use-context prod-eks

# Switch back to minikube
kubectl config use-context minikube
```

* * *

# Common gotchas

## **minikube service tunnel on Mac with Docker driver**

On Mac, minikube runs inside a Docker container with its own internal network. Running `minikube service go-api --url` creates a temporary tunnel and warns:

```plaintext
Services [default/go-api] have type "ClusterIP" not meant to be exposed,
however for local development minikube allows you to access this!
❗  Because you are using a Docker driver on darwin, the terminal needs to be open to run it.
```

This isn't an error, but the tunnel only works while that terminal stays open. Use `kubectl port-forward` instead, it's more portable, works the same on Mac and Linux, and doesn't need a dedicated terminal window.

## **Docker not running**

minikube uses Docker as its default driver. If Docker isn't running when you call `minikube start`, you'll see:

```plaintext
X Exiting due to PROVIDER_DOCKER_NOT_RUNNING: Docker Desktop is not running
```

Start Docker first, then retry `minikube start`.

## **Not enough resources: pods stuck in Pending**

If your pods stay in `Pending` state, the most common cause is insufficient resources. Check with:

```shell
kubectl describe pod <pod-name> | grep -A5 Events
# Warning  FailedScheduling  Insufficient memory
```

Fix it by giving minikube more resources:

```shell
minikube delete
minikube start --cpus=4 --memory=8192
```

## **minikube IP changes on restart**

Every time you `minikube delete && minikube start`, minikube may get a different IP address for the API server. If you ever hardcode the API server URL anywhere in your Go code, it will break after a restart. Always use `BuildConfigFromFlags` with the kubeconfig path, it reads the current IP dynamically from the config file.

* * *

# Summary

You now have everything you need to follow the rest of this series:

*   A local Kubernetes cluster running minikube
    
*   metrics-server enabled and collecting data
    
*   A Go app deployed with 3 replicas and proper resource configuration
    
*   kubectl configured and pointing at your local cluster
    

Three things to remember as you work through the series:

*   `minikube stop` at the end of every session, don't leave it consuming resources in the background
    
*   `minikube delete` when things get into a broken state, a clean cluster takes less than 2 minutes to rebuild
    
*   `kubectl config current-context` before running any Go code, always know which cluster you're talking to
    

* * *

## What's next

In the next post we write our first Go program that connects to this cluster using client-go, the official Kubernetes Go client. We'll query the pods we just deployed, detect failing ones, and build something immediately useful: a cluster health reporter that gives you a full picture of your cluster's state in a single command.

* * *

## **Let's connect!**

One of the best parts of writing in public is the people you meet along the way, engineers at different stages of their journey, working on similar problems from completely different angles.

If something in this post resonated, if you spotted a bug, or if you just want to talk Go, Kubernetes, Platform Engineering, DevOps, or whatever, I'm always happy to hear from you.

*   [**LinkedIn**](https://www.linkedin.com/in/ferrios/)
    
*   [**GitHub**](https://github.com/FerRiosCosta)
    
*   [**Twitter / X**](https://x.com/ferztyle)
    

*Building from Asunción, Paraguay 🇵🇾*
