This blog is the practical companion to Kubernetes Fundamentals.
We will build a real Go HTTP server, containerize it with Docker, and deploy it to a local Kubernetes cluster powered by kind.
kind stands for Kubernetes in Docker: it runs Kubernetes nodes as Docker containers, which makes it a convenient way to practise Kubernetes locally without needing a cloud account.
By the end you will have run a rolling update and scaled your application — all from your local machine.
Prerequisites
- Go 1.21 or later installed.
- Docker Desktop installed and running.
- kubectl installed — the CLI for talking to a Kubernetes cluster.
Verify everything is working before proceeding:
go version
docker version
kubectl version --client
Step 1: Create the Go Application
Create a project directory and initialise a Go module:
mkdir go-k8s-demo
cd go-k8s-demo
go mod init go-k8s-demo
Create main.go:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
)
type Response struct {
Message string `json:"message"`
Hostname string `json:"hostname"`
Version string `json:"version"`
Time time.Time `json:"time"`
}
func handler(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
resp := Response{
Message: "Hello from Kubernetes!",
Hostname: hostname,
Version: os.Getenv("APP_VERSION"),
Time: time.Now(),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("encode response: %v", err)
}
}
func healthcheck(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", handler)
http.HandleFunc("/healthcheck", healthcheck)
log.Printf("Server listening on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Listen error: %v", err)
}
}
A few things to note:
-
os.Hostname()returns the Pod name when running inside Kubernetes. This is handy for verifying which replica handled a request. -
APP_VERSIONis read from an environment variable so we can inject different values at deploy time without changing the image. -
/healthcheckis the liveness and readiness probe endpoint Kubernetes will call to check if the container is healthy.
Test the application locally:
go run main.go
In another terminal:
curl http://localhost:8080
Expected response:
{
"message": "Hello from Kubernetes!",
"hostname": "your-machine-name",
"version": "",
"time": "2026-06-02T10:00:00Z"
}
Step 2: Containerize with Docker
Create a Dockerfile in the project root:
# Build stage
FROM golang:1.25.3-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
# Runtime stage
FROM alpine:3.21
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/server .
USER app
EXPOSE 8080
CMD ["./server"]
This is a multi-stage build:
- The first stage (
builder) compiles the binary inside a full Go toolchain image. - The second stage copies only the compiled binary into a minimal Alpine image.
- The final image is a few megabytes, not hundreds. There is no Go compiler, no source code — only the binary the application needs.
-
CGO_ENABLED=0produces a fully static binary with no C library dependencies, which is required on the minimal Alpine base. -
adduser -S appruns the process as a non-root user — a standard security baseline.
Build and test the image locally
docker build -t go-k8s-demo:v1 .
Run it locally to verify:
docker run -p 8080:8080 -e APP_VERSION=v1 go-k8s-demo:v1
curl http://localhost:8080
The response now includes "version": "v1" because the environment variable was injected at runtime.
Step 3: Create a Kubernetes Cluster in Docker Desktop
So far we have only used Docker directly: we built an image and proved the container can run on our machine.
Kubernetes is the next layer. Instead of starting one container manually with docker run, we will ask Kubernetes to keep the application running for us. It will create Pods, restart unhealthy containers, expose the app through a Service, and later perform a rolling update.
For this local tutorial, Docker Desktop can create a small Kubernetes cluster using kind. Because kind runs Kubernetes nodes as Docker containers, your cluster is still local, but it behaves like a real Kubernetes cluster from the point of view of kubectl.
Go to Settings → Kubernetes → Create a Kubernetes Cluster.
You will be offered two cluster types:
-
kind — recommended. Creates a cluster using kind. Requires the containerd image store. Locally built images must be explicitly loaded into the cluster with
kind load docker-imagebefore Kubernetes can use them. - Kubeadm — creates a single-node cluster with kubeadm. Same requirement: locally built images need to be loaded or pushed to a registry.
Select kind, leave the node count at 1, and click Create. The first creation pulls cluster components and takes a minute or two. Once the status indicator turns green, verify the cluster is up:
kubectl get nodes
NAME STATUS ROLES AGE VERSION
desktop-control-plane Ready control-plane 60s v1.34.3
Docker Desktop automatically sets the kubectl context to the new cluster, so no extra context switching is needed.
Load the image into the kind cluster
Now that the kind cluster exists, we need to make the image available inside it.
This is a common point of confusion: the image go-k8s-demo:v1 exists in Docker's local image store because we built it with docker build, but the kind cluster has its own container runtime inside the node container. Kubernetes will not automatically see every image on your host machine. We must explicitly load or import the image before the Deployment can start Pods from it.
On Mac/Linux — use the kind CLI:
kind load docker-image go-k8s-demo:v1
On Windows (Git Bash + Docker Desktop) — kind may be blocked by Windows Application Control policies. Use the following three-step method instead. Docker Desktop's kind node runs inside the desktop-linux context and the node container is named desktop-control-plane.
Save the image as a tar file in the current directory (avoid /tmp — Git Bash maps it to a Windows temp path which causes issues):
docker save go-k8s-demo:v1 -o go-k8s-demo-v1.tar
Copy the tar into the kind node container (MSYS_NO_PATHCONV=1 prevents Git Bash from converting the Linux path to a Windows path):
MSYS_NO_PATHCONV=1 docker --context desktop-linux cp go-k8s-demo-v1.tar desktop-control-plane:/go-k8s-demo-v1.tar
Import the image into containerd inside the node:
MSYS_NO_PATHCONV=1 docker --context desktop-linux exec desktop-control-plane ctr images import /go-k8s-demo-v1.tar
On Windows (PowerShell or Command Prompt + Docker Desktop) — use the same manual import method, but you do not need MSYS_NO_PATHCONV because PowerShell and Command Prompt do not rewrite Linux-style container paths.
Save the image as a tar file in the current directory:
docker save go-k8s-demo:v1 -o go-k8s-demo-v1.tar
Copy the tar into the kind node container:
docker --context desktop-linux cp go-k8s-demo-v1.tar desktop-control-plane:/go-k8s-demo-v1.tar
Import the image into containerd inside the node:
docker --context desktop-linux exec desktop-control-plane ctr images import /go-k8s-demo-v1.tar
Verify the image is available.
Git Bash:
MSYS_NO_PATHCONV=1 docker --context desktop-linux exec desktop-control-plane ctr images ls
PowerShell or Command Prompt:
docker --context desktop-linux exec desktop-control-plane ctr images ls
You should see docker.io/library/go-k8s-demo:v1 in the list. From here on, Kubernetes can create Pods from that local image without pulling it from Docker Hub or another registry.
Step 4: Write Kubernetes Manifests
Create a directory for the manifests:
mkdir k8s
Deployment
Create k8s/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: go-k8s-demo
labels:
app: go-k8s-demo
spec:
replicas: 3
selector:
matchLabels:
app: go-k8s-demo
template:
metadata:
labels:
app: go-k8s-demo
spec:
containers:
- name: go-k8s-demo
image: go-k8s-demo:v1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: APP_VERSION
value: 'v1'
livenessProbe:
httpGet:
path: /healthcheck
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthcheck
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: '50m'
memory: '32Mi'
limits:
cpu: '200m'
memory: '64Mi'
Key settings explained:
-
replicas: 3— Kubernetes will keep exactly 3 Pods running. If one dies, it creates a replacement. -
imagePullPolicy: IfNotPresent— Kubernetes uses the image already present on the node and only pulls from a registry if it is missing. Since we manually loaded the image into the kind node's containerd runtime in Step 3, the image is already present and no registry push is needed. -
livenessProbe— Kubernetes restarts the container if/healthcheckstops returning 200. -
readinessProbe— Kubernetes only sends traffic to a Pod once/healthcheckreturns 200. During startup or a slow restart, unhealthy Pods are excluded from load balancing automatically. -
resources—requestsare what the scheduler uses to find a node with enough room.limitsare the hard cap. Setting both is a production best practice.
Service
Create k8s/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: go-k8s-demo
spec:
selector:
app: go-k8s-demo
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: NodePort
The Service uses NodePort so we can reach it from outside the cluster. Port 80 on the Service maps to port 8080 on each Pod. The selector ties the Service to any Pod labelled app: go-k8s-demo.
Step 5: Deploy to Kubernetes
Apply both manifests:
kubectl apply -f k8s/
Check that the Deployment and Pods are running:
kubectl get deployments
kubectl get pods
NAME READY UP-TO-DATE AVAILABLE AGE
go-k8s-demo 3/3 3 3 20s
NAME READY STATUS RESTARTS AGE
go-k8s-demo-6d8b9f5c4d-4xqjk 1/1 Running 0 20s
go-k8s-demo-6d8b9f5c4d-7pnl2 1/1 Running 0 20s
go-k8s-demo-6d8b9f5c4d-kztr9 1/1 Running 0 20s
Check the Service:
kubectl get services
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
go-k8s-demo NodePort 10.96.145.203 <none> 80:31234/TCP 20s
Reach the application
With a kind cluster, NodePort services are not directly reachable on localhost. Use kubectl port-forward to tunnel traffic from your machine to the Service:
kubectl port-forward service/go-k8s-demo 8080:80
Leave that running and in a second terminal:
curl http://localhost:8080
Expected response:
{
"message": "Hello from Kubernetes!",
"hostname": "go-k8s-demo-7c7b7dd9f9-n7q56",
"version": "v1",
"time": "2026-06-02T16:36:47Z"
}
Note: kubectl port-forward tunnels to a single Pod, so the hostname field will be the same across multiple requests. In a real production cluster with a LoadBalancer service (AWS ELB, GCP LB), traffic would be distributed across all Pods and you would see different hostnames on each request.
Step 6: Perform a Rolling Update
Let's build a second version of the image with a changed message to simulate an application update.
Edit main.go — change the message to "Hello from Kubernetes v2!".
Build the new image and load it into the kind cluster using the same method as Step 3:
docker build -t go-k8s-demo:v2 .
docker save go-k8s-demo:v2 -o go-k8s-demo-v2.tar
MSYS_NO_PATHCONV=1 docker --context desktop-linux cp go-k8s-demo-v2.tar desktop-control-plane:/go-k8s-demo-v2.tar
MSYS_NO_PATHCONV=1 docker --context desktop-linux exec desktop-control-plane ctr images import /go-k8s-demo-v2.tar
Update the Deployment to use the new image:
kubectl set image deployment/go-k8s-demo go-k8s-demo=go-k8s-demo:v2
Watch the rollout happen in real time:
kubectl rollout status deployment/go-k8s-demo
Waiting for deployment "go-k8s-demo" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "go-k8s-demo" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "go-k8s-demo" rollout to finish: 1 old replicas are pending termination...
deployment "go-k8s-demo" successfully rolled out
Kubernetes terminates old Pods and starts new ones one at a time, keeping at least two running throughout. The application never went offline.
To roll back to v1:
kubectl rollout undo deployment/go-k8s-demo
Step 7: Scale the Deployment
Scale to 5 replicas:
kubectl scale deployment go-k8s-demo --replicas=5
kubectl get pods
Scale back down:
kubectl scale deployment go-k8s-demo --replicas=2
Kubernetes sends SIGTERM to the excess Pods and waits for them to exit before removing them.
Step 8: Inspect and Troubleshoot
Useful commands for day-to-day Kubernetes work:
# Describe a Pod — shows events, probe results, resource usage
kubectl describe pod <pod-name>
# Stream logs from all Pods matching the label
kubectl logs -l app=go-k8s-demo -f
# Get logs from a specific Pod
kubectl logs <pod-name>
# Open a shell inside a running container
kubectl exec -it <pod-name> -- sh
# Watch all resources in the default namespace
kubectl get all
Clean Up
When you are done:
kubectl delete -f k8s/
This removes the Deployment and Service. The kind cluster keeps running in the background. To delete it, go to Settings → Kubernetes in Docker Desktop and delete the cluster, or recreate it with a fresh state.
Project Structure Summary
The finished project looks like this:
go-k8s-demo/
├── main.go
├── go.mod
├── Dockerfile
└── k8s/
├── deployment.yaml
└── service.yaml
What We Covered
- Built a Go HTTP server with a
/healthcheckendpoint. - Packaged it into a minimal Docker image using a multi-stage build.
- Loaded the image into a local kind Kubernetes cluster running in Docker Desktop.
- Declared the desired state with a Deployment (3 replicas, liveness/readiness probes, resource limits) and a Service (stable DNS, load balancing).
- Performed a zero-downtime rolling update and rollback.
- Scaled replicas up and down with a single command.
These same deployment.yaml and service.yaml files work unchanged on a production Kubernetes cluster (AWS EKS, GCP GKE, Azure AKS) — the main difference would be pointing the image at a real registry and usually setting imagePullPolicy to Always or using a unique version tag for each release. The concepts and workflow you practised here transfer directly.
You now have a fully functional local development playground running real Kubernetes nodes inside Docker!
If you want to look at the complete source files or check out more developer tools I've built to simplify cloud-native setups, head over to the original post:
Read the full guide on Solution Toolkit
Top comments (0)