Secured Product Microservice

Build a JWT-secured Spring Boot microservice and run it with Docker Compose.

Introduction

30 Second Summary

Think about how your banking app knows who you are and what you're allowed to do on every screen. Behind the scenes, a security token proves your identity with each request, and the server decides your permissions instantly.

In this project, you will build a Product Service microservice secured with JWT authentication and role-based access control. You'll register users, log in to receive a signed token, and use that token to access protected API endpoints running in Docker containers with a PostgreSQL database.

What You'll Build

You'll fire off API requests from your terminal, watch unauthorized users get rejected with error codes, and see admin-only operations succeed with proper credentials, all from a fully containerized stack you launch with a single command.

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

  • A working JWT authentication flow where you register a user, log in, and use the signed token to unlock protected API endpoints.
  • A role-based access control system where admin users can create and delete products while regular users can only browse the catalog.
  • A single Docker Compose command that launches your entire app and database together, exactly like a production deployment.
  • Secret Mission: Add pagination, sorting, and search filtering to handle product catalogs at scale.

Are there any prerequisites?

This project assumes you have core Java knowledge and understand Spring Boot fundamentals like annotations, dependency injection, and REST controllers. Basic SQL understanding is also helpful.

If you're comfortable writing a simple Spring Boot REST endpoint, you're ready to go.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This is your moment to commit to the goal so every step that follows has a clear purpose.

Set Up Your Development Environment

To build a production-grade microservice, you need two things: a containerization tool to package and run your app like it would in the real world, and a properly scaffolded project to build on.

In this step, you will install Docker Desktop for containerization, scaffold a Spring Boot project with all its dependencies, and confirm the app starts successfully.

In this step, get ready to:
  • Install Docker Desktop and verify it is running.
  • Scaffold a Spring Boot project with the required dependencies.
  • Configure an in-memory database and run the app on port 8080.
Install Docker Desktop

Docker lets you package your app and its database into containers that run identically on any machine. You will use it in a later step to run your microservice alongside PostgreSQL.

  • Check if Docker is already installed by running this command in your terminal:
docker --version

✔️ I see a version number

Docker is installed and ready. Make sure Docker Desktop is running before you continue.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Docker Desktop and press Enter to open it.
  • Wait until the Docker engine status shows a green icon in the bottom-left corner of the Docker Desktop window.

ⓧ Command not found

Docker is not installed yet. Follow the instructions below for your operating system.

macOS

  • Open https://www.docker.com/products/docker-desktop/ in your browser.
  • Click the download button for Mac (choose Apple Silicon or Intel depending on your machine).
  • Open the downloaded .dmg file and drag Docker to your Applications folder.
  • Open Docker Desktop from your Applications folder.
  • Accept the Docker Subscription Service Agreement when prompted. Docker Desktop is free for personal use and education.
  • Wait for the engine to start. You will see a green status icon in the bottom-left corner.

Windows

  • Open https://www.docker.com/products/docker-desktop/ in your browser.
  • Click the download button for Windows.
  • Run the downloaded installer. It does not require administrator privileges.
  • Accept the Docker Subscription Service Agreement when prompted. Docker Desktop is free for personal use and education.
  • After installation, Docker Desktop opens automatically. Wait for the engine to start.
  • You will see a green status icon in the bottom-left corner when the engine is ready.

After installation, close and reopen your terminal. Then run docker --version to confirm the command is now available.

Still seeing command not found?

Close and reopen your terminal after installing Docker Desktop. The docker command is only available in new terminal sessions started after installation.

On Windows, make sure the WSL 2 backend is enabled. Docker Desktop prompts you to install it during setup if it is missing.

Help me troubleshoot my Docker Desktop installation.

Scaffold the Spring Boot project

Spring Initializr is a web tool that generates a ready-to-build Maven project with all the dependencies you need. You will use it to set up your microservice skeleton.

  • Open https://start.spring.io/ in your browser.
  • Set Project to Maven.
  • Set Language to Java.
  • Set Spring Boot to 3.5.16 (or the latest 3.5.x version available).
  • Under Project Metadata, set Group to com.example.
  • Set Artifact to productservice.
  • Set Packaging to Jar.
  • Set Java to 17.
  • Click ADD DEPENDENCIES and add each of the following one at a time:
  • Spring Web.
  • Spring Data JPA.
  • Spring Security.
  • PostgreSQL Driver.
  • H2 Database.

Why these five dependencies?

Spring Web gives you REST controller support. Spring Data JPA handles database operations without writing SQL. Spring Security is the framework you will configure for JWT authentication.

PostgreSQL Driver connects to your production database later in Docker. H2 Database is a lightweight in-memory database you will use during development so you can build and test without running Docker yet.

  • Click GENERATE to download the project as a ZIP file.
  • Extract the ZIP file to your Desktop.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ IDEA and press Enter to open it.
  • Click Open on the welcome screen.
  • Navigate to your Desktop and select the productservice folder you just extracted.
  • Click OK to open the project.

IntelliJ will take a moment to index the project and download Maven dependencies. Wait until the progress bar in the bottom status bar finishes.

What should I see?

In the Project panel on the left, you should see a src/main/java/com/example/productservice folder containing ProductserviceApplication.java. You should also see src/main/resources/application.properties (currently empty).

Project not loading correctly?

If IntelliJ does not recognize the project as a Maven project, right-click the pom.xml file in the Project panel and select Add as Maven Project.

If dependency downloads fail, check your internet connection. IntelliJ needs to reach Maven Central to fetch the Spring Boot starter libraries.

Help me get my Spring Boot project loading in IntelliJ IDEA.

Configure H2 for local development and run the app

Spring Boot needs a database connection to start. Instead of running PostgreSQL in Docker right away, you will use H2, a lightweight in-memory database that disappears when the app stops.

