Deploy Microservices to Kubernetes Local Environment

Running microservices locally in a production-like Kubernetes environment helps catch infrastructure issues early, before they ever reach UAT or production. This hands-on guide walks you through deploying a complete microservices application to a local Kubernetes cluster using Kind (Kubernetes in Docker).
You'll learn how to build Docker images, load them into Kind's internal registry, configure environment-specific settings with Kustomize, and access your services locally. By the end, all four services (Config Server, API, Tracking, and Gateway) will be running in Kubernetes pods and accessible via port-forwarding.
New to Kubernetes? Check out our companion article Understanding Kubernetes Fundamentals to learn the core concepts before diving into this practical guide.

Prerequisites

Make sure the following tools are installed before proceeding:
  • Java 21+
  • Maven 3.9+
  • Docker
  • Kind (Kubernetes in Docker)
  • kubectl
Note: Kind is a lightweight tool that runs Kubernetes clusters inside Docker containers. It is ideal for local development and CI pipelines where a full cloud cluster is not needed.

FreightFlow Nexus Services Overview

The application consists of four Spring Boot microservices:
  • Config Server (8071) - Centralized configuration management using Spring Cloud Config
  • API Service (8080) - Core business logic for freight operations (shipments, orders, customers)
  • Tracking Service (8090) - Real-time shipment tracking and GPS location updates
  • Gateway (8072) - API Gateway for routing, authentication, and load balancing

Dockerfile Example

Each service uses a similar Dockerfile. Here's the structure:
freightflow-api/Dockerfile
FROM eclipse-temurin:21-jre-alpine # Set working directory WORKDIR /app # Copy the jar file COPY target/*.jar app.jar # Expose the application port EXPOSE 8080 # Run the application ENTRYPOINT ["java", "-jar", "app.jar"]
All services use the eclipse-temurin:21-jre-alpine base image for a lightweight footprint (~170MB). The pattern is identical across services, with only port numbers varying.

Create the Kind Cluster

A Kubernetes cluster is where your applications run. Kind creates this cluster using Docker containers instead of virtual machines, making it lightweight and fast to start. We'll create a cluster named nexus:
kind create cluster --name nexus
After a few seconds, your cluster will be ready. Verify it's up and running:
kubectl cluster-info --context kind-nexus
You should see output showing the Kubernetes control plane is running at a local address. This confirms kubectl (the Kubernetes command-line tool) is pointing at your new Kind cluster and can communicate with it. The cluster is now ready to accept deployments.

Build the Services

Before we can run our services in Kubernetes, we need to compile them into executable JAR files. Maven will build all four Spring Boot microservices in one command:
mvn clean package
This command cleans previous builds and creates fresh JAR files in each service's target/ directory. These JARs contain everything needed to run the services, including dependencies. The build also runs all tests to ensure code quality before deployment.

Build Docker Images

Kubernetes runs applications in containers. We need to package each JAR file into a Docker image, which includes the Java runtime, the application code, and all dependencies. This makes the application portable and consistent across environments.
docker build -t nexus-config:v0.0.4 ./freightflow-config docker build -t nexus-api:v0.0.4 ./freightflow-api docker build -t nexus-tracking:v0.0.4 ./freightflow-tracking docker build -t nexus-gateway:v0.0.4 ./freightflow-gateway
Each command builds an image tagged with version v0.0.4. The tag helps track different versions of your application. After building, you can verify the images exist by running docker images.

Load Images into Kind

Here's a critical step unique to Kind: the cluster runs inside Docker containers with its own isolated image registry. Even though you built images locally with Docker, Kind can't see them yet. You must explicitly transfer each image into the Kind cluster:
kind load docker-image nexus-config:v0.0.4 --name nexus kind load docker-image nexus-api:v0.0.4 --name nexus kind load docker-image nexus-tracking:v0.0.4 --name nexus kind load docker-image nexus-gateway:v0.0.4 --name nexus
This copies each image from your local Docker daemon into Kind's internal registry. Each load takes a few seconds depending on image size. You'll see a progress indicator as the image is transferred.
Common Mistake: Forgetting this step causes Kubernetes pods to fail with ImagePullBackOfferrors. The pods try to pull images from Docker Hub or other registries, but the images only exist locally. Loading them into Kind makes them available to the cluster.

