Order Management API with Kubernetes

Build a Spring Boot REST API, containerize it, and deploy to Kubernetes.

Introduction

30 Second Summary

Every time you order something online, a backend service somewhere is creating, tracking, and updating that order. These services need to handle thousands of requests, recover from crashes gracefully, and update without any downtime.

In this project, you will build a production-ready order management API from scratch using Spring Boot. You will containerize it with Docker and deploy it to a local Kubernetes cluster with health monitoring and rolling updates.

What You'll Build

A Spring Boot microservice that manages customer orders with full CRUD operations, backed by PostgreSQL, documented with Swagger UI, and orchestrated by Kubernetes.

By the end of this project, you'll have:

  • A Spring Boot REST API with layered architecture (Controller, Service, Repository) performing full CRUD on customer orders.
  • A PostgreSQL database running in a container with auto-generated Swagger UI documentation for interactive API testing.
  • A Kubernetes Deployment with health probes and rolling updates, running your containerized application in a local cluster.
  • Secret Mission: Add a GitHub Actions CI/CD pipeline that automatically tests and builds your Docker image on every push.

Are there any prerequisites?

You should be familiar with Java basics and have some exposure to Spring Boot concepts. You also need to be comfortable using a terminal for running commands.

Set Up Your Development Environment

Before you can build the order management API, you need the right tools in place. Kubernetes is where your microservice will eventually run, so you need a local cluster to develop against.

You also need a Spring Boot project as the foundation for your API code. In this step, you will install the cluster tools, generate the project skeleton, and confirm everything compiles cleanly.

In this step, get ready to:
  • Install Minikube and kubectl, then start a local Kubernetes cluster.
  • Generate the Spring Boot project from Spring Initializr and import it into IntelliJ IDEA.
  • Add the Springdoc OpenAPI dependency and verify the project compiles.
Install Minikube and kubectl

Minikube runs a single-node Kubernetes cluster on your laptop. kubectl is the command-line tool that sends instructions to any Kubernetes cluster.

  • Install Minikube and kubectl using the package manager for your operating system by running the commands below:

macOS

brew install minikube
brew install kubectl

What do these commands do?

  • brew install minikube downloads and installs Minikube via Homebrew.
  • brew install kubectl installs the Kubernetes CLI so you can manage your cluster from the terminal.

Windows

winget install Kubernetes.minikube
winget install Kubernetes.kubectl

What do these commands do?

  • winget install Kubernetes.minikube downloads and installs Minikube via the Windows Package Manager.
  • winget install Kubernetes.kubectl installs the Kubernetes CLI.
  • Close and reopen your terminal so the new commands are available on your PATH.
  • Verify Minikube installed correctly by running:
minikube version

✔️ I see a version number

Minikube is installed. Move on to the next verification below.

ⓧ Command not found

Your terminal cannot find the Minikube binary.

  • Make sure you closed and reopened your terminal after the install.
  • On macOS, try running brew link minikube to create the necessary symlinks.
  • On Windows, open a new PowerShell window as administrator and retry.
  • Verify kubectl installed correctly by running:
kubectl version --client

✔️ I see a version number

kubectl is ready. Proceed to starting your cluster.

ⓧ Command not found

Your terminal cannot find kubectl.

  • Close and reopen your terminal, then try again.
  • On macOS, run brew link kubectl to fix broken symlinks.
  • On Windows, confirm the install completed by running winget list Kubernetes.kubectl and retrying in a fresh terminal.

Now you can start your local cluster. Minikube auto-detects Docker Desktop and uses it as the driver.

  • Make sure Docker Desktop is running on your machine.
  • Start the Minikube cluster by running:
minikube start

What does this command do?

minikube start creates a single-node Kubernetes cluster inside a Docker container on your machine. It downloads the necessary Kubernetes components and configures kubectl to point at your new cluster automatically.

The startup takes 1-2 minutes. You should see output ending with a message confirming the cluster is ready.

  • Verify the cluster is healthy by running:
minikube status

You should see host: Running, kubelet: Running, and apiserver: Running in the output.

Minikube failed to start?

  • Confirm Docker Desktop is running. Minikube needs the Docker daemon active to create containers.
  • If you see a memory error, make sure Docker Desktop has at least 2 GB of RAM allocated in its settings.
  • On Windows, run your terminal as administrator if you see permission errors.

Why won't my Minikube cluster start?

Generate the Spring Boot project from Spring Initializr

Spring Initializr is a web tool that generates a ready-to-code Spring Boot project with your chosen dependencies pre-configured in the build file.

  • Open your browser and navigate to start.spring.io.
  • Set **Project** to Maven.
  • Set **Language** to Java.
  • Set **Spring Boot** to 3.4.5 (or the latest 3.4.x stable version available).
  • Under **Project Metadata**, set Group to com.example.
  • Set Artifact to order-service.
  • Set Java to 17.
  • Click the Add Dependencies button (or press Ctrl+B).
  • Search for and add Spring Web.
  • Search for and add Spring Data JPA.
  • Search for and add PostgreSQL Driver.
  • Search for and add Spring Boot Actuator.
  • Search for and add Validation.
  • Click Generate (or press Ctrl+Enter) to download the project zip file.

Why these five dependencies?

  • Spring Web provides the REST controller framework for building API endpoints.
  • Spring Data JPA lets you define database entities and repositories without writing SQL.
  • PostgreSQL Driver connects your application to a PostgreSQL database.
  • Spring Boot Actuator exposes health check endpoints that Kubernetes uses to monitor your service.
  • Validation provides annotations to reject bad request data before it reaches your business logic.
  • Extract the downloaded order-service.zip file to a location you can easily find (such as your Desktop).
  • Open IntelliJ IDEA. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ and press Enter to open it.
  • In IntelliJ, click File in the top menu bar.
  • Click Open.
  • Navigate to the extracted order-service folder and select it, then click OK.
  • If IntelliJ asks whether to trust the project, click Trust Project.

IntelliJ will take a minute to index the project and download Maven dependencies. Wait for the progress bar at the bottom to finish.

IntelliJ stuck on indexing?

  • Check your internet connection. IntelliJ needs to download dependencies from Maven Central the first time.
  • If the progress bar shows no movement after 3 minutes, click File then Invalidate Caches / Restart to reset the index.

Why is IntelliJ taking so long to import my project?

Add the Springdoc OpenAPI dependency and compile