You also need to temporarily disable Spring Security's default lockdown so you can test your API freely in the next step.

  • In IntelliJ's Project panel, expand src/main/resources and open application.properties.
  • Replace the empty file with the following configuration:
spring.datasource.url=jdbc:h2:mem:productdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true

What does this configuration do?

  • spring.datasource.url=jdbc:h2:mem:productdb creates an in-memory database called productdb. It resets every time you restart the app.
  • spring.jpa.hibernate.ddl-auto=update tells JPA to automatically create and update database tables based on your Java entities.
  • spring.h2.console.enabled=true enables a web-based database viewer at /h2-console for debugging.
  • Save application.properties.

Now you need a temporary security configuration. By default, Spring Security blocks every request and requires a login. You will disable this for now so you can build and test your API freely.

  • In IntelliJ's Project panel, right-click the com.example.productservice package under src/main/java.
  • Select New then Package.
  • Type config and press Enter.
  • Right-click the new config package and select New then Java Class.
  • Type SecurityConfig and press Enter.
  • Replace the entire contents of the file with the following code:
package com.example.productservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}

What does this code do?

The @Configuration annotation tells Spring this class defines beans. The SecurityFilterChain bean is where all HTTP security rules live.

The line auth.anyRequest().permitAll() tells Spring Security to let every request through without authentication. This is temporary. You will replace it with JWT-based rules in Step 3.

  • Save SecurityConfig.java.

✔️ Awesome, I've got everything!

Great. Make sure both application.properties and SecurityConfig.java are saved before continuing.

ⓧ I'd like to double check the full code

Here is application.properties:

spring.datasource.url=jdbc:h2:mem:productdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true

Here is SecurityConfig.java:

package com.example.productservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}

Time to see if everything works. Before you run the app, what do you think will happen when Spring Boot starts with these two files in place?

  • Start the application by opening IntelliJ's built-in terminal with Alt+F12 (Windows) or Option+F12 (macOS), then run this command:

macOS

./mvnw spring-boot:run

Permission denied?

If you see a permission error, make the wrapper executable first by running:

chmod +x mvnw

Windows

mvnw.cmd spring-boot:run

First run takes a while

Maven downloads all project dependencies on the first run. This can take 2-5 minutes depending on your internet connection. You will see download progress in the terminal. Subsequent runs are much faster.

You should see the Spring Boot banner appear in the terminal, followed by a line that says Started ProductserviceApplication and that the app is running on port 8080.

App fails to start?

If you see a datasource error, double-check that application.properties contains spring.datasource.url=jdbc:h2:mem:productdb with no typos.

If you see a compilation error mentioning SecurityConfig, make sure the file is in the com.example.productservice.config package and that the package declaration at the top matches.

If port 8080 is already in use, stop whatever is running on that port or close other development servers before trying again.

Help me fix my Spring Boot startup error.

Your development environment is fully set up. Next, you will build the Product REST API with full CRUD endpoints that anyone can access.

Build the Product REST API

Your Spring Boot app starts successfully, but it has no endpoints yet. A microservice needs an API that clients can call to create, read, update, and delete data.

In this step, you will build a complete Product REST API following the layered architecture pattern. This means a separate entity, repository, service, and controller, exactly the structure interviewers expect to see in a Spring Boot microservice.

In this step, get ready to:
  • Create a Product entity and database repository.
  • Build a service and controller with full CRUD endpoints.
  • Test all endpoints with curl to verify they work without authentication.
Create the Product entity and repository

Every REST API needs a data model. The Product entity defines what a product looks like in the database, and the repository provides methods to save and retrieve products automatically.

  • In IntelliJ IDEA, right-click the com.example.productservice package in the Project sidebar.
  • Select New then Package.
  • Name the package model and press Enter.
  • Right-click the new model package, select New then Java Class, and name it Product.
  • Replace the generated file content with the following code:
package com.example.productservice.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

// JPA entity mapped to the "products" table
@Entity
@Table(name = "products")
public class Product {
    // Auto-generated primary key
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String description;
    private BigDecimal price;
    private String category;

    // getters and setters
}

What does this code do?

  • @Entity tells JPA this class maps to a database table.
  • @Table(name = "products") sets the table name explicitly.
  • @Id and @GeneratedValue mark the id field as an auto-incrementing primary key.
  • The other fields (name, description, price, category) become columns in the table automatically.

The // getters and setters comment is a placeholder. You need to generate actual getter and setter methods for JPA to read and write your fields.

  • Place your cursor inside the Product class body, below the category field.
  • Delete the // getters and setters comment line.
  • Press Cmd+N (macOS) or Alt+Insert (Windows) to open the Generate menu.
  • Select Getter and Setter.
  • Select all five fields (id, name, description, price, category) and click OK.

IntelliJ generates ten methods (a getter and setter for each field).

Now create the repository. The repository gives you database access methods like findAll() and save() without writing any SQL.

  • Right-click the com.example.productservice package, select New then Package, and name it repository.
  • Right-click the repository package, select New then Java Class, choose Interface, and name it ProductRepository.
  • Replace the file content with the following code:
package com.example.productservice.repository;

import com.example.productservice.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;

// Spring Data JPA provides CRUD methods automatically
public interface ProductRepository extends JpaRepository<Product, Long> {
}

What does this code do?

By extending JpaRepository<Product, Long>, Spring Data JPA automatically provides methods like findAll(), findById(), save(), and deleteById(). You write zero implementation code.

  • Save both files.
  • Return to your terminal and restart the app by pressing Ctrl+C to stop it, then running:
./mvnw spring-boot:run

You should see the Spring banner appear and the app start on port 8080 with no errors. The products table is now created in H2 automatically.

