Setting Up a Local K8s Cluster with kind

Setting Up a Local K8s Cluster with kind


Introduction

To study or practice Kubernetes, you need a cluster. There are three main options:

ToolStrengthsDrawbacks
EKS/GKEProduction-gradeCosts money (EKS alone is $0.10/hr)
minikubeEasy to installSingle node only, no multi-node practice
kindMulti-node, lightweight, fastNot meant for production

kind (Kubernetes IN Docker) creates K8s nodes inside Docker containers. Each Docker container becomes a K8s node, so you can spin up a cluster with 1 control plane + 2 worker nodes locally in just a few minutes.

After using it for a while, these are the standout benefits:

  • Fast: Creating a cluster takes just 1-2 minutes
  • Lightweight: Uses Docker containers instead of VMs
  • Multi-node: Add as many worker nodes as you want
  • Reproducible: A single config file recreates the same cluster every time

This post walks through the entire process of building a local cluster with kind, then building and deploying a Spring Boot application to it.

This is the first post in the Local K8s Practice series.

  • This post: Setting Up a Local K8s Cluster with kind
  • Part 2: Building a GitOps Pipeline with ArgoCD on kind
  • Part 3: K8s Log Monitoring with Loki + Grafana

Prerequisites

Docker Runtime

kind runs on top of Docker, so you need a Docker runtime. Docker Desktop, Colima, or Orbstack all work. This guide uses Orbstack.

Orbstack is a macOS-only Docker runtime that is lighter and faster than Docker Desktop. If you have not tried it yet, you can install it from orbstack.dev.

Verify that Docker is working:

docker version

If both Client and Server information are displayed, you are good to go.

Installing kind, kubectl, and helm

On macOS, you can install all three with brew:

brew install kind kubectl helm

Verify the installed versions:

kind version
# kind v0.27.0 go1.24.1 darwin/arm64

kubectl version --client
# Client Version: v1.32.3

helm version
# version.BuildInfo{Version:"v3.17.1", ...}

Note: The kind, kubectl, and helm versions may differ depending on when you run this. Minor version differences should not cause any issues.


Creating a kind Cluster

Writing the Cluster Config File

kind uses a YAML config file to define the cluster structure. Create a kind-config.yaml file in your project root:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 80
        hostPort: 80
        protocol: TCP
      - containerPort: 443
        hostPort: 443
        protocol: TCP
  - role: worker
  - role: worker

Here is what each setting does:

SettingDescription
nodesCluster node layout. 1 control-plane + 2 workers
extraPortMappingsMaps host ports to container ports. Opens ports 80/443 for Ingress

A quick note on why extraPortMappings is needed: kind clusters run inside Docker containers. To access services inside the cluster from your local machine, you need to open ports. When you install the Nginx Ingress Controller later, traffic will come in on ports 80/443. Without this mapping, localhost:80 will not reach the cluster.

Creating the Cluster

kind create cluster --name marketplace --config kind-config.yaml

You should see output like this:

Creating cluster "marketplace" ...
 ✓ Ensuring node image (kindest/node:v1.32.2) 🖼
 ✓ Preparing nodes 📦 📦 📦
 ✓ Writing configuration 📜
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌
 ✓ Installing StorageClass 💾
 ✓ Joining worker nodes 🚜
Set kubectl context to "kind-marketplace"
You can now use your cluster with:

kubectl cluster-info --context kind-marketplace

Have a nice day! 👋

Notice the last line — the kubectl context has been automatically set to kind-marketplace. No need to switch contexts manually.

Verifying the Cluster

Check the node status:

kubectl get nodes
NAME                        STATUS   ROLES           AGE   VERSION
marketplace-control-plane   Ready    control-plane   75s   v1.32.2
marketplace-worker          Ready    <none>          54s   v1.32.2
marketplace-worker2         Ready    <none>          54s   v1.32.2

All 3 nodes should be in Ready status.

You can also verify using Docker containers:

docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
NAMES                        IMAGE                  STATUS
marketplace-control-plane    kindest/node:v1.32.2   Up 2 minutes
marketplace-worker           kindest/node:v1.32.2   Up 2 minutes
marketplace-worker2          kindest/node:v1.32.2   Up 2 minutes