Springdoc OpenAPI auto-generates interactive API documentation (Swagger UI) from your controller annotations. This dependency is not available on Spring Initializr, so you add it manually.

  • In IntelliJ, open pom.xml from the Project sidebar on the left.
  • Find the <dependencies> section and add the following dependency block after the spring-boot-starter-validation entry:
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.8.17</version>
        </dependency>

What does this dependency do?

springdoc-openapi-starter-webmvc-ui scans your REST controllers at startup and generates an OpenAPI specification. It also bundles Swagger UI, giving you an interactive web page to test every endpoint without a separate tool like Postman.

  • Save pom.xml with Cmd+S (macOS) or Ctrl+S (Windows).
  • If IntelliJ shows a popup asking to reload Maven changes, click Load Maven Changes.

Now verify everything compiles. The Maven Wrapper (mvnw) is included in the generated project so you do not need Maven installed globally.

  • Open the built-in terminal in IntelliJ by clicking View in the top menu, then Tool Windows, then Terminal.
  • Compile the project by running:
./mvnw clean compile

What does this command do?

  • ./mvnw uses the Maven Wrapper script bundled with your project. It downloads the correct Maven version automatically.
  • clean removes any previous build output.
  • compile compiles all Java source files and resolves every dependency from Maven Central.

You should see BUILD SUCCESS at the end of the terminal output.

Seeing BUILD FAILURE?

  • If you see a permission denied error on macOS, run chmod +x mvnw first to make the wrapper executable.
  • If you see a dependency resolution error, check that the Springdoc version in your pom.xml is exactly 2.8.17 with no typos.
  • On Windows, use mvnw.cmd clean compile instead of ./mvnw.

Why is my Maven build failing?

✔️ Awesome, I've got everything!

Great. Double check you have saved pom.xml and your terminal shows BUILD SUCCESS.

ⓧ I'd like to double check the full code

Here is the complete pom.xml you should have at this point:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.5</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>order-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>order-service</name>
    <description>Order Management Microservice</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.8.17</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

And the main application class at src/main/java/com/example/orderservice/OrderServiceApplication.java:

package com.example.orderservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OrderServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

Your environment is ready. Next up, you will build the REST API with a proper layered architecture.

Build the REST API with Layered Architecture

Every production API needs a clear separation of concerns. The layered architecture pattern (Controller, Service, Repository) keeps your code organized and testable.

In this step, you will build the full REST API layer by layer. You will start with the data model, then add business logic, and finally wire up the HTTP endpoints.

In this step, get ready to:
  • Create the Order entity and repository for the data layer.
  • Build the service layer with business logic and the controller with REST endpoints.
  • Add request validation with a DTO and global exception handling.
Create the Order entity and repository

The data layer is the foundation of your API. A JPA entity maps a Java class directly to a database table, and a repository gives you methods to query that table without writing SQL.

  • In the IntelliJ Project panel on the left, right-click on the com.example.orderservice package.
  • Select New, then Package.
  • Type model and press Enter.
  • Right-click on the new model package and select New > Java Class.
  • Name it Order and press Enter.
  • Replace the entire contents of the file with the following code:
package com.example.orderservice.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

// JPA entity mapped to the "orders" table in PostgreSQL
@Entity
@Table(name = "orders")
public class Order {

    // Auto-generated primary key
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String customerName;

    @Column(nullable = false)
    private String product;

    @Column(nullable = false)
    private Integer quantity;

    // Order status tracks lifecycle (PENDING, SHIPPED, etc.)
    @Column(nullable = false)
    private String status;

    // Timestamp set automatically when order is first saved
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    // Required no-arg constructor for JPA
    protected Order() {}

    // Constructor for creating new orders in the service layer
    public Order(String customerName, String product, Integer quantity, String status) {
        this.customerName = customerName;
        this.product = product;
        this.quantity = quantity;
        this.status = status;
        this.createdAt = LocalDateTime.now();
    }

    // Lifecycle callback to set createdAt before first persist
    @PrePersist
    protected void onCreate() {
        if (this.createdAt == null) {
            this.createdAt = LocalDateTime.now();
        }
    }

    // Getters and setters for each field
    public Long getId() { return id; }
    public String getCustomerName() { return customerName; }
    public void setCustomerName(String customerName) { this.customerName = customerName; }
    public String getProduct() { return product; }
    public void setProduct(String product) { this.product = product; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}

What does this code do?

This class defines the shape of an order in your database.

  • @Entity and @Table(name = "orders") tell JPA to map this class to a database table called orders.
  • @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) let the database auto-generate a unique ID for each new order.
  • @PrePersist marks a method that JPA calls automatically before saving a new record, ensuring createdAt is always populated.
  • The protected no-arg constructor is required by JPA internally. Your application code uses the four-argument constructor instead.
  • Save Order.java with Ctrl+S.
  • You won't see a change yet. The next file (OrderRepository) uses this entity to talk to the database.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

package com.example.orderservice.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String customerName;

    @Column(nullable = false)
    private String product;

    @Column(nullable = false)
    private Integer quantity;

    @Column(nullable = false)
    private String status;

    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    protected Order() {}

    public Order(String customerName, String product, Integer quantity, String status) {
        this.customerName = customerName;
        this.product = product;
        this.quantity = quantity;
        this.status = status;
        this.createdAt = LocalDateTime.now();
    }

    @PrePersist
    protected void onCreate() {
        if (this.createdAt == null) {
            this.createdAt = LocalDateTime.now();
        }
    }

    public Long getId() { return id; }
    public String getCustomerName() { return customerName; }
    public void setCustomerName(String customerName) { this.customerName = customerName; }
    public String getProduct() { return product; }
    public void setProduct(String product) { this.product = product; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}

Now you need a way to save and retrieve Order objects from the database. Spring Data JPA handles this with a repository interface.

  • Right-click on the com.example.orderservice package in the Project panel.
  • Select New > Package and type repository.
  • Right-click on the repository package and select New > Java Class.
  • Choose Interface from the kind dropdown, name it OrderRepository, and press Enter.
  • Replace the contents with the following code:
package com.example.orderservice.repository;

import com.example.orderservice.model.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

// Spring Data JPA auto-implements CRUD methods (save, findById, findAll, delete)
public interface OrderRepository extends JpaRepository<Order, Long> {
    // Custom query method: Spring generates SQL from the method name
    List<Order> findByStatus(String status);
    List<Order> findByCustomerName(String customerName);
}