Seeing a compilation error?

  • Make sure your Product.java import uses jakarta.persistence.* (not javax.persistence). Spring Boot 3.x uses the Jakarta namespace.
  • Confirm your getters and setters were generated for all five fields.
  • Check that ProductRepository imports com.example.productservice.model.Product and not a different Product class.

Help me debug my Spring Boot entity or repository compilation error.

Create the Product service and controller

The service layer holds business logic and sits between the controller and repository. The controller maps incoming HTTP requests to service methods and returns proper HTTP responses.

  • Right-click com.example.productservice, create a new package called service.
  • Inside the service package, create a new Java class called ProductService.
  • Replace the file content with the following code:
package com.example.productservice.service;

import com.example.productservice.model.Product;
import com.example.productservice.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;

// Marks this class as a Spring-managed service bean
@Service
public class ProductService {
    private final ProductRepository productRepository;

    // Constructor injection of the repository
    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // Delegate each operation to the repository
    public List<Product> findAll() { return productRepository.findAll(); }
    public Optional<Product> findById(Long id) { return productRepository.findById(id); }
    public Product save(Product product) { return productRepository.save(product); }
    public void deleteById(Long id) { productRepository.deleteById(id); }
}

What does this code do?

  • @Service registers this class with Spring's dependency injection container.
  • The constructor receives the ProductRepository automatically (constructor injection).
  • Each method wraps a repository call. In a real app, this layer would hold validation, caching, and transaction logic.

Now create the controller that exposes HTTP endpoints. You will build it in two parts: read endpoints first, then write endpoints.

  • Right-click com.example.productservice, create a new package called controller.
  • Inside the controller package, create a new Java class called ProductController.
  • Replace the file content with this code for the class setup and GET endpoints:
package com.example.productservice.controller;

import com.example.productservice.model.Product;
import com.example.productservice.service.ProductService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

// REST controller for product CRUD operations
@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    // GET /api/products - list all products
    @GetMapping
    public List<Product> getAll() { return productService.findAll(); }

    // GET /api/products/{id} - get one product
    @GetMapping("/{id}")
    public ResponseEntity<Product> getById(@PathVariable Long id) {
        return productService.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
    }
}

What does this code do?

  • @RestController combines @Controller and @ResponseBody, so every method returns JSON automatically.
  • @RequestMapping("/api/products") sets the base URL path for all endpoints in this class.
  • The getAll() method returns the full list. The getById() method returns a 200 with the product or a 404 if not found.
  • Save all files.
  • Restart the app by pressing Ctrl+C and running:
./mvnw spring-boot:run
  • Open a new terminal tab and test the GET endpoint by running:
curl http://localhost:8080/api/products

You should see an empty JSON array [] returned. Your read endpoints are working.

Now add the write endpoints (create, update, delete) to the same controller.

  • In ProductController.java, place your cursor after the closing brace of the getById method (but still inside the class).
  • Add the following methods:
    // POST /api/products - create a new product
    @PostMapping
    public ResponseEntity<Product> create(@RequestBody Product product) {
        return ResponseEntity.status(HttpStatus.CREATED).body(productService.save(product));
    }

    // PUT /api/products/{id} - update existing product
    @PutMapping("/{id}")
    public ResponseEntity<Product> update(@PathVariable Long id, @RequestBody Product product) {
        return productService.findById(id).map(existing -> {
            product.setId(id);
            return ResponseEntity.ok(productService.save(product));
        }).orElse(ResponseEntity.notFound().build());
    }

    // DELETE /api/products/{id} - remove a product
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        productService.deleteById(id);
        return ResponseEntity.noContent().build();
    }

What does this code do?

  • @PostMapping handles POST requests. It returns HTTP 201 Created with the saved product (including its generated id).
  • @PutMapping handles updates. It first checks if the product exists, then overwrites it. If not found, it returns 404.
  • @DeleteMapping removes the product and returns HTTP 204 No Content.
  • Save ProductController.java.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all four files: Product.java, ProductRepository.java, ProductService.java, and ProductController.java.

ⓧ I'd like to double check the full code

Scroll up and confirm each of the following code blocks is present in your project, in order:

  • Product.java with the @Entity annotation, five fields, and generated getters/setters.
  • ProductRepository.java interface extending JpaRepository<Product, Long>.
  • ProductService.java with four methods: findAll, findById, save, deleteById.
  • ProductController.java with five endpoints: getAll, getById, create, update, delete.
Test the API with curl

All five endpoints are wired up. Time to prove the full CRUD cycle works end-to-end.

  • Restart the app by pressing Ctrl+C in the terminal running Spring Boot, then running:
./mvnw spring-boot:run
  • In your second terminal tab, create a product by running:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Mechanical Keyboard","description":"RGB backlit","price":89.99,"category":"Electronics"}'

You should see a JSON response with the product and an auto-generated id of 1.

  • Retrieve the product by running:
curl http://localhost:8080/api/products

You should see a JSON array containing the keyboard product you just created.

  • Update the product by running:
curl -X PUT http://localhost:8080/api/products/1 -H "Content-Type: application/json" -d '{"name":"Mechanical Keyboard Pro","description":"RGB backlit, hot-swap","price":129.99,"category":"Electronics"}'

You should see the updated product returned with the new name and price.

  • Delete the product by running:
curl -X DELETE http://localhost:8080/api/products/1 -v

You should see a 204 No Content response, confirming the product was removed.

  • Confirm the deletion by running the list request one more time:
curl http://localhost:8080/api/products

You should see an empty array [] again.

Getting a 404 or connection refused?

  • Make sure the app is still running in the other terminal tab. Look for the Started ProductserviceApplication message.
  • Check that your @RequestMapping path is /api/products (not /products or /api/product).
  • Verify that SecurityConfig still has .anyRequest().permitAll(). If it was removed, Spring Security blocks everything.