Three Docker containers, three K8s nodes. This is the core idea behind kind — Docker container = K8s node.


Installing the Nginx Ingress Controller

To route external traffic into the cluster, you need an Ingress Controller. kind officially supports the Nginx Ingress Controller and provides a kind-specific manifest.

Installation

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

What makes this manifest different from the standard Nginx Ingress? It is configured to use hostPort, which connects to the 80/443 ports opened earlier via extraPortMappings.

Verifying the Installation

Wait for the Ingress Controller Pod to become ready:

kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=90s
pod/ingress-nginx-controller-xxxxx condition met

To check the Pod status directly:

kubectl get pods -n ingress-nginx
NAME                                        READY   STATUS      RESTARTS   AGE
ingress-nginx-admission-create-xxxxx        0/1     Completed   0          60s
ingress-nginx-admission-patch-xxxxx         0/1     Completed   0          60s
ingress-nginx-controller-xxxxx              1/1     Running     0          60s

The ingress-nginx-controller Pod should be Running. The admission-create and admission-patch Pods are one-time setup Jobs, so Completed is expected.


Building the marketplace Image

Now prepare the application image for deployment. This guide uses an existing Spring Boot multi-module project (marketplace).

Writing the Dockerfile

Use a multi-stage build to separate the build image from the runtime image, keeping the final image small.

# Stage 1: Build
FROM gradle:8.5-jdk17 AS builder
WORKDIR /app
COPY . .
RUN gradle :module-api:bootJar -x test --no-daemon

# Stage 2: Runtime
FROM eclipse-temurin:17-jre
WORKDIR /app

# Security: run as a non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser

COPY --from=builder /app/module-api/build/libs/*.jar app.jar

RUN chown appuser:appuser app.jar
USER appuser

ENTRYPOINT ["java", "-jar", "app.jar"]

Here is what each stage does:

StageBase ImagePurpose
buildergradle:8.5-jdk17Builds the bootJar with Gradle. Heavy image with JDK + Gradle
runtimeeclipse-temurin:17-jreCopies only the JAR and runs it. Lightweight image with just JRE

Why use multi-stage builds? A single-stage build includes the JDK, Gradle, and source code in the final image. Multi-stage keeps only the JRE + JAR, significantly reducing image size.

The USER appuser line is also important. Running containers as root creates security vulnerabilities. In production, always run as a non-root user.

Building the Image

Build from the project root:

docker build -t marketplace:v1 .

After the build completes, verify the image:

docker images marketplace
REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
marketplace   v1        abc123def456   10 seconds ago   280MB

Loading the Image into kind

Normally, to use an image in a K8s cluster, you need to push it to a registry like Docker Hub. But kind provides a feature to copy local images directly to the cluster nodes.

kind load docker-image marketplace:v1 --name marketplace
Image: "marketplace:v1" with ID "sha256:abc123..." not yet present on node "marketplace-worker2", loading...
Image: "marketplace:v1" with ID "sha256:abc123..." not yet present on node "marketplace-worker", loading...
Image: "marketplace:v1" with ID "sha256:abc123..." not yet present on node "marketplace-control-plane", loading...

This command simply copies the image from your local Docker into each kind cluster node (Docker container). It skips the registry entirely, making it fast and convenient.

Setting imagePullPolicy

There is one thing to watch out for when using images loaded via kind load. By default, Kubernetes tries to pull images from a registry. To use a locally loaded image, you must set imagePullPolicy: Never.

In your Helm values.yaml or Deployment manifest:

# values.yaml
image:
  repository: marketplace
  tag: v1
  pullPolicy: Never  # Use local image, do not pull from registry

Or directly in the Deployment:

containers:
  - name: marketplace
    image: marketplace:v1
    imagePullPolicy: Never

If you do not set imagePullPolicy to Never, the Pod will end up in ErrImagePull status. This is one of the most common mistakes when using local images with kind.

Tip: imagePullPolicy has three options:

  • Always: Always pull from the registry (default when the tag is latest)
  • IfNotPresent: Pull only if not available locally (default for specific tags)
  • Never: Never use the registry. Use local images only

In a kind environment, IfNotPresent also works, but explicitly using Never makes your intent clearer.


Troubleshooting: Redis/Kafka Auto-Config Issues

After deploying the image, the Pod fell into CrashLoopBackOff. Here is the actual problem and how it was resolved.

v1: CrashLoopBackOff

After deployment, checking the Pod status:

kubectl get pods
NAME                           READY   STATUS             RESTARTS   AGE
marketplace-xxxxx-yyyyy        0/1    CrashLoopBackOff    3          2m

Checking the logs:

kubectl logs marketplace-xxxxx-yyyyy
***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to configure a DataSource: 'url' is required...

Action:

Consider the following:
	If you want an embedded database (H2, HSQL or Derby), please put it on the classpath.
	If you have database settings to be applied to a particular profile, ...

This is a database-related error, but looking more closely, there was also a Redis connection failure:

org.springframework.data.redis.RedisConnectionFailureException:
Unable to connect to Redis

Root Cause

This is caused by Spring Boot’s auto-configuration. When spring-boot-starter-data-redis is on the classpath, Spring Boot automatically tries to connect to Redis.

It does not matter whether the active profile is local or prod. As long as the dependency is on the classpath, auto-configuration kicks in.

In a local K8s environment without a Redis server, the connection naturally fails and the app crashes.

v2: Excluding Redis Auto-Config

Exclude the Redis auto-configuration in application.yml:

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
      - org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration

Rebuild and redeploy:

docker build -t marketplace:v2 .
kind load docker-image marketplace:v2 --name marketplace

After updating the Deployment image tag to v2 and redeploying… this time it crashes with a Kafka connection failure:

org.apache.kafka.common.KafkaException:
Failed to construct kafka consumer

Same principle. If spring-kafka is on the classpath, Kafka auto-configuration activates too.

v3: Excluding Kafka Auto-Config as Well

Add the Kafka auto-configuration exclusion:

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
      - org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration
      - org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration

Alternatively, exclude directly in @SpringBootApplication:

@SpringBootApplication(exclude = {
    RedisAutoConfiguration.class,
    RedisReactiveAutoConfiguration.class,
    KafkaAutoConfiguration.class
})
public class MarketplaceApplication {
    public static void main(String[] args) {
        SpringApplication.run(MarketplaceApplication.class, args);
    }
}

Rebuild and redeploy:

docker build -t marketplace:v3 .
kind load docker-image marketplace:v3 --name marketplace

This time the app starts successfully:

kubectl get pods
NAME                           READY   STATUS    RESTARTS   AGE
marketplace-xxxxx-yyyyy        1/1     Running   0          30s

Key Takeaway

The core lesson here is that Spring Boot auto-configuration is classpath-based, not profile-based.

spring-boot-starter-data-redis is on the classpath
  → RedisAutoConfiguration activates
    → Attempts to connect to Redis
      → No Redis in local K8s
        → App fails to start

Even if a given profile does not use Redis, having the dependency on the classpath triggers auto-configuration. There are two ways to fix this:

  1. Exclude explicitly: Use @SpringBootApplication(exclude = ...) or spring.autoconfigure.exclude
  2. Separate dependencies by profile: Restructure your Gradle configuration so each module only includes the dependencies it needs

In practice, the common approach is to exclude in a profile-specific config file (application-local.yml) while keeping auto-configuration active for the production profile.


Summary

Here is a recap of what this post covered:

StepDescription
PrerequisitesInstall Docker (Orbstack), kind, kubectl, helm
Cluster creationConfigure control-plane 1 + worker 2 with kind config
Ingress setupInstall the kind-specific Nginx Ingress Controller
Image buildBuild the Spring Boot app with a multi-stage Dockerfile
Image loadingTransfer the image to the cluster without a registry using kind load
TroubleshootingFix startup issues by excluding Redis/Kafka auto-config

You now have a multi-node K8s environment locally that resembles a production setup. Unlike EKS, it costs nothing and can be created or destroyed whenever you need it — ideal for hands-on practice.

To delete the cluster when you are done:

kind delete cluster --name marketplace

That single command cleanly removes the cluster. Since it is Docker container-based, nothing is left behind.

In the next post, we will install ArgoCD on this cluster and build a GitOps-based automated deployment pipeline. The goal: push to Git and have it automatically deployed to K8s.

This post is part of the Coupang Partners program, and a commission is earned from qualifying purchases.