What does this code do?

By extending JpaRepository<Order, Long>, Spring Data JPA automatically provides save(), findById(), findAll(), and delete() methods with zero implementation code.

The custom methods like findByStatus() use Spring's query derivation. Spring reads the method name and generates the correct SQL query automatically.

  • Save OrderRepository.java.
  • Verify the data layer compiles by opening IntelliJ's terminal (Alt+F12) and running:
./mvnw clean compile
  • You should see BUILD SUCCESS in the output.

Seeing a compilation error?

  • Check that your Order.java package declaration says package com.example.orderservice.model; and the file lives inside the model folder.
  • Make sure OrderRepository.java imports com.example.orderservice.model.Order. If IntelliJ shows the import in red, the Order class may be in the wrong package.

Still stuck? Help me debug this compilation error in my Spring Boot project.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

package com.example.orderservice.repository;

import com.example.orderservice.model.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByStatus(String status);
    List<Order> findByCustomerName(String customerName);
}
Create the service layer and REST controller

The service layer holds your business logic. The controller layer translates HTTP requests into service calls and formats the responses.

Why separate Service from Controller?

Putting business logic directly in the controller makes it impossible to reuse that logic elsewhere (background jobs, message handlers, other controllers). The service layer is your single source of truth for how orders behave.

First, you need a custom exception class so the service can signal when an order is not found.

  • Right-click on com.example.orderservice and create a new package called service.
  • Inside service, create a new Java class named OrderNotFoundException.
  • Replace the contents with:
package com.example.orderservice.service;

// Custom exception thrown when an order ID does not exist in the database
public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(String message) {
        super(message);
    }
}

What does this code do?

This is a simple runtime exception that the service throws when a requested order does not exist. Later, the global exception handler will catch it and return a clean 404 JSON response instead of a stack trace.

  • Save OrderNotFoundException.java.
  • You won't see a change yet. The OrderService you create next uses this exception.

Now create the service class that contains all the business logic for managing orders.

  • Inside the service package, create a new Java class named OrderService.
  • Replace the contents with the following code:
package com.example.orderservice.service;

import com.example.orderservice.dto.CreateOrderRequest;
import com.example.orderservice.model.Order;
import com.example.orderservice.repository.OrderRepository;
import org.springframework.stereotype.Service;
import java.util.List;

// Marks this class as a Spring-managed service bean
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    // Constructor injection - Spring automatically provides the repository
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // Creates a new order with PENDING status and saves to database
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order(
            request.getCustomerName(),
            request.getProduct(),
            request.getQuantity(),
            "PENDING"
        );
        return orderRepository.save(order);
    }

    // Retrieves a single order or throws if not found
    public Order getOrderById(Long id) {
        return orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException("Order not found with id: " + id));
    }

    // Returns all orders in the database
    public List<Order> getAllOrders() {
        return orderRepository.findAll();
    }

    // Updates an existing order's fields
    public Order updateOrder(Long id, CreateOrderRequest request) {
        Order order = getOrderById(id);
        order.setCustomerName(request.getCustomerName());
        order.setProduct(request.getProduct());
        order.setQuantity(request.getQuantity());
        return orderRepository.save(order);
    }

    // Deletes an order by ID (throws if not found)
    public void deleteOrder(Long id) {
        Order order = getOrderById(id);
        orderRepository.delete(order);
    }
}

What does this code do?

  • @Service registers this class in Spring's application context so it can be injected into the controller.
  • createOrder() always sets the initial status to PENDING. This is a business rule that lives in the service layer, not the controller.
  • getOrderById() uses orElseThrow() to throw your custom exception when the ID does not exist.
  • updateOrder() fetches the existing order first (validating it exists) before applying changes.
  • Save OrderService.java.
  • You won't see a change yet. The controller you build next exposes these methods as HTTP endpoints.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

package com.example.orderservice.service;

import com.example.orderservice.dto.CreateOrderRequest;
import com.example.orderservice.model.Order;
import com.example.orderservice.repository.OrderRepository;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order(
            request.getCustomerName(),
            request.getProduct(),
            request.getQuantity(),
            "PENDING"
        );
        return orderRepository.save(order);
    }

    public Order getOrderById(Long id) {
        return orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException("Order not found with id: " + id));
    }

    public List<Order> getAllOrders() {
        return orderRepository.findAll();
    }

    public Order updateOrder(Long id, CreateOrderRequest request) {
        Order order = getOrderById(id);
        order.setCustomerName(request.getCustomerName());
        order.setProduct(request.getProduct());
        order.setQuantity(request.getQuantity());
        return orderRepository.save(order);
    }

    public void deleteOrder(Long id) {
        Order order = getOrderById(id);
        orderRepository.delete(order);
    }
}

Time to wire up the HTTP layer. The controller maps incoming requests to the service methods you just wrote.

  • Right-click on com.example.orderservice and create a new package called controller.
  • Inside controller, create a new Java class named OrderController.
  • Replace the contents with the following code:
package com.example.orderservice.controller;

import com.example.orderservice.dto.CreateOrderRequest;
import com.example.orderservice.model.Order;
import com.example.orderservice.service.OrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

// REST controller handling all order-related HTTP requests
@RestController
@RequestMapping("/api/orders")
@Tag(name = "Orders", description = "Order management endpoints")
public class OrderController {

    private final OrderService orderService;

    // Constructor injection of the service layer
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    // POST /api/orders - create a new order
    @PostMapping
    @Operation(summary = "Create a new order")
    public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    }

    // GET /api/orders/{id} - retrieve a single order
    @GetMapping("/{id}")
    @Operation(summary = "Get an order by ID")
    public ResponseEntity<Order> getOrder(@PathVariable Long id) {
        Order order = orderService.getOrderById(id);
        return ResponseEntity.ok(order);
    }

    // GET /api/orders - retrieve all orders
    @GetMapping
    @Operation(summary = "Get all orders")
    public ResponseEntity<List<Order>> getAllOrders() {
        List<Order> orders = orderService.getAllOrders();
        return ResponseEntity.ok(orders);
    }

    // PUT /api/orders/{id} - update an existing order
    @PutMapping("/{id}")
    @Operation(summary = "Update an existing order")
    public ResponseEntity<Order> updateOrder(@PathVariable Long id, @Valid @RequestBody CreateOrderRequest request) {
        Order order = orderService.updateOrder(id, request);
        return ResponseEntity.ok(order);
    }

    // DELETE /api/orders/{id} - remove an order
    @DeleteMapping("/{id}")
    @Operation(summary = "Delete an order")
    public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
        orderService.deleteOrder(id);
        return ResponseEntity.noContent().build();
    }
}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and automatically serializes return values to JSON.
  • @RequestMapping("/api/orders") sets the base URL path for all endpoints in this controller.
  • @Valid triggers Bean Validation on the request body. If validation fails, Spring returns a 400 error automatically.
  • @Tag and @Operation are Swagger annotations that generate interactive API documentation in the browser.
  • Save OrderController.java.
  • The project will not compile yet because CreateOrderRequest (the DTO) does not exist. You will create it in the next substep.