Help me debug why my Spring Boot REST endpoints return 404 or refuse connections.

Notice anything missing?

Every single request succeeded. No login, no token, no password. Anyone who can reach port 8080 can create, read, update, or delete every product in your database.

This is the problem. Your microservice has zero access control. In the next step, you will lock it down with Spring Security and JWT authentication.

Your REST API is alive and serving data. Next up, you will experience what happens when Spring Security locks it all down, and build a JWT authentication system to restore controlled access.

Secure the API with JWT Authentication

Your Product API works. Anyone can create, read, update, and delete products with a simple curl command. That is a serious problem.

In a real microservice, you need to know WHO is making each request before you decide whether to allow it. In this step, you will add JWT authentication so that users must register, log in to receive a signed token, and present that token on every request. Without it, they get a 401 Unauthorized response.

In this step, get ready to:
  • Add the JJWT library and experience Spring Security locking you out.
  • Build a JWT service that creates and validates signed tokens.
  • Wire a security filter chain and authentication endpoints to restore controlled access.
Add JJWT dependencies and experience the lockout

Before you can issue tokens, you need the JJWT library. You will also remove the temporary permit-all rule and see what Spring Security does by default when no explicit permit exists.

  • Open pom.xml in IntelliJ and find the <dependencies> section.
  • Add the three JJWT dependencies inside the <dependencies> block:
<!-- JWT support for token creation and validation -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.13.0</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.13.0</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.13.0</version>
    <scope>runtime</scope>
</dependency>

What are these three dependencies?

JJWT splits its library into three JARs. jjwt-api provides the interfaces your code compiles against. jjwt-impl contains the actual implementation logic at runtime. jjwt-jackson handles JSON serialization inside the token.

The runtime scope means your code never imports these directly. Maven only includes them when the app runs.

  • Save pom.xml. IntelliJ will prompt you to reload Maven dependencies.
  • Click the refresh icon or press Cmd+Shift+I (macOS) or Ctrl+Shift+O (Windows) to reload.

Now remove the temporary permit-all rule so Spring Security enforces its default deny behavior.

  • Open SecurityConfig.java and find this line inside the securityFilterChain method:
http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
  • Delete that line entirely, so the method body only contains return http.build();:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http.build();
}
  • Save SecurityConfig.java.
  • Stop your running app if it is still active (Ctrl+C in the terminal).
  • Restart the application by running:
./mvnw spring-boot:run

Before you test, predict: what do you think will happen when you hit the products endpoint now that the permit-all rule is gone?

  • Open a second terminal and run:
curl http://localhost:8080/api/products

You get a 401 Unauthorized response. Spring Security 6 denies all requests by default when no explicit authorization rules are configured. This is the problem you are about to solve.

Why does Spring Security block everything?

Spring Security follows a deny-by-default philosophy. If you do not explicitly permit a request, it is rejected. This is safer than allowing everything and hoping you remembered to lock down sensitive endpoints.

Your job now is to build a JWT system that lets Spring Security verify who is making the request and whether they should be allowed in.

Still seeing 200 OK?

Make sure you deleted the entire http.authorizeHttpRequests(...) line, not just part of it. The method should only contain return http.build();. Also confirm you saved the file and restarted the app.

ask me to help debug my Spring Security configuration

Build the JWT service and user entity

To issue and validate tokens, you need two things: a User model that stores credentials and roles, and a JwtService that handles the cryptographic work of signing and verifying tokens.

  • Create a new file Role.java inside src/main/java/com/example/productservice/model/ with this content:
package com.example.productservice.model;

// Defines the two access levels for the application
public enum Role {
    USER, ADMIN
}
  • Create a new file User.java in the same model package:
package com.example.productservice.model;

import jakarta.persistence.*;

// Stores registered user credentials and their assigned role
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    @Enumerated(EnumType.STRING)
    private Role role;

    // Generate getters and setters for all fields
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public Role getRole() { return role; }
    public void setRole(Role role) { this.role = role; }
}
  • Create a new file UserRepository.java inside src/main/java/com/example/productservice/repository/:
package com.example.productservice.repository;

import com.example.productservice.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

// Spring Data JPA generates the SQL queries from the method name
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

What does this code do?

The Role enum defines two permission levels: USER and ADMIN. The User entity maps to a users table in your database. The @Enumerated(EnumType.STRING) annotation stores the role as readable text rather than an integer.

The UserRepository provides database access. Spring Data JPA automatically generates the SQL query for findByUsername just from the method name.

  • Save all three files.

Now build the service that creates and validates JWT tokens.

  • Create a new file JwtService.java inside src/main/java/com/example/productservice/service/ with the token generation logic:
package com.example.productservice.service;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;

