DEV Community

Cover image for Deploy Your First Go App with Docker and Kubernetes
Azam Akram
Azam Akram

Posted on • Originally published at solutiontoolkit.com

Deploy Your First Go App with Docker and Kubernetes

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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_VERSION is read from an environment variable so we can inject different values at deploy time without changing the image.
  • /healthcheck is the liveness and readiness probe endpoint Kubernetes will call to check if the container is healthy.

Test the application locally:

go run main.go
Enter fullscreen mode Exit fullscreen mode

In another terminal:

curl http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "message": "Hello from Kubernetes!",
  "hostname": "your-machine-name",
  "version": "",
  "time": "2026-06-02T10:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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=0 produces a fully static binary with no C library dependencies, which is required on the minimal Alpine base.
  • adduser -S app runs the process as a non-root user — a standard security baseline.

Build and test the image locally

docker build -t go-k8s-demo:v1 .
Enter fullscreen mode Exit fullscreen mode

Run it locally to verify:

docker run -p 8080:8080 -e APP_VERSION=v1 go-k8s-demo:v1
Enter fullscreen mode Exit fullscreen mode
curl http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

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-image before 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
Enter fullscreen mode Exit fullscreen mode
NAME                    STATUS   ROLES           AGE   VERSION
desktop-control-plane   Ready    control-plane   60s   v1.34.3
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Import the image into containerd inside the node:

docker --context desktop-linux exec desktop-control-plane ctr images import /go-k8s-demo-v1.tar
Enter fullscreen mode Exit fullscreen mode

Verify the image is available.

Git Bash:

MSYS_NO_PATHCONV=1 docker --context desktop-linux exec desktop-control-plane ctr images ls
Enter fullscreen mode Exit fullscreen mode

PowerShell or Command Prompt:

docker --context desktop-linux exec desktop-control-plane ctr images ls
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 /healthcheck stops returning 200.
  • readinessProbe — Kubernetes only sends traffic to a Pod once /healthcheck returns 200. During startup or a slow restart, unhealthy Pods are excluded from load balancing automatically.
  • resourcesrequests are what the scheduler uses to find a node with enough room. limits are 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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

Check that the Deployment and Pods are running:

kubectl get deployments
kubectl get pods
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

Check the Service:

kubectl get services
Enter fullscreen mode Exit fullscreen mode
NAME           TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
go-k8s-demo   NodePort   10.96.145.203   <none>        80:31234/TCP   20s
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Leave that running and in a second terminal:

curl http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "message": "Hello from Kubernetes!",
  "hostname": "go-k8s-demo-7c7b7dd9f9-n7q56",
  "version": "v1",
  "time": "2026-06-02T16:36:47Z"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Update the Deployment to use the new image:

kubectl set image deployment/go-k8s-demo go-k8s-demo=go-k8s-demo:v2
Enter fullscreen mode Exit fullscreen mode

Watch the rollout happen in real time:

kubectl rollout status deployment/go-k8s-demo
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 7: Scale the Deployment

Scale to 5 replicas:

kubectl scale deployment go-k8s-demo --replicas=5
kubectl get pods
Enter fullscreen mode Exit fullscreen mode

Scale back down:

kubectl scale deployment go-k8s-demo --replicas=2
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Clean Up

When you are done:

kubectl delete -f k8s/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What We Covered

  • Built a Go HTTP server with a /healthcheck endpoint.
  • 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)