IntelliJ showing red underlines?

That is expected. The import for com.example.orderservice.dto.CreateOrderRequest cannot resolve until you create the DTO class in the next substep. The red will disappear once you add that file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

package com.example.orderservice.controller;

import com.example.orderservice.dto.CreateOrderRequest;
import com.example.orderservice.model.Order;
import com.example.orderservice.service.OrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/orders")
@Tag(name = "Orders", description = "Order management endpoints")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    @Operation(summary = "Create a new order")
    public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    }

    @GetMapping("/{id}")
    @Operation(summary = "Get an order by ID")
    public ResponseEntity<Order> getOrder(@PathVariable Long id) {
        Order order = orderService.getOrderById(id);
        return ResponseEntity.ok(order);
    }

    @GetMapping
    @Operation(summary = "Get all orders")
    public ResponseEntity<List<Order>> getAllOrders() {
        List<Order> orders = orderService.getAllOrders();
        return ResponseEntity.ok(orders);
    }

    @PutMapping("/{id}")
    @Operation(summary = "Update an existing order")
    public ResponseEntity<Order> updateOrder(@PathVariable Long id, @Valid @RequestBody CreateOrderRequest request) {
        Order order = orderService.updateOrder(id, request);
        return ResponseEntity.ok(order);
    }

    @DeleteMapping("/{id}")
    @Operation(summary = "Delete an order")
    public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
        orderService.deleteOrder(id);
        return ResponseEntity.noContent().build();
    }
}
Add request validation and exception handling

Right now, a client could send an order with a blank customer name or negative quantity. You need a Data Transfer Object (DTO) with validation rules to reject bad input before it reaches your database.

  • Right-click on com.example.orderservice and create a new package called dto.
  • Inside dto, create a new Java class named CreateOrderRequest.
  • Replace the contents with the following code:
package com.example.orderservice.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

// DTO that defines what fields a client must send when creating/updating an order
public class CreateOrderRequest {

    // Must not be null or empty/whitespace
    @NotBlank(message = "Customer name is required")
    private String customerName;

    @NotBlank(message = "Product is required")
    private String product;

    // Must be a non-null integer >= 1
    @NotNull(message = "Quantity is required")
    @Min(value = 1, message = "Quantity must be at least 1")
    private Integer quantity;