@Service
public class JwtService {
    // Reads the secret from application.properties
    @Value("${jwt.secret}")
    private String secret;

    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    // Creates a signed JWT valid for 24 hours
    public String generateToken(String username) {
        return Jwts.builder()
            .subject(username)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + 86400000))
            .signWith(getSigningKey())
            .compact();
    }
}
  • Add the token validation methods below the generateToken method, inside the JwtService class:
    // Extracts the username from a valid token
    public String extractUsername(String token) {
        return Jwts.parser().verifyWith((javax.crypto.SecretKey) getSigningKey()).build()
            .parseSignedClaims(token).getPayload().getSubject();
    }

    // Returns true if the token signature and expiration are valid
    public boolean isTokenValid(String token) {
        try {
            Jwts.parser().verifyWith((javax.crypto.SecretKey) getSigningKey()).build().parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

How does token validation work?

The generateToken method signs a JWT containing the username and a 24-hour expiration. The signing key is derived from a secret string in your configuration.

When a request arrives, isTokenValid verifies the signature has not been tampered with and the token has not expired. If either check fails, the parser throws an exception and the method returns false.

  • Open application.properties and add the JWT secret key at the bottom of the file:
jwt.secret=your-256-bit-secret-key-here-make-it-long-enough
  • Save JwtService.java and application.properties.
  • Stop the running app and restart it to verify everything compiles:
./mvnw spring-boot:run

You should see the Spring banner and a successful startup. The app still returns 401 for product requests because you have not wired the filter yet. That comes next.

Seeing a compilation error?

Check that your Maven reload completed successfully. The io.jsonwebtoken imports in JwtService will be red if the JJWT dependencies did not download. Re-run the Maven reload from the pom.xml change.

Also verify that jwt.secret exists in application.properties. Without it, the @Value injection fails at startup.

help me fix my JwtService compilation errors

Wire the security filter chain and auth controller

You have a JwtService that can create and validate tokens, and a User model to store credentials. Now you need to connect them to Spring Security so every incoming request is checked for a valid token.

  • Create a new package filter under src/main/java/com/example/productservice/.
  • Create JwtAuthenticationFilter.java inside the filter package with the class shell:
package com.example.productservice.filter;

import com.example.productservice.service.JwtService;
import com.example.productservice.repository.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private final UserRepository userRepository;
    public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
        this.jwtService = jwtService;
        this.userRepository = userRepository;
    }
}
  • Add the doFilterInternal method inside the class, below the constructor:
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // Extract the Bearer token from the Authorization header
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            if (jwtService.isTokenValid(token)) {
                String username = jwtService.extractUsername(token);
                // Look up the user and set their role in the SecurityContext
                userRepository.findByUsername(username).ifPresent(user -> {
                    var auth = new UsernamePasswordAuthenticationToken(
                        username, null,
                        List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())));
                    SecurityContextHolder.getContext().setAuthentication(auth);
                });
            }
        }
        filterChain.doFilter(request, response);
    }

How does the filter intercept requests?

This filter runs once for every HTTP request. It checks the Authorization header for a Bearer token. If one exists and is valid, the filter extracts the username and sets the SecurityContext so Spring Security knows who is making the request.

If no token is present or the token is invalid, the filter does nothing and Spring Security's default deny kicks in.

  • Save JwtAuthenticationFilter.java.

Now replace the SecurityConfig to wire everything together.

  • Open SecurityConfig.java and replace everything below the package declaration with:
import com.example.productservice.filter.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}

What does this SecurityFilterChain do?

This is the core of your security setup. .csrf(csrf -> csrf.disable()) disables CSRF protection because your API uses explicit tokens in headers, not cookies. .sessionManagement(...) tells Spring to never create HTTP sessions. Each request must prove identity with its own token.

The requestMatchers("/api/auth/**").permitAll() line lets unauthenticated users reach the register and login endpoints. Everything else requires a valid token. addFilterBefore inserts your custom JWT filter before Spring's default authentication filter.

  • Save SecurityConfig.java.

Finally, create the authentication endpoints so users can register and log in.

  • Create AuthController.java inside src/main/java/com/example/productservice/controller/ with the register endpoint:
package com.example.productservice.controller;

import com.example.productservice.model.Role;
import com.example.productservice.model.User;
import com.example.productservice.repository.UserRepository;
import com.example.productservice.service.JwtService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtService = jwtService;
    }
    // Registers a new user with a hashed password
    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody Map<String, String> body) {
        User user = new User();
        user.setUsername(body.get("username"));
        user.setPassword(passwordEncoder.encode(body.get("password")));
        user.setRole(Role.USER);
        userRepository.save(user);
        return ResponseEntity.ok(Map.of("message", "User registered successfully"));
    }
}
  • Save AuthController.java, stop the running app, and restart:
./mvnw spring-boot:run
  • Test the register endpoint by running:
curl -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d "{\"username\":\"alice\",\"password\":\"secret123\"}"

You should see {"message":"User registered successfully"} in the response. Registration works.

Now add the login endpoint that returns a signed JWT.

  • In AuthController.java, add the login method below the register method, inside the class:
    // Validates credentials and returns a signed JWT
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody Map<String, String> body) {
        return userRepository.findByUsername(body.get("username"))
            .filter(u -> passwordEncoder.matches(body.get("password"), u.getPassword()))
            .map(u -> ResponseEntity.ok(Map.of("token", jwtService.generateToken(u.getUsername()))))
            .orElse(ResponseEntity.status(401).build());
    }

How does login work?

The login method finds the user by username, then uses BCrypt to compare the submitted password against the stored hash. If they match, it generates a signed JWT and returns it. If the username does not exist or the password is wrong, the response is 401 Unauthorized.

  • Save AuthController.java, stop the app, and restart:
./mvnw spring-boot:run

Before you test the full flow, predict: if you log in with the user you just registered, what should the response contain?

  • Log in with the user you registered:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d "{\"username\":\"alice\",\"password\":\"secret123\"}"

You should see a JSON response containing a token field with a long encoded string. That is your signed JWT.

  • Copy the token value from the response. Use it in the Authorization header to access the products endpoint:
curl http://localhost:8080/api/products -H "Authorization: Bearer your-token-here"

Replace your-token-here with the actual token from your login response. You should see [] (an empty product list) returned successfully.

  • Confirm that requests without a token still fail:
curl http://localhost:8080/api/products

This returns 401 Unauthorized. Your API now requires authentication for all product endpoints while keeping the auth endpoints open for registration and login.

Getting 403 instead of 200 with your token?

Make sure the token in your header starts immediately after Bearer (with one space). Extra whitespace or a missing space will cause validation to fail.

