Multi-Container App with Docker Compose
Orchestrate a Spring Boot API and PostgreSQL with Docker Compose.
Introduction
30 Second Summary
Modern applications rarely run as a single process. Behind the scenes, your favorite apps rely on separate containers for the API, the database, the cache, and more, all working together in a coordinated stack.
In this project, you will build a Task Manager REST API backed by PostgreSQL, orchestrated with Docker Compose. You will write a multi-stage Dockerfile that runs tests before building the image, then wire everything together with health checks and persistent volumes.
What You'll Build
A containerized Spring Boot REST API with full CRUD operations, connected to a PostgreSQL database through Docker Compose with health-check-driven startup ordering and data persistence.
By the end of this project, you'll have:
- A multi-stage Dockerfile that compiles, tests, and packages your app into a lean runtime image.
- A Docker Compose configuration orchestrating your API and database with health checks, named volumes, and service networking.
- A Spring Boot REST API with CRUD endpoints for managing tasks, gated by unit tests that block broken builds.
- Secret Mission: Add a Redis cache container to the stack and cache task lookups for faster responses.
Do I need any prior experience?
This project builds on basic Docker knowledge (building images, running containers). Completing "Dockerize a Spring Boot URL Shortener" or having equivalent experience is recommended.
You should also be comfortable with Spring Boot REST APIs and JPA.
Initialize the Spring Boot Project
Every multi-container application starts with a solid foundation. Your Task Manager needs a Spring Boot project configured with the right dependencies before you can build the API or write the Dockerfile.
In this step, you'll generate a project from Spring Initializr, add a test database dependency, and create the package structure for your application code.
In this step, get ready to:
- Generate a Spring Boot project with the correct dependencies.
- Add the H2 test database dependency for unit testing.
- Create the package structure for your application code.
Generate the project from Spring Initializr
Spring Initializr is a web tool that generates a pre-configured Spring Boot project as a downloadable ZIP file. You'll use it to create a project with the exact dependencies your Task Manager needs.
- Open your browser and navigate to start.spring.io.
- Under Project, select Maven.
- Under Language, keep Java selected.
- Under Spring Boot, select 3.5.15.
- In Project Metadata, set Group to com.example.
- Set Artifact to taskmanager.
- Set Java to 17.
- Click ADD DEPENDENCIES and search for Spring Web. Select it.
- Search for Spring Data JPA and select it.
- Search for PostgreSQL Driver and select it.
- Click Generate to download the ZIP file.
What did Spring Initializr do?
Spring Initializr generated a complete Maven project with a pom.xml file listing your dependencies, a main application class, and the Maven Wrapper (mvnw) so you can build without installing Maven globally.
It also included spring-boot-starter-test automatically, which provides testing libraries you'll use in a later step.
- Extract the downloaded taskmanager.zip file to a location you can easily find, like your Desktop.
- Switch to IntelliJ IDEA.
- Click File in the top menu bar.
- Click Open.
- Navigate to the extracted taskmanager folder and click OK.
- If IntelliJ asks whether to trust the project, click Trust Project.
Wait for IntelliJ to finish indexing
IntelliJ will download dependencies and index the project in the background. Watch the progress bar at the bottom of the window. Wait for it to finish before moving on.
Add the H2 test dependency
Your Task Manager will use PostgreSQL in production, but you need a lightweight in-memory database for running unit tests without spinning up a real database. H2 fills that role perfectly.
- In IntelliJ's Project panel on the left, open pom.xml by double-clicking it.
- Find the postgresql dependency block. It looks like this:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
- Add the following H2 dependency directly below the closing </dependency> tag of the postgresql block:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
What does this dependency do?
The scope tag set to test means H2 is only available during testing. It won't be packaged into your production Docker image.
When your unit tests run, Spring Boot detects H2 on the test classpath and uses it as an in-memory database automatically. No external database server needed.
- Save pom.xml with Ctrl+S.
- If IntelliJ shows a popup asking to reload Maven changes, click the reload icon or press Ctrl+Shift+O to sync.
Now verify that the project compiles with the new dependency.
- Open the terminal panel in IntelliJ by pressing Alt+F12.
- Compile the project by running this command:
mvnw.cmd clean compile
On macOS or Linux?
Use ./mvnw clean compile instead. The Maven Wrapper script has a different name on each platform.
- Wait for the build to finish. You should see BUILD SUCCESS in the terminal output.
Seeing BUILD FAILURE?
Make sure IntelliJ finished downloading dependencies (check the progress bar at the bottom). If the error mentions missing dependencies, press Ctrl+Shift+O to reload Maven, then try the compile command again.
Also check that the H2 dependency XML is inside the <dependencies> block, not outside it.
help me fix my Maven build failure
✔️ Awesome, I've got everything!
Great. Make sure pom.xml is saved before continuing.
ⓧ I'd like to double check the full code
Here is the complete pom.xml as it should look now:
<?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.5.15</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>taskmanager</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>taskmanager</name>
<description>Multi-container Task Manager with Docker Compose</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.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</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>
Create the package structure
A well-organized package structure keeps your code separated by responsibility. You'll create four packages to hold the model, repository, service, and controller layers.
- In the Project panel, expand src > main > java > com.example.taskmanager.
- Right-click on com.example.taskmanager and select New > Package.
- Type model and press Enter.
- Repeat the right-click process to create three more packages: repository, service, and controller.
- Verify the packages exist by expanding com.example.taskmanager in the Project panel. You should see four sub-packages listed: controller, model, repository, and service.
Why these four packages?
This layout follows a layered architecture pattern. model holds your database entities, repository handles data access, service contains business logic, and controller exposes the REST endpoints.
Each layer only talks to the one below it. This separation makes your code easier to test and maintain.
Packages not appearing?
IntelliJ sometimes flattens empty packages into a single line (like com.example.taskmanager.model). Click the gear icon in the Project panel and uncheck Compact Middle Packages to see the full tree.
help me create packages in IntelliJ IDEA
Your project skeleton is ready. Next up, you'll fill those empty packages with a Task entity, repository, service, and controller to build a complete REST API.
Build the REST API
Your project skeleton is in place and compiles cleanly. Now it is time to build the actual application logic that will run inside your Docker container.
Every REST API needs three layers: a data model that maps to the database, a service that contains business logic, and a controller that exposes HTTP endpoints. You will build all three in this step, creating a full CRUD API for managing tasks.
In this step, get ready to:
- Create the Task entity and repository for database persistence.
- Build the service layer with CRUD methods.
- Wire up REST endpoints with a controller and configure the database connection.
Create the Task entity and repository
The entity is the foundation of your API. It defines the shape of a task in your database and what fields each task holds.
- In IntelliJ, navigate to src/main/java/com/example/taskmanager/model/ in the Project tool window on the left.
- Right-click the model package, select New then Java Class, and name it Task.
- Replace the generated content with the following code:
package com.example.taskmanager.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String description;
private boolean completed = false;
@Column(name = "created_at")
private LocalDateTime createdAt;
// Automatically set the creation timestamp before saving
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
What does this code do?
The @Entity and @Table annotations tell JPA that this class maps to a database table called tasks.
- @Id and @GeneratedValue mark id as the auto-incrementing primary key.
- @Column(nullable = false) makes title a required field at the database level.
- @PrePersist automatically stamps createdAt with the current time when a task is first saved.
- Save Task.java with Ctrl+S.
You will not see any visible output yet because the entity needs a repository to interact with the database.
✔️ Awesome, I've got everything!
Great. Double check you have saved Task.java and move on to the repository.
ⓧ I'd like to double check the full code
package com.example.taskmanager.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String description;
private boolean completed = false;
@Column(name = "created_at")
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Now create the repository interface that gives you database operations for free.
- Right-click the repository package, select New then Java Class, choose Interface, and name it TaskRepository.
- Replace the generated content with the following code:
package com.example.taskmanager.repository;
import com.example.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
// Spring Data JPA generates all CRUD methods automatically
public interface TaskRepository extends JpaRepository<Task, Long> {
}
Why is this interface empty?
By extending JpaRepository<Task, Long>, Spring Data JPA automatically provides implementations for common operations like findAll(), findById(), save(), and delete().
You get a full data access layer without writing a single SQL query.
- Save TaskRepository.java.
- Verify the project still compiles by clicking Build then Build Project in the top menu (or press Ctrl+F9).
You should see a green Build completed successfully message in the status bar at the bottom.
Seeing compilation errors?
- Make sure the Task import in TaskRepository.java points to com.example.taskmanager.model.Task, not a different Task class.
- Confirm your Task.java file is in the model package. If it is in the wrong folder, IntelliJ will show a package mismatch error.
Help me fix compilation errors in my Task entity or repository.
Create the TaskService class
The service layer sits between your controller and repository. It holds the business logic for creating, reading, updating, and deleting tasks.
- Right-click the service package, select New then Java Class, and name it TaskService.
- Replace the generated content with the following code:
package com.example.taskmanager.service;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository taskRepository;
// Constructor injection for the repository dependency
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
// Return all tasks from the database
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
// Find a single task by ID, throw if not found
public Task getTaskById(Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Task not found with id: " + id));
}
// Save a new task to the database
public Task createTask(Task task) {
return taskRepository.save(task);
}
// Update an existing task's fields
public Task updateTask(Long id, Task taskDetails) {
Task task = getTaskById(id);
task.setTitle(taskDetails.getTitle());
task.setDescription(taskDetails.getDescription());
task.setCompleted(taskDetails.isCompleted());
return taskRepository.save(task);
}
// Delete a task by ID
public void deleteTask(Long id) {
Task task = getTaskById(id);
taskRepository.delete(task);
}
}
What does this code do?
- @Service registers this class as a Spring bean so it can be injected into other classes.
- The constructor accepts a TaskRepository and Spring automatically injects it at startup.
- getTaskById uses orElseThrow to throw a RuntimeException if the requested task does not exist.
- updateTask fetches the existing task first, applies the new field values, then saves it back.
- Save TaskService.java.
- Build the project with Ctrl+F9 to confirm it compiles.
You should see the Build completed successfully message again.
Build failed after adding TaskService?
- Check that your import statements match exactly. IntelliJ may auto-import the wrong List class. You need java.util.List.
- Make sure the file is saved in the service package, not the model or controller package.
Help me debug why my TaskService class won't compile.
✔️ Awesome, I've got everything!
Great. Double check you have saved TaskService.java and continue to the controller.
ⓧ I'd like to double check the full code
package com.example.taskmanager.service;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
public Task getTaskById(Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Task not found with id: " + id));
}
public Task createTask(Task task) {
return taskRepository.save(task);
}
public Task updateTask(Long id, Task taskDetails) {
Task task = getTaskById(id);
task.setTitle(taskDetails.getTitle());
task.setDescription(taskDetails.getDescription());
task.setCompleted(taskDetails.isCompleted());
return taskRepository.save(task);
}
public void deleteTask(Long id) {
Task task = getTaskById(id);
taskRepository.delete(task);
}
}
Create the TaskController and configure the database
The controller is the public face of your API. It maps incoming HTTP requests to your service methods and returns the results as JSON.
- Right-click the controller package, select New then Java Class, and name it TaskController.
- Replace the generated content with the following code:
package com.example.taskmanager.controller;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.service.TaskService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
// Inject the service via constructor
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
// GET /api/tasks - return all tasks
@GetMapping
public List<Task> getAllTasks() {
return taskService.getAllTasks();
}
// GET /api/tasks/{id} - return a single task
@GetMapping("/{id}")
public Task getTaskById(@PathVariable Long id) {
return taskService.getTaskById(id);
}
// POST /api/tasks - create a new task
@PostMapping
public ResponseEntity<Task> createTask(@RequestBody Task task) {
Task created = taskService.createTask(task);
return new ResponseEntity<>(created, HttpStatus.CREATED);
}
// PUT /api/tasks/{id} - update an existing task
@PutMapping("/{id}")
public Task updateTask(@PathVariable Long id, @RequestBody Task task) {
return taskService.updateTask(id, task);
}
// DELETE /api/tasks/{id} - remove a task
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
taskService.deleteTask(id);
return ResponseEntity.noContent().build();
}
}
What does this code do?
- @RestController tells Spring this class handles HTTP requests and returns JSON responses automatically.
- @RequestMapping("/api/tasks") sets the base URL path for all endpoints in this controller.
- @PostMapping returns a ResponseEntity with HTTP status 201 Created instead of the default 200 OK.
- @DeleteMapping returns 204 No Content to signal that the deletion succeeded with no body to return.
- Save TaskController.java.
Red underlines in the controller?
- If IntelliJ shows TaskService in red, make sure you saved TaskService.java in the previous substep.
- If ResponseEntity is unresolved, check that org.springframework.http.ResponseEntity is imported at the top of the file.
Help me fix unresolved references in my TaskController.
✔️ Awesome, I've got everything!
Great. Double check you have saved TaskController.java and continue to configure the database connection.
ⓧ I'd like to double check the full code
package com.example.taskmanager.controller;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.service.TaskService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public List<Task> getAllTasks() {
return taskService.getAllTasks();
}
@GetMapping("/{id}")
public Task getTaskById(@PathVariable Long id) {
return taskService.getTaskById(id);
}
@PostMapping
public ResponseEntity<Task> createTask(@RequestBody Task task) {
Task created = taskService.createTask(task);
return new ResponseEntity<>(created, HttpStatus.CREATED);
}
@PutMapping("/{id}")
public Task updateTask(@PathVariable Long id, @RequestBody Task task) {
return taskService.updateTask(id, task);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
taskService.deleteTask(id);
return ResponseEntity.noContent().build();
}
}
The controller is ready, but the app still needs to know how to connect to a database. You will configure it to use environment variables for the connection (which Docker will supply later) with H2 defaults for local development.
- In the Project tool window, navigate to src/main/resources/ and open application.properties.
- Replace any existing content with the following configuration:
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:h2:mem:testdb}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:sa}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:}
spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER:org.h2.Driver}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
What does this configuration do?
Each ${ENV_VAR:default} expression reads from an environment variable first. If the variable is not set, it falls back to the value after the colon.
- Locally (without Docker), the app uses H2, an in-memory database that needs no installation.
- Inside Docker, environment variables will point the app to PostgreSQL instead.
- ddl-auto=update tells Hibernate to auto-create or update database tables based on your entities.
- show-sql=true prints SQL queries to the console for debugging.
- Save application.properties.
- Build the full project one more time with Ctrl+F9 to confirm everything compiles cleanly.
You should see Build completed successfully with no errors.
How can I see my endpoints are mapped?
In IntelliJ, open the Endpoints tool window from the top menu: View then Tool Windows then Endpoints.
You should see your five endpoints listed: GET /api/tasks, GET /api/tasks/{id}, POST /api/tasks, PUT /api/tasks/{id}, and DELETE /api/tasks/{id}.
Endpoints tool window is empty?
- Make sure you saved all files and built the project successfully.
- The Endpoints tool window requires IntelliJ IDEA Ultimate. If you are using the Community edition, you can verify endpoints by running the app and checking the startup logs for mapped request lines.
Help me verify that my REST endpoints are mapped correctly.
Your REST API is fully wired. Next up, you will write unit tests that prove your service logic works correctly and act as a build gate inside Docker.
Write Unit Tests
Your REST API compiles and has all the endpoints wired up. Before you containerize this application, you need a safety net that catches bugs automatically.
In this step, you will write unit tests for the service layer using Mockito. These tests will become a build gate in the next step. If any test fails, the Docker image cannot be created.
In this step, get ready to:
- Write unit tests for the TaskService using Mockito mocks.
- Run the tests and verify they pass.
- Prove that a failing test stops the build.
Create the TaskServiceTest class
Unit tests verify individual pieces of your application in isolation. By mocking the database layer, you can test your service logic without needing a real PostgreSQL connection.
- In the IntelliJ Project panel on the left, right-click the src/test/java/com/example/taskmanager folder.
- Select New, then Package.
- Enter service and press Enter.
- Right-click the new service package, select New, then Java Class.
- Name it TaskServiceTest and press Enter.
- Replace the entire file contents with the following test class:
package com.example.taskmanager.service;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.repository.TaskRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
// Mockito creates a fake TaskRepository so tests don't need a real database
@Mock
private TaskRepository taskRepository;
// Injects the mock repository into a real TaskService instance
@InjectMocks
private TaskService taskService;
@Test
void getAllTasks_returnsListOfTasks() {
Task task1 = new Task();
task1.setTitle("Task 1");
Task task2 = new Task();
task2.setTitle("Task 2");
when(taskRepository.findAll()).thenReturn(Arrays.asList(task1, task2));
List<Task> result = taskService.getAllTasks();
assertEquals(2, result.size());
verify(taskRepository, times(1)).findAll();
}
@Test
void getTaskById_returnsTask_whenFound() {
Task task = new Task();
task.setId(1L);
task.setTitle("Test Task");
when(taskRepository.findById(1L)).thenReturn(Optional.of(task));
Task result = taskService.getTaskById(1L);
assertEquals("Test Task", result.getTitle());
verify(taskRepository, times(1)).findById(1L);
}
@Test
void getTaskById_throwsException_whenNotFound() {
when(taskRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(RuntimeException.class, () -> taskService.getTaskById(1L));
}
@Test
void createTask_savesAndReturnsTask() {
Task task = new Task();
task.setTitle("New Task");
when(taskRepository.save(task)).thenReturn(task);
Task result = taskService.createTask(task);
assertEquals("New Task", result.getTitle());
verify(taskRepository, times(1)).save(task);
}
}
What does this test class do?
@ExtendWith(MockitoExtension.class) tells JUnit to use Mockito for creating fake objects. @Mock creates a fake TaskRepository that you can program with specific return values. @InjectMocks builds a real TaskService but injects the fake repository into it.
Each test follows the same pattern: set up fake data with when(...).thenReturn(...), call the service method, then assert the result matches what you expect. The verify() calls confirm the repository was actually invoked.
Your tests also need a dedicated configuration file so they use the H2 in-memory database instead of PostgreSQL.
- In the Project panel, right-click the src/test/resources folder.
- Select New, then File.
- Name it application-test.properties and press Enter.
- Paste the following configuration into the new file:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
Why a separate test properties file?
Your main application.properties uses environment variable placeholders that resolve to PostgreSQL in production. This test properties file hardcodes H2 values so tests can run instantly without a database server. The create-drop setting rebuilds the schema fresh for every test run.
- Save both files with Ctrl+S.
Seeing red underlines in TaskServiceTest?
If IntelliJ shows unresolved imports, make sure the file is inside src/test/java/com/example/taskmanager/service/ (not src/main/java). The Mockito and JUnit imports come from spring-boot-starter-test which is scoped to test in your pom.xml.
Try right-clicking the project root and selecting Maven, then Reload Project to refresh dependencies.
Help me fix red underlines in my test file.
Run the tests and prove the failure gate
Running the tests locally lets you confirm they pass. You will also intentionally break a test to see what happens when Maven encounters a failure. This is the exact mechanism that will block bad code from becoming a Docker image in the next step.
- Open the IntelliJ terminal by clicking Terminal at the bottom of the window.
- Run the tests by executing this command:
mvnw.cmd test
What should I see?
Maven downloads any missing test dependencies, compiles the test class, and runs all four test methods. You should see output ending with something like Tests run: 4, Failures: 0, Errors: 0 followed by BUILD SUCCESS.
Now you will intentionally break a test to confirm that failures stop the build.
- In TaskServiceTest.java, find the getAllTasks_returnsListOfTasks test method.
- Find this line:
assertEquals(2, result.size());
- Change 2 to 99 so the assertion intentionally fails:
assertEquals(99, result.size());
- Save the file and re-run the tests:
mvnw.cmd test
Maven should now report a test failure and exit with BUILD FAILURE. This is exactly the behaviour that will block a broken Docker image from being created.
- Change 99 back to 2 to fix the test:
assertEquals(2, result.size());
- Save the file and run the tests one more time to confirm everything is green again:
mvnw.cmd test
- Confirm you see BUILD SUCCESS with all 4 tests passing.
Tests still failing after fixing the value?
Make sure you saved TaskServiceTest.java after changing the value back to 2. IntelliJ may still be running the old version if the file is unsaved.
If you see compilation errors instead of test failures, double check that the package declaration at the top of your test file matches com.example.taskmanager.service and that the file lives in the correct folder path.
Help me debug my failing tests.
✔️ Awesome, I've got everything!
Great. Double check you have saved both TaskServiceTest.java and application-test.properties.
ⓧ I'd like to double check the full code
Here is the complete TaskServiceTest.java:
package com.example.taskmanager.service;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.repository.TaskRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
@Mock
private TaskRepository taskRepository;
@InjectMocks
private TaskService taskService;
@Test
void getAllTasks_returnsListOfTasks() {
Task task1 = new Task();
task1.setTitle("Task 1");
Task task2 = new Task();
task2.setTitle("Task 2");
when(taskRepository.findAll()).thenReturn(Arrays.asList(task1, task2));
List<Task> result = taskService.getAllTasks();
assertEquals(2, result.size());
verify(taskRepository, times(1)).findAll();
}
@Test
void getTaskById_returnsTask_whenFound() {
Task task = new Task();
task.setId(1L);
task.setTitle("Test Task");
when(taskRepository.findById(1L)).thenReturn(Optional.of(task));
Task result = taskService.getTaskById(1L);
assertEquals("Test Task", result.getTitle());
verify(taskRepository, times(1)).findById(1L);
}
@Test
void getTaskById_throwsException_whenNotFound() {
when(taskRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(RuntimeException.class, () -> taskService.getTaskById(1L));
}
@Test
void createTask_savesAndReturnsTask() {
Task task = new Task();
task.setTitle("New Task");
when(taskRepository.save(task)).thenReturn(task);
Task result = taskService.createTask(task);
assertEquals("New Task", result.getTitle());
verify(taskRepository, times(1)).save(task);
}
}
And here is the complete application-test.properties:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
Your tests are passing and you have proven that a failure stops the build dead in its tracks. Next up, you will write a multi-stage Dockerfile that runs these exact tests during image creation. If anything breaks, no image gets produced.
Write the Multi-Stage Dockerfile
Your REST API compiles, runs tests, and works locally. Now you need to package it into a container image that any machine can run without installing Java, Maven, or configuring dependencies.
A naive Dockerfile would bundle the entire JDK and Maven toolchain into the final image, resulting in a bloated 600MB+ container. A multi-stage build solves this by using one stage for compiling and testing, then copying only the finished JAR into a minimal runtime image.
In this step, get ready to:
- Write a multi-stage Dockerfile with a build stage and a runtime stage.
- Create a .dockerignore file to keep the build context clean.
- Build the image and verify that failing tests block image creation.
Create the Dockerfile build stage
The first stage of a multi-stage Dockerfile handles compilation and testing. It uses a full Maven + JDK image to resolve dependencies and package your application. Once the JAR is built, this stage is discarded.
- In IntelliJ, right-click your project root (the taskmanager folder) and select New then File.
- Name the file Dockerfile (no extension) and press Enter.
- Add the build stage by pasting this code into the new file:
# Stage 1: Build and test
FROM maven:3.9.16-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package
What does this code do?
This stage compiles your application and runs all unit tests inside a temporary build container.
- FROM maven:3.9.16-eclipse-temurin-17 AS builder starts from an image with Maven and JDK 17 pre-installed. The AS builder label lets Stage 2 reference files from this stage.
- COPY pom.xml followed by mvn dependency:go-offline downloads all dependencies first. Docker caches this layer, so rebuilds are fast unless your dependencies change.
- COPY src ./src copies your source code into the container after dependencies are cached.
- RUN mvn package compiles the code, runs all unit tests, and produces the JAR. Because mvn package includes the test phase by default, any failing test causes Maven to exit with a non-zero code. Docker treats that as a build failure and stops.
Why not skip tests in the build?
Many tutorials add -DskipTests to speed up Docker builds. By intentionally leaving that flag out, you create a test gate. If any test fails, the image is never built. This is the simplest form of CI gating.
- Save Dockerfile.
Add the runtime stage
The second stage produces the final image that actually runs in production. It starts from a JRE-only base image, meaning no compiler, no Maven, and no source code. This cuts the image size roughly in half.
- In Dockerfile, add an empty line after RUN mvn package, then paste the runtime stage below it:
# Stage 2: Runtime
FROM eclipse-temurin:17-jre-noble
RUN useradd --create-home --shell /bin/bash appuser
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
USER appuser
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this code do?
- FROM eclipse-temurin:17-jre-noble starts a fresh image with only the Java Runtime Environment. No compiler, no build tools.
- RUN useradd --create-home --shell /bin/bash appuser creates a non-root user for security. Running containers as root is a known security risk.
- COPY --from=builder /build/target/*.jar app.jar pulls the compiled JAR from Stage 1 into this clean image. Everything else from the build stage is discarded.
- USER appuser switches to the non-root user before the app starts.
- ENTRYPOINT ["java", "-jar", "app.jar"] defines the command that runs when the container starts.
- Save Dockerfile.
Seeing a red squiggly line in IntelliJ?
IntelliJ may not recognize Dockerfile syntax by default. The file will still work with Docker. If you want syntax highlighting, install the Docker plugin from Settings then Plugins.
help me fix Dockerfile syntax highlighting in IntelliJ
Now create a .dockerignore file to prevent unnecessary files from being sent to the Docker build context.
- Right-click the project root and select New then File.
- Name the file .dockerignore and press Enter.
- Paste the following exclusions:
target/
.idea/
*.iml
.git/
.env
What does this file do?
The .dockerignore file works like .gitignore but for Docker. It prevents large or sensitive files from being sent to the Docker daemon during the build. The target/ folder alone can be hundreds of megabytes.
- Save .dockerignore.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
# Stage 1: Build and test
FROM maven:3.9.16-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package
# Stage 2: Runtime
FROM eclipse-temurin:17-jre-noble
RUN useradd --create-home --shell /bin/bash appuser
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
USER appuser
ENTRYPOINT ["java", "-jar", "app.jar"]
And the complete .dockerignore:
target/
.idea/
*.iml
.git/
.env
Build and verify the Docker image
With the Dockerfile ready, you can now build an image and confirm that the test gate works. The first build will take a few minutes because Docker downloads the Maven and JRE base images.
- Open a terminal in IntelliJ by pressing Alt+F12 (Windows) or Option+F12 (macOS).
- Make sure you are in the project root (the folder containing your Dockerfile).
- Build the Docker image by running this command:
docker build -t taskmanager:latest .
What does this command do?
- docker build tells Docker to read the Dockerfile and create an image.
- -t taskmanager:latest tags the image with the name taskmanager and the tag latest.
- . tells Docker to use the current directory as the build context.
The build will take a few minutes on the first run. You will see Maven downloading dependencies and running your unit tests inside the container.
- Once the build completes, check the image size by running:
docker images taskmanager
- You should see your taskmanager image listed at around 300MB. A single-stage build with the full JDK would be 600MB or more.
Build failed or Docker not found?
- Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.
- Confirm your terminal is in the project root directory (the folder that contains the Dockerfile).
- If the build fails during the test phase, check the error output. It means one of your unit tests is failing. Run mvnw.cmd test locally to identify the problem.
help me debug my Docker build failure
Now verify that the test gate actually works. If a test fails, the image should not be built.
- Open src/test/java/com/example/taskmanager/service/TaskServiceTest.java in IntelliJ.
- Find this line in the getAllTasks_returnsListOfTasks test:
assertEquals(2, result.size());
- Change 2 to 99 so the test will fail:
assertEquals(99, result.size());
- Save the file and rebuild the Docker image by running:
docker build -t taskmanager:latest .
The build will fail during the test phase. You should see Maven report a test failure and Docker will stop the build with a non-zero exit code.
- Change 99 back to 2 in TaskServiceTest.java to restore the correct assertion:
assertEquals(2, result.size());
- Save the file and rebuild one more time to confirm the image builds successfully:
docker build -t taskmanager:latest .
- Run docker images taskmanager again to confirm the image is listed at around 300MB.
What just happened?
You proved that the multi-stage Dockerfile acts as a quality gate. A broken test stops the image from being created, which means a buggy build can never reach production. This is the same pattern CI/CD pipelines use.
Your application is now packaged into a lean, test-verified container image. Next up, you will orchestrate this container alongside a PostgreSQL database using Docker Compose.
Create Docker Compose with PostgreSQL
Your Docker image is built and verified at around 300MB. Right now you can run the app as a single container, but it needs a real PostgreSQL database to store tasks in production.
Docker Compose lets you define multiple containers in a single file and start them together with one command. In this step, you will create a compose.yaml that orchestrates your app container alongside a PostgreSQL container, complete with health checks and persistent storage.
In this step, get ready to:
- Create a compose.yaml defining the app and database services.
- Create a .env file to externalize database credentials.
- Verify the multi-container stack runs and persists data across restarts.
Create the compose.yaml file
A compose.yaml file declares every service your application needs as a set of containers that Docker can start, stop, and network together.
- In IntelliJ, right-click the project root (the taskmanager folder that contains your Dockerfile).
- Select New, then File.
- Name the file compose.yaml and press Enter.
- Paste the following into compose.yaml:
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
SPRING_DATASOURCE_DRIVER: org.postgresql.Driver
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres_data:
What does this file do?
This file defines two services that Docker will run as linked containers:
- app builds your image from the local Dockerfile, exposes port 8080, and injects Spring datasource environment variables pointing at the database container.
- db runs the official postgres:16-alpine image. Its healthcheck uses pg_isready so Docker knows when PostgreSQL is actually accepting connections.
- depends_on with condition: service_healthy means the app container will not start until the database health check passes. No more race conditions.
- The postgres_data named volume stores database files outside the container, so your data survives container restarts and recreation.
Why does the app use db as the hostname?
Docker Compose creates a private network for your services. Each service name becomes a DNS hostname on that network. When the app connects to jdbc:postgresql://db:5432/..., Docker resolves db to the database container's IP address automatically.
- Save compose.yaml.
Seeing YAML syntax errors?
YAML is sensitive to indentation. Make sure you are using spaces (not tabs) and that each nested level is indented by exactly two spaces. IntelliJ highlights YAML errors with a red underline.
help me fix YAML indentation errors in my compose.yaml
Create the .env file for database credentials
The ${...} placeholders in your compose.yaml pull values from an environment file. This keeps credentials out of version control.
- Right-click the project root again and select New then File.
- Name the file .env and press Enter.
- Paste the following into .env:
POSTGRES_DB=taskmanager
POSTGRES_USER=taskuser
POSTGRES_PASSWORD=taskpass123
What does this file do?
Docker Compose automatically reads .env from the same directory as compose.yaml. Every ${POSTGRES_DB}, ${POSTGRES_USER}, and ${POSTGRES_PASSWORD} reference in the Compose file resolves to the values defined here.
Your .dockerignore already excludes .env from the Docker build context, so these credentials never end up baked into your image.
- Save .env.
IntelliJ asking about a plugin for .env files?
IntelliJ may prompt you to install the EnvFile or DotEnv plugin. You can dismiss this. The file works as plain text and Docker Compose reads it regardless of IDE plugins.
help me with .env file issues in my Docker Compose project
Run and verify the multi-container stack
With both files in place, you can bring up the entire stack. Docker Compose will build your app image, pull the PostgreSQL image, create the network, and start both containers in the correct order.
- Open a terminal in IntelliJ (click Terminal at the bottom of the window).
- Start the stack by running this command:
docker compose up --build
What does this command do?
- docker compose up starts all services defined in compose.yaml.
- The --build flag forces a fresh image build for the app service so your latest code is included.
Watch the terminal output. You should see the database container start first, pass its health check, and then the app container start. The app logs will show Spring Boot connecting to PostgreSQL.
App container keeps restarting or failing to connect?
If the app starts before the database is ready, it will fail to connect and restart. Check that your depends_on section uses condition: service_healthy (not just depends_on: [db]). Also confirm your .env file is in the same directory as compose.yaml.
help me debug my Docker Compose app that cannot connect to PostgreSQL
- Open a second terminal tab in IntelliJ (click the + icon in the Terminal panel).
- Test the API by running:
curl http://localhost:8080/api/tasks
You should see [] returned, confirming the app is running and connected to an empty database.
- Create a test task by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"My first task\", \"description\": \"Testing persistence\"}"
You should see a JSON response with the created task, including an auto-generated id and createdAt timestamp.
- Now test data persistence. Stop the stack by running:
docker compose down
- Start it again by running:
docker compose up
- In the second terminal tab, query the tasks again:
curl http://localhost:8080/api/tasks
Your task is still there. The postgres_data named volume preserved the database files across the full container teardown and recreation.
Why did the data survive docker compose down?
Named volumes are managed by Docker separately from containers. Running docker compose down removes containers and networks but leaves named volumes intact. Only docker compose down -v would delete the volumes and wipe your data.
Getting an empty array after restart?
If your task disappeared, check that you have the volumes: section at the bottom of compose.yaml declaring postgres_data: as a top-level named volume. Without this declaration, Docker creates an anonymous volume that may not persist correctly.
help me debug why my PostgreSQL data is not persisting with Docker Compose named volumes
✔️ Awesome, I've got everything!
Great. Make sure you have saved both compose.yaml and .env.
ⓧ I'd like to double check the full code
Here is the complete compose.yaml:
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
SPRING_DATASOURCE_DRIVER: org.postgresql.Driver
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres_data:
And the complete .env:
POSTGRES_DB=taskmanager
POSTGRES_USER=taskuser
POSTGRES_PASSWORD=taskpass123
Your multi-container stack is running with health-checked startup ordering and persistent storage. Next up, you will tackle the Secret Mission and add a Redis cache to the stack.
Secret mission
Add a Redis Cache Container
Your Task Manager works, but every request hits PostgreSQL directly. In production, frequently accessed data should be cached to reduce database load and speed up responses. Can you add a Redis container to the stack and wire up Spring Boot caching so that repeated task lookups are served from memory instead of disk?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker images (taskmanager:latest, postgres:16-alpine, redis:7-alpine).
- Docker containers (app, db, redis).
- Named volumes storing PostgreSQL data (postgres_data) and Redis data (redis_data).
- The taskmanager project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your Task Manager stack.
- Your containers will keep running until you stop them or restart Docker Desktop.
- Data in postgres_data and redis_data persists across container restarts.
- The containers use local CPU and memory while running, but incur no financial cost.
Pause - I'll come back to this later
Shut down running containers to free up memory, but keep your volumes and project files intact so you can pick up where you left off.
- Stop all containers by running this command in the taskmanager project folder:
docker compose stop
- Your volumes (postgres_data and redis_data) remain intact, so all your tasks and cached data are preserved.
- When you are ready to resume, run docker compose up from the same folder to bring everything back up.
Delete - I don't want to use this again
Remove all project containers, networks, volumes, and local files. This is irreversible and deletes all stored task data.
- Stop containers and delete all named volumes by running this command in the taskmanager project folder:
docker compose down -v
- Remove the Docker images you built and pulled by running:
docker rmi taskmanager:latest
docker rmi postgres:16-alpine
docker rmi redis:7-alpine
- Delete the project folder from your Desktop. On macOS or Linux:
cd ~/Desktop
rm -rf taskmanager
- On Windows, run this in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force taskmanager
Nice Work!
Nice Work!
You just built and deployed a production-grade multi-container application using Docker Compose. Your Task Manager API, PostgreSQL database, and container orchestration layer are all running together as a coordinated stack.
You've learned how to:
- Write unit tests that gate the Docker build so that failing tests block image creation entirely.
- Create a multi-stage Dockerfile that produces a lean ~300MB JRE-only runtime image instead of a ~600MB single-stage image.
- Orchestrate a multi-container stack with Docker Compose using health checks, named volumes, and service-name DNS networking for startup ordering and data persistence.
- Secret Mission: Extended the stack with a Redis cache container and integrated Spring Boot caching annotations to speed up task lookups.
Ready to quiz yourself?