    // Getters and setters for JSON deserialization
    public String getCustomerName() { return customerName; }
    public void setCustomerName(String customerName) { this.customerName = customerName; }
    public String getProduct() { return product; }
    public void setProduct(String product) { this.product = product; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

What does this code do?

  • @NotBlank rejects null values, empty strings, and strings that are only whitespace.
  • @NotNull ensures the quantity field is present in the JSON body.
  • @Min(value = 1) prevents orders with zero or negative quantities.
  • The message attribute on each annotation is the error text returned to the client when validation fails.
  • Save CreateOrderRequest.java.
  • You won't see a change yet. The next file (GlobalExceptionHandler) turns validation failures into clean JSON error responses.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

package com.example.orderservice.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public class CreateOrderRequest {

    @NotBlank(message = "Customer name is required")
    private String customerName;

    @NotBlank(message = "Product is required")
    private String product;

    @NotNull(message = "Quantity is required")
    @Min(value = 1, message = "Quantity must be at least 1")
    private Integer quantity;

    public String getCustomerName() { return customerName; }
    public void setCustomerName(String customerName) { this.customerName = customerName; }
    public String getProduct() { return product; }
    public void setProduct(String product) { this.product = product; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

When validation fails or an order is not found, Spring would normally return an ugly error page. A global exception handler intercepts those exceptions and returns structured JSON instead.

  • Inside the controller package, create a new Java class named GlobalExceptionHandler.
  • Replace the contents with the following code:
package com.example.orderservice.controller;

import com.example.orderservice.service.OrderNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

// Catches exceptions thrown by any controller and returns structured JSON errors
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handles OrderNotFoundException - returns 404 with a clear message
    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleOrderNotFound(OrderNotFoundException ex) {
        Map<String, Object> error = new HashMap<>();
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status", 404);
        error.put("error", "Not Found");
        error.put("message", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // Handles validation failures - returns 400 with per-field error messages
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, Object> error = new HashMap<>();
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status", 400);
        error.put("error", "Bad Request");
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fe ->
            fieldErrors.put(fe.getField(), fe.getDefaultMessage())
        );
        error.put("fieldErrors", fieldErrors);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

What does this code do?

  • @RestControllerAdvice applies this handler globally to all controllers in the application.
  • handleOrderNotFound() catches your custom exception and builds a 404 JSON body with a timestamp, status code, and message.
  • handleValidation() catches Bean Validation failures and returns a 400 response listing each invalid field and its error message.
  • Save GlobalExceptionHandler.java.
  • Verify the entire project compiles by running this in the IntelliJ terminal:
./mvnw clean compile
  • You should see BUILD SUCCESS in the output. All seven classes now compile together.

Seeing a compilation error?

  • Check that every file's package declaration matches its folder path. For example, OrderService.java must be inside service/ and declare package com.example.orderservice.service;.
  • Verify that CreateOrderRequest.java is in the dto package. Both OrderService and OrderController import this class.
  • If you see cannot find symbol errors, double-check that your class names match exactly (case-sensitive).

Still stuck? Help me fix this compilation error in my Spring Boot order service.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files before moving on.

ⓧ I'd like to double check the full code

package com.example.orderservice.controller;

import com.example.orderservice.service.OrderNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleOrderNotFound(OrderNotFoundException ex) {
        Map<String, Object> error = new HashMap<>();
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status", 404);
        error.put("error", "Not Found");
        error.put("message", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, Object> error = new HashMap<>();
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status", 400);
        error.put("error", "Bad Request");
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fe ->
            fieldErrors.put(fe.getField(), fe.getDefaultMessage())
        );
        error.put("fieldErrors", fieldErrors);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

Your entire API is coded and compiling. Next up, you will connect it to a real PostgreSQL database and see it come alive with interactive Swagger documentation.

Connect to PostgreSQL and Add Swagger Documentation

Your API code is written and compiling. Now it needs a real database to persist data and a way for you to interact with it through a browser.

In this step, you will spin up a PostgreSQL database inside a Docker container, connect your Spring Boot app to it, and then test every endpoint through Swagger UI. You will also enable health probes that Kubernetes will use later to monitor your service.

In this step, get ready to:
  • Launch a PostgreSQL container and configure the database connection.
  • Run the application and test CRUD operations through Swagger UI.
  • Enable Spring Boot Actuator health probes for Kubernetes readiness.
Launch PostgreSQL and configure the database connection

Your Spring Boot app needs a running PostgreSQL instance to store order data. Instead of installing PostgreSQL directly on your machine, you will run it inside a Docker container.

Why use a container for the database?

Running PostgreSQL in a container means you can start it with one command and throw it away when you are done. No system-level installation, no leftover configuration files.

It also mirrors how databases run in production Kubernetes clusters, so you are practicing the same pattern from the start.

  • Start a PostgreSQL container by running this command in your terminal:
docker run --name postgres-local -e POSTGRES_DB=orderdb -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16-alpine

What does this command do?

  • `--name postgres-local` gives the container a friendly name so you can stop or remove it easily later.
  • `-e POSTGRES_DB=orderdb` creates a database called `orderdb` on first startup.
  • `-p 5432:5432` maps the container's PostgreSQL port to your machine so your app can reach it at `localhost:5432`.
  • `-d postgres:16-alpine` runs the lightweight Alpine-based PostgreSQL 16 image in the background.
  • Verify the container is running:
docker ps

You should see a container named `postgres-local` with status `Up` and port `0.0.0.0:5432->5432/tcp`.

Don't see the container running?

  • Make sure Docker Desktop is running. Check for the Docker icon in your system tray.
  • If you see `port is already allocated`, another process is using port 5432. Stop it with `docker stop postgres-local` and `docker rm postgres-local`, then rerun the command.
  • If the container exited immediately, check logs with `docker logs postgres-local` for details.

help me troubleshoot my Docker PostgreSQL container

Now you need to tell Spring Boot where to find this database. The application.properties file holds all of your application's configuration.

  • In IntelliJ, open `src/main/resources/application.properties` from the **Project** panel on the left.
  • Replace the contents of the file with the following configuration:
spring.application.name=order-service

# PostgreSQL connection settings
spring.datasource.url=jdbc:postgresql://localhost:5432/orderdb
spring.datasource.username=postgres
spring.datasource.password=postgres

# JPA settings - auto-create tables from entities
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

What does this configuration do?

  • `spring.datasource.url` points to the PostgreSQL container you just started on `localhost:5432`.
  • `spring.jpa.hibernate.ddl-auto=update` tells JPA to automatically create or update database tables based on your entity classes. This means your `orders` table will appear without writing any SQL.
  • `spring.jpa.show-sql=true` prints every SQL query to the console so you can see exactly what Hibernate does behind the scenes.
  • Save `application.properties`.

Seeing a connection refused error?

  • Double check that your `docker ps` output still shows `postgres-local` as `Up`.
  • Make sure the URL in `application.properties` is exactly `jdbc:postgresql://localhost:5432/orderdb` with no extra spaces.

help me fix my PostgreSQL connection

Run the application and test with Swagger UI

With the database running and the connection configured, you can now start the application and use the auto-generated Swagger UI to test your API endpoints interactively.

  • In IntelliJ, open `OrderServiceApplication.java` from the **Project** panel.
  • Click the green play button in the gutter next to the `main` method.
  • Select **Run 'OrderServiceApplication'** from the popup menu.

The console at the bottom of IntelliJ will show Spring Boot starting up. Wait until you see `Started OrderServiceApplication` in the log output.

What should I see in the console?

You will see Hibernate SQL statements creating the `orders` table, followed by `Tomcat started on port 8080` and `Started OrderServiceApplication`. The SQL output confirms your JPA entity was mapped to a real database table.

Application fails to start?

  • If you see `Connection refused` in the stack trace, your PostgreSQL container may have stopped. Run `docker ps` to check and restart it with `docker start postgres-local` if needed.
  • If you see `relation orders already exists`, that is safe to ignore. It means the table was already created from a previous run.
  • If you see a compilation error, make sure all files are saved and rerun.

help me start my Spring Boot application

  • Open your browser and navigate to `http://localhost:8080/swagger-ui.html`.

You should see the Swagger UI page with your **Orders** endpoint group listed. This documentation was auto-generated from the `@Operation` and `@Tag` annotations you added to your controller.

What is Swagger UI?

Swagger UI is an interactive API documentation tool powered by the OpenAPI specification. It reads your controller annotations and generates a web page where you can send real HTTP requests to your API without writing any client code.

  • Click the **POST /api/orders** row to expand it.
  • Click **Try it out**.
  • Replace the request body with the following JSON:
{
  "customerName": "Alice Johnson",
  "product": "Wireless Keyboard",
  "quantity": 2
}
  • Click **Execute**.

You should see a `201 Created` response with the order details including an auto-generated `id`, `status` set to `PENDING`, and a `createdAt` timestamp.

  • Expand the **GET /api/orders** row, click **Try it out**, then **Execute**.

You should see a `200 OK` response containing the order you just created in a JSON array.

  • Test the **PUT /api/orders/{id}** endpoint by entering `1` as the id and changing the quantity to `5` in the request body.
  • Test the **DELETE /api/orders/{id}** endpoint by entering `1` as the id and clicking **Execute**.

The DELETE should return a `204 No Content` response, confirming the order was removed.

Enable Actuator health probes

When your app runs in Kubernetes, the cluster needs a way to check whether your service is alive and ready to accept traffic. Spring Boot Actuator provides dedicated health endpoints that Kubernetes probes can call.

  • Stop the running application by clicking the red square stop button in IntelliJ's Run panel at the bottom.
  • Open `src/main/resources/application.properties`.
  • Add the following lines at the bottom of the file:
# Actuator health probes for Kubernetes
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true
management.health.livenessState.enabled=true
management.health.readinessState.enabled=true

What do these settings do?

  • `management.endpoints.web.exposure.include=health,info` makes the health and info endpoints accessible over HTTP.
  • `management.endpoint.health.probes.enabled=true` activates the `/actuator/health/liveness` and `/actuator/health/readiness` endpoints.
  • Kubernetes will call the liveness probe to know if the app is alive. If it fails, Kubernetes restarts the pod. The readiness probe tells Kubernetes when the app is ready to receive traffic.
  • Save `application.properties`.
  • Run the application again by clicking the green play button next to the `main` method in `OrderServiceApplication.java`.
  • Once the app starts, open your browser and navigate to `http://localhost:8080/actuator/health`.

You should see a JSON response with `{"status":"UP"}`.

  • Navigate to `http://localhost:8080/actuator/health/liveness`.

You should see `{"status":"UP"}` confirming the liveness probe is active.

  • Navigate to `http://localhost:8080/actuator/health/readiness`.

You should see `{"status":"UP"}` confirming the readiness probe is active.

Seeing a 404 on the actuator endpoints?

  • Make sure you added the actuator lines to `application.properties` and saved the file before restarting.
  • Check that `spring-boot-starter-actuator` is listed as a dependency in your `pom.xml`.
  • Verify the URL is exactly `http://localhost:8080/actuator/health` with no trailing slash.

help me fix my Actuator health endpoints

✔️ Awesome, I've got everything!

Double check you have saved `application.properties` and that all three actuator URLs return `UP`.

ⓧ I'd like to double check the full code

Here is the complete `application.properties` file:

spring.application.name=order-service

spring.datasource.url=jdbc:postgresql://localhost:5432/orderdb
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true
management.health.livenessState.enabled=true
management.health.readinessState.enabled=true

Your API is running with a real database, interactive documentation, and health monitoring. Next up, you will package everything into a Docker image and deploy it to Kubernetes.

Containerize with a Multi-Stage Dockerfile

Your API works perfectly when you run it from IntelliJ. The problem is that running from an IDE is not how production applications are deployed.

In this step, you will package the application into a Docker container image using a multi-stage build. This produces a small, self-contained image that runs the same way everywhere.

In this step, get ready to:
  • Create a multi-stage Dockerfile with layered JAR extraction for fast rebuilds.
  • Build and run the containerized application alongside PostgreSQL on a shared Docker network.
  • Load the image into Minikube so Kubernetes can access it without a remote registry.
Create the multi-stage Dockerfile

A multi-stage Dockerfile separates the build environment from the runtime environment. The first stage compiles your code with the full JDK. The second stage copies only the compiled output into a lightweight JRE image.

Spring Boot 3.4 supports layered JAR extraction, which splits your application into separate layers (dependencies, Spring Boot loader, snapshot dependencies, and your code). Docker caches layers that do not change between builds. Since your dependencies change rarely, Docker skips re-downloading them on every rebuild.

  • In IntelliJ, right-click the order-service project root in the Project sidebar.
  • Select New, then File.
  • Name the file Dockerfile (no extension) and press Enter.
  • Paste the following content into the new file:
# Stage 1: Build the application with the full JDK
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B
COPY src/ src/
RUN ./mvnw package -DskipTests -B
# Extract the JAR into layers for optimal Docker caching
RUN java -Djarmode=tools -jar target/*.jar extract --layers --destination extracted

# Stage 2: Create the lightweight runtime image
FROM eclipse-temurin:17-jre
WORKDIR /app
# Create a non-root user for security
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
# Copy each extracted layer separately for caching
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "application.jar"]

What does this Dockerfile do?

Stage 1 (builder):

  • Starts with eclipse-temurin:17-jdk which includes the full Java compiler.
  • Copies the Maven wrapper and pom.xml first, then downloads dependencies. This layer is cached until pom.xml changes.
  • Packages the app and extracts it into four layers using jarmode=tools.

Stage 2 (runtime):

  • Uses the smaller eclipse-temurin:17-jre image (no compiler needed at runtime).
  • Creates a non-root user appuser so the app does not run as root (a security best practice).
  • Copies the four extracted layers in order from least-changing to most-changing for maximum cache reuse.
  • Save Dockerfile by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file exists in IntelliJ's Project sidebar at the same level as pom.xml and the src/ folder.

Don't see the Dockerfile in your project?

  • Make sure you created the file at the project root (same level as pom.xml), not inside src/.
  • The file must be named exactly Dockerfile with a capital D and no file extension.

Why is my Dockerfile not being recognized by Docker?

Why layered JARs instead of a single COPY?

A typical Spring Boot fat JAR is 50-100 MB. If you copy it as a single layer, Docker re-uploads the entire 100 MB every time you change one line of code.

Layered extraction splits the JAR so that dependencies (which rarely change) are cached separately from your application code (which changes often). Rebuilds that only touch your code take seconds instead of minutes.

Build and run the containerized application

With the Dockerfile ready, you can build the image and run your API as a container. The container needs to reach the PostgreSQL container, so you will create a shared Docker network that connects them.

  • In IntelliJ, stop the running Spring Boot application by clicking the red Stop square in the Run panel at the bottom of the window.

Why stop the IntelliJ app?

The IntelliJ app is already using port 8080. Your Docker container also needs port 8080. Only one process can bind to a port at a time, so you need to free it up first.

  • Open the IntelliJ terminal by clicking Terminal in the bottom toolbar (or press Alt+F12).
  • Build the Docker image by running this command:
docker build -t order-service:latest .

What does this command do?

  • docker build reads the Dockerfile and executes each instruction to produce an image.
  • -t order-service:latest tags the image with the name order-service and the latest version tag.
  • The . at the end tells Docker to use the current directory as the build context.

This build takes 2-5 minutes on the first run because it downloads all Maven dependencies inside the container. Subsequent builds will be much faster thanks to layer caching.

  • You should see Successfully tagged order-service:latest at the end of the output.

Build failed?

  • If you see COPY failed: file not found for .mvn/ or mvnw, make sure your terminal is in the project root directory (the folder containing pom.xml).
  • If you see a permission denied error on mvnw, make sure the chmod +x mvnw line is in your Dockerfile before the first RUN ./mvnw command.
  • Make sure Docker Desktop is running. You can check by running docker info in your terminal.

Help me debug my Docker build failure.

Now you need a Docker network so the application container can talk to the PostgreSQL container by name.

  • Create a Docker network by running:
docker network create order-network
  • Connect your existing PostgreSQL container to the new network by running:
docker network connect order-network postgres-local
  • Run the order-service container on the same network by running:
docker run -d --name order-service --network order-network -p 8080:8080 -e SPRING_DATASOURCE_URL=jdbc:postgresql://postgres-local:5432/orderdb order-service:latest

What do these flags mean?

  • -d runs the container in the background (detached mode).
  • --network order-network places the container on the same network as PostgreSQL.
  • -p 8080:8080 maps port 8080 on your machine to port 8080 inside the container.
  • -e SPRING_DATASOURCE_URL=... overrides the database URL so the app connects to postgres-local by container name instead of localhost.
  • Wait about 15-20 seconds for the application to start inside the container.
  • Open your browser and navigate to http://localhost:8080/swagger-ui.html.
  • You should see the Swagger UI with your Order endpoints, exactly as before. This time the API is running from inside the Docker container.

Swagger UI not loading?

  • Check the container logs by running docker logs order-service. Look for connection refused errors pointing to the database.
  • Make sure you stopped the IntelliJ app. If port 8080 is still in use, the container cannot bind to it.
  • Verify both containers are on the same network by running docker network inspect order-network. You should see both postgres-local and order-service listed.

My containerized Spring Boot app cannot connect to PostgreSQL.

Load the image into Minikube

Your Docker image exists on your local machine, but Minikube runs its own isolated Docker environment. Without loading the image into Minikube, Kubernetes would try to pull it from a remote registry like Docker Hub and fail.

  • Load the image into Minikube by running:
minikube image load order-service:latest
  • This transfers the image from your local Docker to Minikube's internal image store. It may take 30-60 seconds.
  • Verify the image is available inside Minikube by running:
minikube image list | grep order-service
  • You should see docker.io/library/order-service:latest in the output. This confirms Kubernetes can access the image without needing a remote registry.

Image not showing in the list?

  • Make sure Minikube is running by checking minikube status. If it says stopped, run minikube start first.
  • Try running minikube image load order-service:latest again. If it times out, restart Docker Desktop and Minikube.

Help me load a Docker image into Minikube.

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile and that the image is loaded into Minikube.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile for comparison:

FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B
COPY src/ src/
RUN ./mvnw package -DskipTests -B
RUN java -Djarmode=tools -jar target/*.jar extract --layers --destination extracted

FROM eclipse-temurin:17-jre
WORKDIR /app
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "application.jar"]

Your application is now a portable container image ready for orchestration. Next up, you will deploy it to Kubernetes with health probes and rolling updates.

Deploy to Kubernetes with Health Probes

In the previous step, you containerized the order service and confirmed it runs correctly as a Docker container. Running a single container is fine for development, but production systems need orchestration to handle scaling, self-healing, and zero-downtime updates.

Kubernetes solves these problems by managing your containers across a cluster. In this step, you will deploy both PostgreSQL and the order service into your local Minikube cluster with health probes that let Kubernetes automatically restart unhealthy pods.

In this step, get ready to:
  • Write Kubernetes Deployment and Service manifests for the order service and PostgreSQL.
  • Configure liveness and readiness probes pointing at Spring Boot Actuator endpoints.
  • Deploy to Minikube and verify the API is accessible with health checks passing.
Create the order-service Deployment manifest

A Kubernetes Deployment tells the cluster how many replicas of your container to run and how to update them. You will also configure health probes so Kubernetes knows when a pod is alive and ready to serve traffic.

  • Create a k8s/ directory inside your order-service project folder by running this command:
mkdir k8s
  • Create a new file called order-service-deployment.yaml inside the k8s/ directory.
  • Paste the following Deployment manifest into the file:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-service
          image: order-service:latest
          imagePullPolicy: Never
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_DATASOURCE_URL
              value: jdbc:postgresql://postgres:5432/orderdb
            - name: SPRING_DATASOURCE_USERNAME
              value: postgres
            - name: SPRING_DATASOURCE_PASSWORD
              value: postgres
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 5
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"

What does this manifest do?

This Deployment resource tells Kubernetes to run 2 replicas of your order-service container. The RollingUpdate strategy means Kubernetes replaces pods one at a time during updates, so your API never goes fully offline.

imagePullPolicy: Never tells Kubernetes to use the image you loaded directly into Minikube instead of trying to pull from a remote registry.

The livenessProbe checks /actuator/health/liveness every 10 seconds. If it fails, Kubernetes restarts the pod. The readinessProbe checks /actuator/health/readiness every 5 seconds. If it fails, Kubernetes stops sending traffic to that pod until it recovers.

Why imagePullPolicy: Never?

You loaded the Docker image directly into Minikube's internal registry using minikube image load in the previous step. Without imagePullPolicy: Never, Kubernetes would try to pull the image from Docker Hub and fail because order-service:latest does not exist in any remote registry.

  • Save k8s/order-service-deployment.yaml.

Seeing YAML indentation errors later?

YAML is extremely sensitive to indentation. Every level must use exactly 2 spaces (not tabs). The containers key is indented 6 spaces from the left margin. If you see parse errors when you apply the manifest, double-check that you have no tabs mixed in.

Help me fix YAML indentation errors in my Kubernetes manifest

Create the PostgreSQL and Service manifests

Your order service needs a PostgreSQL database running inside the cluster. You also need a Kubernetes Service for each component so pods can find each other by name.

  • Create a new file called postgres-deployment.yaml inside the k8s/ directory.
  • Paste the following manifest that defines both the PostgreSQL Deployment and its Service:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: orderdb
            - name: POSTGRES_USER
              value: postgres
            - name: POSTGRES_PASSWORD
              value: postgres
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432

What does this manifest do?

The --- separator lets you define multiple Kubernetes resources in a single file. The Deployment creates a single PostgreSQL pod using the postgres:16-alpine image with the same database name and credentials you used locally.

The Service creates a stable DNS name (postgres) inside the cluster. This is why the order-service environment variable uses jdbc:postgresql://postgres:5432/orderdb instead of localhost.

  • Save k8s/postgres-deployment.yaml.

Now you need a Service for the order service itself. A NodePort Service exposes your API on a fixed port that you can access from your host machine through Minikube.

  • Open k8s/order-service-deployment.yaml.
  • Add the following Service definition at the very bottom of the file, below the Deployment you already pasted:
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  type: NodePort
  selector:
    app: order-service
  ports:
    - port: 8080
      targetPort: 8080
      nodePort: 30080

What is a NodePort Service?

A NodePort Service opens a specific port (30080) on every node in the cluster. When you hit that port, Kubernetes routes traffic to the order-service pods on port 8080. This is how you will access your API from outside the cluster using minikube service.

  • Save k8s/order-service-deployment.yaml.
  • Confirm both files exist by running:
ls k8s/

You should see order-service-deployment.yaml and postgres-deployment.yaml listed in the output.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete k8s/postgres-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: orderdb
            - name: POSTGRES_USER
              value: postgres
            - name: POSTGRES_PASSWORD
              value: postgres
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432

Here is the complete k8s/order-service-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-service
          image: order-service:latest
          imagePullPolicy: Never
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_DATASOURCE_URL
              value: jdbc:postgresql://postgres:5432/orderdb
            - name: SPRING_DATASOURCE_USERNAME
              value: postgres
            - name: SPRING_DATASOURCE_PASSWORD
              value: postgres
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 5
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  type: NodePort
  selector:
    app: order-service
  ports:
    - port: 8080
      targetPort: 8080
      nodePort: 30080

Files not showing up?

Make sure you created the files inside the k8s/ directory, not in the project root. The path should be order-service/k8s/postgres-deployment.yaml.

Help me find my Kubernetes manifest files

Apply manifests and verify the deployment

With both manifest files ready, you can now deploy everything to your Minikube cluster in a single command. Kubernetes reads all YAML files in the directory and creates the resources.

  • Apply all manifests in the k8s/ directory by running this command:
kubectl apply -f k8s/

You should see output confirming that 2 deployments and 2 services were created.

  • Check that all pods are running by running this command:
kubectl get pods

You should see 3 pods listed: 1 postgres pod and 2 order-service pods. Wait until all pods show Running in the STATUS column and 1/1 in the READY column.

Pods taking a while to start?

The order-service pods may take 30-40 seconds to become ready because of the initialDelaySeconds settings on your probes. Run kubectl get pods again after about 30 seconds if you see 0/1 in the READY column initially.

  • Access the order-service API through Minikube by running this command:
minikube service order-service

This command opens a tunnel to the NodePort Service and prints the URL. Your browser may open automatically.

  • Navigate to /swagger-ui.html at the URL that Minikube printed in your terminal.
  • Verify that the Swagger UI loads and you can see the Orders endpoints.

What should I see?

You should see the same Swagger UI you tested earlier, but now it is running inside Kubernetes. Try creating an order to confirm the database connection is working through the cluster-internal Service.

  • Confirm that health probes are passing by describing one of the order-service pods:
kubectl describe pod -l app=order-service

Scroll through the output and look for the Conditions section. You should see Ready: True and no restart events in the Events section.

Pods stuck in CrashLoopBackOff?

If the order-service pods keep restarting, the most common cause is that the postgres pod is not ready yet. Run kubectl logs -l app=order-service to check for connection errors.

If you see Connection refused errors, wait for the postgres pod to reach Running status first. Kubernetes will automatically retry the order-service pods.

If you see ImagePullBackOff, confirm that you ran minikube image load order-service:latest in the previous step and that imagePullPolicy: Never is set in your Deployment manifest.

Help me debug my Kubernetes pods that keep crashing

Secret mission

Add a GitHub Actions CI/CD Pipeline

You have a working API deployed to Kubernetes locally. But what happens when you push a code change? In real engineering teams, every push triggers automated tests and builds. Your secret mission is to create a GitHub Actions CI/CD pipeline that runs tests and verifies your Docker image on every push to main.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally using Minikube and Docker Desktop, so there are no ongoing cloud costs. However, running containers and a Kubernetes cluster does consume CPU, RAM, and disk space on your machine.

Resources you used:

  • Minikube cluster (local Kubernetes).
  • Docker Desktop (container runtime).
  • PostgreSQL container (running inside Minikube and/or standalone via Docker).
  • Spring Boot Docker image (order-service:latest).
  • Kubernetes Deployments and Services (order-service, postgres).
  • Project files in your order-service folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue experimenting with the API and Kubernetes deployment.

  • Your Minikube cluster stays running and accessible.
  • Docker Desktop continues running in the background.
  • All pods, services, and images remain available.

Pause - I'll come back to this later

Stop running processes to free up CPU and RAM, but keep all your files and images so you can resume quickly.

  • Stop the Minikube cluster by running:
minikube stop
  • Stop any standalone PostgreSQL containers by running:
docker stop $(docker ps -q --filter ancestor=postgres:16-alpine)
  • Optionally quit Docker Desktop from the system tray to free additional memory.
  • To resume later, start Docker Desktop, then run minikube start and your cluster will be restored.

Delete - I don't want to use this again

Remove all project resources and start fresh. This frees up disk space and removes all Kubernetes state.

  • Delete all Kubernetes resources by running:
kubectl delete -f k8s/
  • Delete the Minikube cluster entirely by running:
minikube delete
  • Remove the Docker image by running:
docker rmi order-service:latest
  • Stop and remove any standalone PostgreSQL containers by running:
docker stop $(docker ps -q --filter ancestor=postgres:16-alpine)
docker rm $(docker ps -aq --filter ancestor=postgres:16-alpine)
  • Delete the project folder from your Desktop if you no longer need the source code.

Nice Work!

Nice Work!

You just built a production-ready order management microservice from scratch and deployed it to a local Kubernetes cluster. This is the exact workflow backend engineers use daily to ship containerized services.

You've learned how to:

  • Build a Spring Boot REST API with layered architecture (Controller, Service, Repository), PostgreSQL integration via Spring Data JPA, and auto-generated Swagger UI documentation.
  • Containerize a Java application with a multi-stage Dockerfile using Spring Boot's layered JAR extraction for optimized Docker image caching.
  • Deploy to Kubernetes with liveness and readiness probes, rolling update strategy, resource limits, and a NodePort Service for external access.
  • Added a GitHub Actions CI/CD pipeline that automatically runs tests and builds the Docker image on every push.

Ready to quiz yourself?