Apply Kubernetes Manifests

Now we tell Kubernetes what to deploy. Instead of plain YAML files, this project uses Kustomize, a built-in Kubernetes tool that lets you customize configurations for different environments (local, UAT, production) without duplicating files.
One command applies all resources for the local environment:
kubectl apply -k k8s/overlays/local/
This creates namespaces, service accounts, deployments, services, and secrets. You'll see output like namespace/nexus created, deployment.apps/api created, etc., as Kubernetes processes each resource.
To preview what will be deployed without actually applying it:
kubectl kustomize k8s/overlays/local/
This shows the fully merged YAML that Kubernetes will receive, helpful for debugging configuration issues.
The Kustomize directory layout looks like this:
k8s/ ├── base/ # Shared manifests │ ├── kustomization.yml │ ├── namespace.yml │ ├── rbac.yml │ ├── freightflow-config/ │ ├── freightflow-api/ │ ├── freightflow-tracking/ │ └── freightflow-gateway/ └── overlays/ ├── local/ # Kind (local development) │ ├── kustomization.yml │ ├── secrets.yml │ └── patches/ └── aks/ ├── uat/ # AKS UAT └── prod/ # AKS Prod
The base/ folder contains shared configurations used everywhere. The overlays/local/folder contains local-specific patches like imagePullPolicy: Never (telling Kubernetes not to pull from remote registries) and local database URLs.

Kustomize Configuration Deep Dive

Let's look inside the Kustomize configuration to understand how it works. This section shows the actual YAML files that define your deployment. Understanding these helps you customize settings for your own projects or troubleshoot issues.
Why show this? Most tutorials skip the configuration details, leaving you unable to adapt them to your needs. These examples provide a working template you can modify for your applications.

Base Configuration

The base kustomization.yml defines shared resources used across all environments:
k8s/base/kustomization.yml
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: nexus resources: - namespace.yml - rbac.yml - freightflow-config/deployment.yml - freightflow-config/service.yml - freightflow-api/deployment.yml - freightflow-api/service.yml - freightflow-tracking/deployment.yml - freightflow-tracking/service.yml - freightflow-gateway/deployment.yml - freightflow-gateway/service.yml
The namespace.yml creates an isolated namespace for the application:
k8s/base/namespace.yml
apiVersion: v1 kind: Namespace metadata: name: nexus
The rbac.yml sets up permissions for service discovery. Spring Cloud Kubernetes needs permission to query the Kubernetes API to find other services:
k8s/base/rbac.yml
apiVersion: v1 kind: ServiceAccount metadata: name: nexus-service-account namespace: nexus --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: service-discovery-role namespace: nexus rules: - apiGroups: [""] resources: ["services", "endpoints", "pods"] verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: service-discovery-rolebinding namespace: nexus subjects: - kind: ServiceAccount name: nexus-service-account namespace: nexus roleRef: kind: Role name: service-discovery-role apiGroup: rbac.authorization.k8s.io
This creates a service account (like a user account for apps), a role defining permissions (can read services/endpoints/pods), and a binding connecting them. Without this, Spring Cloud Kubernetes can't discover services and will fail to start.

Sample Deployment & Service

A Deployment tells Kubernetes how to run your application. Here's the API service deployment:
k8s/base/freightflow-api/deployment.yml
apiVersion: apps/v1 kind: Deployment metadata: name: api labels: app: api spec: replicas: 1 selector: matchLabels: app: api template: metadata: labels: app: api spec: serviceAccountName: nexus-service-account containers: - name: api image: nexus-api:v0.0.4 ports: - containerPort: 8080 env: - name: SPRING_CLOUD_CONFIG_URI value: "http://config:8071" - name: ENVIRONMENT value: "default" resources: requests: memory: "512Mi" cpu: "500m" limits: memory: "1Gi" cpu: "1000m"
Key parts explained:
  • replicas: 1 - Run one instance (pod) of this service
  • image: nexus-api:v0.0.4 - Use the Docker image we built and loaded earlier
  • ports - Expose port 8080 for HTTP traffic
  • env - Environment variables (config server URL, environment name)
  • resources - Memory and CPU limits prevent one service from hogging all cluster resources