Also check that you restarted the app after adding the login method. The H2 database is in-memory, so you need to register the user again after each restart.

help me debug why my JWT token is not working

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and your app restarts cleanly.

ⓧ I'd like to double check the full code

Scroll up and confirm you have each of these files in order:

  • Role.java in the model package (enum with USER, ADMIN).
  • User.java in the model package (entity with id, username, password, role).
  • UserRepository.java in the repository package (with findByUsername method).
  • JwtService.java in the service package (generateToken, extractUsername, isTokenValid).
  • JwtAuthenticationFilter.java in the filter package (doFilterInternal with Bearer extraction).
  • SecurityConfig.java in the config package (CSRF disabled, STATELESS, auth paths permitted, filter added).
  • AuthController.java in the controller package (register and login endpoints).
  • application.properties with the jwt.secret line added.

Your API is no longer wide open. Every request to /api/products must carry a valid JWT. Next up, you will add role-based access control so that only admin users can modify the product catalog.

Add Role-Based Access Control

Your API is locked behind JWT authentication, but every authenticated user has the same power. A regular user can delete the entire product catalog just as easily as an admin.

In production, different users need different permission levels. In this step, you will add role-based access control (RBAC) so that only admins can modify products while regular users can only read them.

In this step, get ready to:
  • Update the SecurityFilterChain with role-based authorization rules.
  • Update the registration endpoint to support assigning roles.
  • Verify that USER accounts get 403 Forbidden on write operations while ADMIN accounts have full access.
Add role-based authorization rules

Authentication answers "who are you?" but authorization answers "what are you allowed to do?" Spring Security's SecurityFilterChain evaluates rules in the order you declare them, applying the first match to each request.

You will add rules that let any authenticated user read products (GET requests), but require the ADMIN role for creating, updating, or deleting them.

  • Stop the running application by pressing Ctrl+C in the terminal where it is running.
  • In IntelliJ IDEA, open SecurityConfig.java from src/main/java/com/example/productservice/config/.
  • Add this import at the top of the file, below the existing imports:
import org.springframework.http.HttpMethod;
  • Find the authorizeHttpRequests section inside the securityFilterChain method. It currently looks like this:
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/auth/**").permitAll()
    .anyRequest().authenticated())
  • Replace that entire .authorizeHttpRequests(...) block with the following:
// Define which endpoints require which roles
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/auth/**").permitAll()
    .requestMatchers(HttpMethod.GET, "/api/products/**").authenticated()
    .requestMatchers("/api/products/**").hasRole("ADMIN")
    .anyRequest().authenticated())

What does this code do?

Spring Security evaluates authorization rules top-to-bottom and applies the first match. The order here is critical.

  • .requestMatchers("/api/auth/**").permitAll() keeps login and registration open to everyone.
  • .requestMatchers(HttpMethod.GET, "/api/products/**").authenticated() allows any authenticated user to read products.
  • .requestMatchers("/api/products/**").hasRole("ADMIN") catches all remaining product requests (POST, PUT, DELETE) and requires the ADMIN role.

The hasRole("ADMIN") method automatically checks for the authority ROLE_ADMIN. This matches what your JwtAuthenticationFilter already sets: "ROLE_" + user.getRole().name().

  • Save SecurityConfig.java.
  • Restart the application by running:
./mvnw spring-boot:run
  • Once the Spring banner appears, register a test user by running:
curl -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d '{"username":"alice","password":"password123"}'
  • Log in with that user to get a JWT token:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"alice","password":"password123"}'

Copy the token value from the response. You will use it in the next two commands.

  • Test that your USER account can still read products by running:
curl http://localhost:8080/api/products -H "Authorization: Bearer your-token-here"

You should see [] (an empty product list). The GET request works.

Before you run the next command, what do you think will happen when a USER-role account tries to create a product?

  • Try to create a product with the same USER token:
curl -X POST http://localhost:8080/api/products -H "Authorization: Bearer your-token-here" -H "Content-Type: application/json" -d '{"name":"Laptop","description":"A powerful laptop","price":999.99,"category":"Electronics"}'

You get a 403 Forbidden response. Your RBAC rules are working. The USER can read but cannot write.

Still seeing 200 instead of 403?

  • Make sure you saved SecurityConfig.java and restarted the app after saving.
  • Confirm the new HttpMethod import is present at the top of the file.
  • Check that the rule order matches exactly. The GET matcher must come before the catch-all product matcher.

Help me debug my role-based access control rules in SecurityConfig.

Update registration to support roles and test

Your RBAC rules block non-admin users, but the register endpoint currently hardcodes every new user to the USER role. You need a way to create ADMIN accounts to prove the full access control works.

  • Stop the application with Ctrl+C.
  • Open AuthController.java from src/main/java/com/example/productservice/controller/.
  • In the register method, find this line:
user.setRole(Role.USER);
  • Replace that single line with these two lines, so the method reads the role from the request body but defaults to USER if none is provided:
// Accept optional role parameter, default to USER
String roleStr = body.getOrDefault("role", "USER");
user.setRole(Role.valueOf(roleStr.toUpperCase()));

What does this change do?

The getOrDefault("role", "USER") method checks if the request body includes a role field. If not, it defaults to "USER".

The Role.valueOf(roleStr.toUpperCase()) converts the string to your Role enum value. This means callers can pass "role": "ADMIN" when registering to get admin access.

  • Save AuthController.java.
  • Restart the application:
./mvnw spring-boot:run

Now test the full RBAC flow with both a USER and an ADMIN account.

  • Register a regular user:
curl -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d '{"username":"bob","password":"password123"}'
  • Register an admin user (notice the role field):
curl -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d '{"username":"adminuser","password":"password123","role":"ADMIN"}'
  • Log in as the admin to get an ADMIN token:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"adminuser","password":"password123"}'

What do you expect this time, when you use an ADMIN token to create a product?

  • Use the ADMIN token to create a product:
curl -X POST http://localhost:8080/api/products -H "Authorization: Bearer your-admin-token-here" -H "Content-Type: application/json" -d '{"name":"Laptop","description":"A powerful laptop","price":999.99,"category":"Electronics"}'

You get a 201 Created response with the product JSON. The ADMIN role grants full access.

  • Now log in as the regular user to verify the restriction still holds:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"bob","password":"password123"}'
  • Use the USER token to try reading the product (should succeed):
curl http://localhost:8080/api/products -H "Authorization: Bearer your-user-token-here"

You see the Laptop product in the response. Regular users can read just fine.

  • Try to delete the product with the USER token (should fail):
curl -X DELETE http://localhost:8080/api/products/1 -H "Authorization: Bearer your-user-token-here"

You get 403 Forbidden. Your microservice now has production-grade role-based access control.

Why does rule order matter?

Spring Security applies the first matching rule to each request. The GET rule for /api/products/** must come before the catch-all /api/products/** rule.

If you reversed the order, the hasRole("ADMIN") rule would match all product requests first, blocking regular users from even reading. This is exactly the kind of detail interviewers love to ask about.

Getting 403 on both accounts?

  • Confirm you registered the admin with "role":"ADMIN" in the JSON body. Without it, the account defaults to USER.
  • Make sure you are using the correct token for each request. Tokens are user-specific.
  • H2 is an in-memory database. If you restarted the app between registering and testing, your users were wiped. Re-register after each restart.

Help me debug why both my USER and ADMIN accounts are getting 403.

✔️ Awesome, I've got everything!

Great. Double check you have saved both SecurityConfig.java and AuthController.java.

ⓧ I'd like to double check the full code

Confirm the following edits are in place:

  • SecurityConfig.java has the import org.springframework.http.HttpMethod; line and the updated authorizeHttpRequests block with three matchers (auth permitAll, GET authenticated, catch-all hasRole ADMIN) plus anyRequest().authenticated().
  • AuthController.java register method uses body.getOrDefault("role", "USER") and Role.valueOf(roleStr.toUpperCase()) instead of the hardcoded Role.USER.

Your API now enforces who can do what, not just who is logged in. Next up, you will containerize the whole stack with Docker and swap H2 for a real PostgreSQL database.

Dockerize with PostgreSQL

Your microservice is fully secured with JWT and role-based access control. But there is one big gap: H2 is an in-memory database. Every time you stop the app, every user account and product vanishes.

Production microservices use real databases like PostgreSQL. Docker Compose lets you run your entire stack (app + database) with one command, exactly how it runs in production.

In this step, get ready to:
  • Create a multi-stage Dockerfile that builds a minimal production image.
  • Define both services in a Docker Compose file with a PostgreSQL database.
  • Verify the full stack runs and data persists across restarts.
Create the Dockerfile

A multi-stage Docker build uses one container to compile your code and a second, smaller container to run it. This keeps your production image tiny because it does not include Maven or the full JDK.

  • In IntelliJ IDEA, right-click the project root (productservice) and select New then File.
  • Name the file Dockerfile (no extension) and press Enter.
  • Paste the following into Dockerfile:
# Build stage - compiles the app using Maven and JDK 17
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Run stage - uses only the lightweight JRE to run the JAR
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

What does this Dockerfile do?

The build stage (maven:3.9-eclipse-temurin-17) downloads dependencies and packages your app into a JAR file.

The run stage (eclipse-temurin:17-jre-alpine) copies only the finished JAR into a minimal Alpine Linux image. The result is a production image under 200 MB instead of the 800+ MB build image.

  • Save Dockerfile.
  • Confirm the file exists by running this in your terminal:
ls Dockerfile

You should see Dockerfile listed in the output.

Don't see the file?

Make sure your terminal is in the productservice project root (the folder containing pom.xml). Run ls pom.xml to confirm you are in the right directory.

help me find my project root directory

✔️ Awesome, I've got everything!

Great. Make sure you have saved Dockerfile before moving on.

ⓧ I'd like to double check the full code

# Build stage
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Run stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Create docker-compose.yml with PostgreSQL

Docker Compose orchestrates multiple containers as a single application. You will define two services: a PostgreSQL database and your Spring Boot app.

  • In the project root, create a new file called docker-compose.yml (same level as Dockerfile).
  • Paste the following into docker-compose.yml:
version: '3.8'
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_DB: productdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: docker
      JWT_SECRET: your-256-bit-secret-key-here-make-it-long-enough
    depends_on:
      db:
        condition: service_healthy
volumes:
  postgres_data:

What does this compose file do?

  • db service runs postgres:18 with a named volume (postgres_data) so data survives container restarts.
  • healthcheck uses pg_isready to confirm PostgreSQL is accepting connections before the app starts.
  • app service builds from your Dockerfile, maps port 8080, and passes SPRING_PROFILES_ACTIVE=docker so Spring loads the Docker-specific config.
  • depends_on with condition: service_healthy ensures the app waits for a healthy database before starting.

Now you need a Spring profile that tells the app how to connect to PostgreSQL instead of H2.

  • In src/main/resources/, create a new file called application-docker.properties.
  • Paste the following into application-docker.properties:
spring.datasource.url=jdbc:postgresql://db:5432/productdb
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=update

What is a Spring profile?

Spring profiles let you swap configuration based on the environment. When SPRING_PROFILES_ACTIVE=docker is set, Spring automatically loads application-docker.properties on top of the default application.properties.

The hostname db in the JDBC URL resolves to the PostgreSQL container because Docker Compose puts both services on the same network.

  • Save both docker-compose.yml and application-docker.properties.
  • Validate your compose file by running:
docker compose config --services

You should see two service names printed: app and db.

Seeing a YAML error?

YAML is sensitive to indentation. Make sure you used spaces (not tabs) and that each nested level is indented exactly 2 spaces from its parent.

help me fix my docker-compose.yml syntax

✔️ Awesome, I've got everything!

Great. Confirm you saved both docker-compose.yml and application-docker.properties.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml:

version: '3.8'
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_DB: productdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: docker
      JWT_SECRET: your-256-bit-secret-key-here-make-it-long-enough
    depends_on:
      db:
        condition: service_healthy
volumes:
  postgres_data:

Here is the complete application-docker.properties:

spring.datasource.url=jdbc:postgresql://db:5432/productdb
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=update
Build and run the full stack

Everything is wired up. Time to launch the entire stack with a single command.

  • If your Spring Boot app is still running locally (from earlier steps), stop it by pressing Ctrl+C in that terminal. This frees port 8080 for the Docker container.
  • Make sure Docker Desktop is running (check for the whale icon in your system tray).

Before you run the next command, what do you think will happen? Will both containers start at the same time, or will one wait for the other?

  • Build and start both containers by running:
docker compose up --build

Watch the terminal output. PostgreSQL starts first. You will see the healthcheck pass, then the Spring Boot app begins its startup sequence.

What should I see?

You will see Docker building the multi-stage image (downloading Maven dependencies, packaging the JAR), then pulling the postgres:18 image. After the db healthcheck passes, the app container starts and you will see the Spring Boot banner followed by Started ProductserviceApplication in the logs.

Now test the full flow against the containerized app. Open a second terminal window (leave the compose logs running).

  • Register a new admin user by running:
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"pass123","role":"ADMIN"}'

You should see {"message":"User registered successfully"} in the response.

  • Log in to get a JWT token by running:
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"pass123"}'

Copy the token value from the response. You will use it in the next command.

  • Create a product using your token (replace your-token-here with the actual token you received):
curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-token-here" \
  -d '{"name":"Laptop","description":"Dev machine","price":999.99,"category":"Electronics"}'

You should see the created product returned with an id field.

Now for the real test. Does data survive a restart?

  • Stop the containers by pressing Ctrl+C in the compose terminal, then run:
docker compose down
  • Start the stack again (no --build needed since nothing changed):
docker compose up
  • In your second terminal, log in again and fetch products:
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"pass123"}'
  • Use your new token to list products:
curl http://localhost:8080/api/products \
  -H "Authorization: Bearer your-token-here"

Your Laptop product is still there. The named volume postgres_data preserved the data across restarts. With H2, it would be gone.

App container fails to start?

  • If you see a connection refused error, the healthcheck may not have passed yet. Check that Docker Desktop is running and that no other process is using port 5432.
  • If you see port 8080 already in use, make sure you stopped the local Spring Boot app with Ctrl+C before running docker compose up.
  • If you see a build failure during the Maven stage, confirm your pom.xml has no syntax errors by running ./mvnw validate locally.

help me debug my Docker Compose startup

Your microservice now runs exactly the way it would in production. A real PostgreSQL database, a minimal Docker image, and a single command to launch everything. Next up, take on the Secret Mission and add pagination, sorting, and search to handle real-world data volumes.

Secret mission

Add Pagination, Sorting, and Search

Your product API currently dumps every single product in one response. Real catalogs have thousands of items. Can you add pagination, sorting, and keyword search so the endpoint handles production-scale data gracefully?

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 Docker, so there are no ongoing cloud costs.

Resources you used:

  • Docker containers (app and db services running locally).
  • Docker volume (postgres_data) storing your PostgreSQL data on local disk.
  • Docker images built on your machine during docker compose up --build.
  • Project files in your productservice folder.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep testing your API.

  • Your Docker containers use minimal CPU and memory when idle.
  • The PostgreSQL volume persists your product data between restarts.
  • You can access your API anytime at http://localhost:8080 as long as containers are running.

Pause - I'll come back to this later

Shut down running containers to free up memory and CPU without losing any data. Your PostgreSQL volume and project files stay intact.

  • Stop your containers by running:
  • Resume later with:
docker compose stop

When you are ready to continue, start everything back up with:

docker compose start

Your data persists in the postgres_data volume. You will pick up right where you left off.

Delete - I don't want to use this again

Remove all project containers, networks, volumes, and files. This is a full teardown.

  • Stop containers and delete the PostgreSQL data volume by running:
docker compose down -v

What does this command do?

The -v flag removes the named volume postgres_data along with the containers and network. Without it, your database data would persist even after removing the containers.

  • Remove the Docker images built for this project by running:
docker image prune

Confirm the removal when prompted by typing y.

  • Delete the project folder. On macOS or Linux, run:
rm -rf productservice
  • On Windows, run:
rmdir /s /q productservice

After these steps, all traces of the project are removed from your machine.

Nice Work!

Nice Work!

You just built a production-grade microservice from scratch. Your Product Service is secured with JWT authentication, enforces role-based access control, and runs in Docker with PostgreSQL.

Here's what you learned:

  • Built a Spring Boot REST API secured with stateless JWT authentication, implementing token creation, signing, and validation from scratch using JJWT.
  • Configured Spring Security 6's SecurityFilterChain with role-based access control, restricting product modifications to admin users while letting regular users read the catalog.
  • Containerized the full stack with a multi-stage Dockerfile and Docker Compose, orchestrating the Spring Boot app and PostgreSQL with health checks and persistent volumes.
  • Secret Mission: Added pagination, sorting, and search filtering for production-ready data handling.

Ready to quiz yourself?