A Service makes your pods accessible on the network. Here's the API service definition:
k8s/base/freightflow-api/service.yml
apiVersion: v1 kind: Service metadata: name: api labels: app: api spec: type: ClusterIP selector: app: api ports: - name: http port: 8080 targetPort: 8080 protocol: TCP
This creates a stable network endpoint. Even if pods restart and get new IP addresses, other services can always reach the API at api:8080. The ClusterIP type means it's only accessible within the cluster (perfect for internal microservice communication).

Local Overlay Configuration

The local overlay kustomization.yml references base resources and applies environment-specific patches:
k8s/overlays/local/kustomization.yml
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - ../../base - secrets.yml patches: - path: patches/config-deployment-patch.yml - path: patches/api-deployment-patch.yml - path: patches/tracking-deployment-patch.yml - path: patches/gateway-deployment-patch.yml
Secrets are defined in secrets.yml (never commit real secrets to Git):
k8s/overlays/local/secrets.yml
apiVersion: v1 kind: Secret metadata: name: nexus-secrets namespace: nexus type: Opaque stringData: CONFIG_REPO_URL: "https://github.com/your-org/nexus-config" GITHUB_TOKEN: "<YOUR_GITHUB_TOKEN>" DB_URL: "jdbc:postgresql://localhost:5432/nexus" DB_USERNAME: "postgres" DB_PASSWORD: "<YOUR_DB_PASSWORD>"
Patches modify the base deployment for specific environments. Here's the local environment patch:
k8s/overlays/local/patches/api-deployment-patch.yml
apiVersion: apps/v1 kind: Deployment metadata: name: api spec: template: spec: containers: - name: api imagePullPolicy: Never # Critical for Kind env: - name: ENVIRONMENT value: "local" - name: SPRING_DATASOURCE_URL valueFrom: secretKeyRef: name: nexus-secrets key: DB_URL - name: SPRING_DATASOURCE_USERNAME valueFrom: secretKeyRef: name: nexus-secrets key: DB_USERNAME - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: name: nexus-secrets key: DB_PASSWORD
What this does: It merges with the base deployment, adding/overriding specific fields. The critical setting is imagePullPolicy: Never, which tells Kubernetes to only use locally loaded images. The environment variables come from secrets instead of being hardcoded, keeping sensitive data like database passwords secure.
Key Points:
  • imagePullPolicy: Never is critical for Kind to use locally loaded images
  • Patches use strategic merge to add/override specific fields without duplicating entire manifests
  • Secrets are referenced via secretKeyRef to avoid hardcoding sensitive values
  • Resource limits prevent any single service from consuming all cluster resources

Verify Pods are Running

After applying the manifests, Kubernetes starts creating pods (containers running your services). This takes a minute as images are loaded, containers start, and health checks pass. Let's verify everything is working:
kubectl get pods -n nexus
You should see four pods (config, api, tracking, gateway) with STATUS: Running and READY: 1/1. The 1/1 means 1 out of 1 containers in the pod is ready. If you see 0/1, the pod is still starting.
To watch the status update in real-time (useful during deployment):
kubectl get pods -n nexus -w
Press Ctrl+C to stop watching. If a pod stays in Pending, CrashLoopBackOff, or ImagePullBackOff status for more than a minute, something's wrong.
To troubleshoot a stuck pod, describe it to see detailed events:
kubectl describe pod <pod-name> -n nexus
Look at the Events section at the bottom. Common issues include missing images (forgot to load into Kind), insufficient resources, or application crashes (check logs with kubectl logs).

Access Services Locally

Your services are now running inside the Kind cluster, but you can't access them from your browser yet because they're on an internal network. Kind doesn't support LoadBalancer services (which would expose them automatically). Instead, use port-forward to create a tunnel from your localhost to a pod:
# Config Server (port 8071) kubectl port-forward -n nexus deployment/config 8071:8071 # API Service (port 8080) kubectl port-forward -n nexus deployment/api 8080:8080 # Tracking Service (port 8090) kubectl port-forward -n nexus deployment/tracking 8090:8090 # Gateway (port 8072) kubectl port-forward -n nexus deployment/gateway 8072:8072
Each command runs in the foreground, so open a separate terminal tab for each one. The format islocalPort:podPort. For example, 8072:8072 makes the gateway available at localhost:8072.
Now test the gateway in your browser or with curl:
http://localhost:8072/config/api/dev http://localhost:8072/api/<endpoint>
The gateway routes requests to other services internally. For example, /api/* routes to the API service,/tracking/* to the tracking service. This is how microservices communicate in Kubernetes.

Service Overview

ServiceSpring App NamePortK8s DeploymentImage
Config Serverconfig8071confignexus-config:v0.0.4
APIapi8080apinexus-api:v0.0.4
Trackingtracking8090trackingnexus-tracking:v0.0.4
Gatewaygateway8072gatewaynexus-gateway:v0.0.4

Redeploying After Code Changes

During development, you'll frequently modify code and need to see changes running in Kubernetes. Because Kind uses locally loaded images, you must rebuild, reload, and restart. Here's the complete workflow for updating one service (e.g., the gateway after fixing a bug):
# 1. Rebuild the jar mvn clean package -pl freightflow-gateway # 2. Rebuild the Docker image docker build -t nexus-gateway:v0.0.4 ./freightflow-gateway # 3. Remove the old image from Kind's cache docker exec nexus-control-plane crictl rmi nexus-gateway:v0.0.4 # 4. Load the new image kind load docker-image nexus-gateway:v0.0.4 --name nexus # 5. Restart the deployment to pick up the new image kubectl rollout restart deployment/gateway -n nexus
To redeploy all four services at once, use this loop:
mvn clean package \ -pl freightflow-config,freightflow-api,freightflow-tracking,freightflow-gateway for svc in config api tracking gateway; do docker build -t nexus-${svc}:v0.0.4 ./freightflow-${svc} docker exec nexus-control-plane crictl rmi nexus-${svc}:v0.0.4 kind load docker-image nexus-${svc}:v0.0.4 --name nexus kubectl rollout restart deployment/${svc} -n nexus done

Useful Commands

Pod & Deployment Management

# Check pod status kubectl get pods -n nexus # Describe a pod (for debugging startup issues) kubectl describe pod <pod-name> -n nexus # Reapply config and restart kubectl apply -k k8s/overlays/local/ kubectl rollout restart deployment/api -n nexus # Delete and recreate all resources kubectl delete -k k8s/overlays/local/ kubectl apply -k k8s/overlays/local/ # Delete the entire cluster kind delete cluster --name nexus

Log Tailing

# Follow logs for a deployment kubectl logs -f -n nexus deployment/config kubectl logs -f -n nexus deployment/api kubectl logs -f -n nexus deployment/gateway # View last 100 lines kubectl logs --tail=100 -n nexus deployment/api # Follow logs for all pods of a service kubectl logs -f -l app=api -n nexus
Congratulations! You've successfully deployed a complete microservices application to a local Kubernetes cluster.
  • A working Kubernetes environment, mirrors production architecture without needing cloud resources or expensive infrastructure
  • Four microservices running in pods with proper service discovery, networking, and config management through Spring Cloud Config
  • Experience with Kustomize for managing environment-specific configurations, a critical skill for production Kubernetes deployments
  • A repeatable deployment workflow you can use for any Spring Boot microservices project, not just this example
Running microservices locally in Kubernetes catches issues early. You'll discover problems with service communication, resource limits, configuration management, and deployment manifests on your laptop, not in UAT or production where they're expensive to fix. This dramatically shortens the feedback loop during development.
This local setup is perfect for development, but production needs differ. In the next article, Deploy Microservices to Azure Kubernetes Service (AKS), we'll take these same services and deploy them to a managed Kubernetes cluster in Azure, covering topics like Azure Container Registry integration, managed identities, ingress controllers, and production-grade monitoring. If you have questions or run into issues with this local setup, feel free to leave a comment below.
Write your Comment