Containerized Task Manager API

Build a Spring Boot REST API and orchestrate it with PostgreSQL via Docker Compose.

Introduction

30 Second Summary

Every app on your phone relies on a backend API to store and retrieve data. Packaging that API so it runs identically anywhere is what separates a project from a product.

In this project, you will build a Task Manager REST API with Spring Boot, containerize it with Docker, and orchestrate it alongside a PostgreSQL database using Docker Compose.

What You'll Build

Imagine running one command and watching your API and its database spin up together, ready for you to create tasks, update them, restart the entire stack, and see all your data still there.

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

  • Five working endpoints where you can create, read, update, and delete tasks, with real data flowing through your API into a persistent PostgreSQL database.
  • A portable Docker image built from source using a multi-stage Dockerfile, so anyone with Docker installed can run your API without setting up Java.
  • A two-container stack where a single docker compose up command starts the API and PostgreSQL together, and your data survives restarts thanks to persistent volumes.
  • Secret Mission: Write professional project documentation with a README that includes API examples and a "Why Compose?" section.

Are there any prerequisites?

This project assumes basic Java knowledge (object-oriented programming and data structures). You should also have Docker Desktop, Git, and VS Code installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Development Environment

Before writing any API code, you need a working Java toolchain and a project skeleton. Every step after this one builds on the foundation you set up here.

You will scaffold a Spring Boot project using Spring Initializr, a web tool that generates pre-configured project skeletons. Once it boots successfully, you will push it to GitHub to start tracking changes.

In this step, get ready to:
  • Verify your Java and Docker installations.
  • Scaffold a Spring Boot project with Spring Initializr.
  • Boot the project and push it to GitHub.
Verify your Java and Docker installations

Spring Boot 4.1.0 requires Java SDK version 17 or higher. Let's confirm your Java version before going further.

  • Check your installed Java version by running this command:
java -version

Look at the first line of the output. It should show a version number of 17 or higher.

✔️ I see version 17 or higher

Your Java installation is compatible with Spring Boot 4.1.0. You're ready to move on.

ⓧ I see an older version

You need Java 17 or higher for this project. The Spring team recommends BellSoft Liberica JDK version 17 or 21.

  • Visit the BellSoft Liberica JDK download page.
  • Select JDK 17 LTS (or JDK 21 LTS), choose your operating system, and download the installer.
  • Run the installer and follow the prompts.
  • Close and reopen your terminal, then run java -version again to confirm it now shows 17 or higher.

ⓧ Command not found

Java is not installed on your machine. The Spring team recommends BellSoft Liberica JDK version 17 or 21.

  • Visit the BellSoft Liberica JDK download page.
  • Select JDK 17 LTS (or JDK 21 LTS), choose your operating system, and download the installer.
  • Run the installer and follow the prompts.
  • Close and reopen your terminal, then run java -version to confirm it shows 17 or higher.

macOS prompting you to install?

On macOS, running java -version without Java installed may open a dialog suggesting you install Apple's JDK. Dismiss it and use the BellSoft download link above instead to get the correct version.

Next, confirm Docker Desktop is running. You will need it in a later step when you containerize your API.

  • Verify Docker is available by running this command:
docker --version

You should see a version number in the output, confirming Docker is installed and accessible.

Don't see a version number?

Docker Desktop needs to be open for the docker command to work. Press Cmd+Space (macOS) or the Windows key (Windows), type Docker Desktop, and press Enter to launch it.

Once Docker Desktop finishes starting, run docker --version again.

Docker Desktop still not responding after launch?

Scaffold your project with Spring Initializr

Spring Initializr is a web tool that generates a Spring Boot project with your chosen dependencies already wired up. It creates the folder structure, build configuration, and Maven wrapper so you can start coding immediately.

  • Open your browser and navigate to start.spring.io.
  • Select Maven as the project type on the left.
  • Select 4.1.0 from the Spring Boot version dropdown.

The Language should already be set to Java and Packaging to Jar. Leave those as they are.

  • In the Project Metadata section, set Group to com.example.
  • Set Artifact to taskmanager.
  • Set Description to Task Manager REST API.
  • Select 17 from the Java version dropdown.
  • Click Add Dependencies.
  • Search for Spring Web and select it.
  • Search for Spring Data JPA and select it.
  • Search for H2 Database and select it.

What do these dependencies do?

Spring Web provides the web framework you need to create REST API endpoints. Spring Data JPA handles database access using the JPA specification, so you write Java objects instead of raw SQL.

H2 is a lightweight in-memory database. It lets you develop and test locally without installing a separate database server.

  • Click Generate to download the ZIP file.
  • Extract the downloaded ZIP file to your Desktop. This creates a taskmanager folder.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the taskmanager folder, and click Open.

In VS Code's Explorer sidebar on the left, you should see the project structure: a src folder, pom.xml, mvnw and mvnw.cmd wrapper scripts, and a .gitignore file.

  • Open pom.xml by clicking it in the Explorer sidebar.
  • Confirm the Spring Boot parent version is 4.1.0 and that you see dependencies for spring-boot-starter-web, spring-boot-starter-data-jpa, and h2.

Missing a dependency?

If pom.xml is missing one of the three dependencies, go back to start.spring.io, add the missing dependency, and regenerate the project. Extract the new ZIP to replace your taskmanager folder.

My pom.xml doesn't match what's expected for this project.

Boot the skeleton and push to GitHub

The scaffolded project has everything it needs to start. Let's boot it to confirm the build tool, dependencies, and application class all work together.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick) on both macOS and Windows.

macOS

  • Make the Maven wrapper executable and start the app by running these commands:
chmod +x mvnw
./mvnw spring-boot:run

Linux users

These same commands work on Linux. Follow this tab for the rest of the project.

Windows

  • Start the app by running this command:
.\mvnw.cmd spring-boot:run

First build takes a while

The first run downloads all Maven dependencies, so it may take a minute or two. Subsequent runs will be much faster because the dependencies are cached.

Before looking at the output, what do you expect to see when a brand new Spring Boot app starts with no endpoints defined?

What should I see?

The terminal shows the Spring Boot banner followed by startup logs. Look for two things:

  • The Spring Boot version (v4.1.0) near the top of the output.
  • The line Started TaskmanagerApplication near the bottom, confirming the app is running.
  • Open your browser and navigate to http://localhost:8080.

You should see a page titled "Whitelabel Error Page". This is expected.

What is the Whitelabel Error Page?

Spring Boot includes a default error page that appears when no controller handles the requested path. Seeing it proves the web server is running. You will replace it with real endpoints in the next step.

  • Return to your terminal in VS Code and press Ctrl+C to stop the running app.

Now let's initialize Git and push this project to GitHub so you have version control from the start.

  • Initialize a new Git repository by running this command:
git init

You should see a message confirming the repository was initialized.

What about .gitignore?

Spring Initializr already generated a .gitignore file that excludes build artifacts, IDE files, and other files that should not be committed. You can verify it exists in the Explorer sidebar.

  • Create a new repository on GitHub by opening github.com in your browser.
  • Click the + icon in the top right corner.
  • Click New repository.
  • Enter taskmanager as the Repository name.
  • Do not check any of the initialization options (README, .gitignore, or license).
  • Click Create repository.

GitHub shows a setup page with the HTTPS URL for your new repository. Copy it.

  • Stage all files and create your first commit by running these commands:
git add .
git commit -m "Initial Spring Boot project skeleton"

You should see a summary listing the files that were committed.

  • Connect your local repository to GitHub and push by running these commands:
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/taskmanager.git"]]
git push -u origin main

You should see output confirming the branch was pushed to GitHub.

Git push failed?

Make sure you copied the correct HTTPS URL from your GitHub repository page. The URL should end with .git.

If you see an authentication error, GitHub requires a personal access token for HTTPS pushes. Check your GitHub authentication settings.

I'm getting an error when trying to push to GitHub.

Your development environment is ready. Next up, you will build your first endpoint and watch your API respond to a real HTTP request.

Build Your First Endpoint and Task Model

Your Spring Boot project is scaffolded, boots cleanly, and lives in a GitHub repository. Now it is time to make it actually do something.

Right now, the app returns a Whitelabel Error Page because it has no endpoints to handle requests. You need a REST endpoint to prove the web layer works. You also need a data model so the API has something to manage.

In this step, get ready to:
  • Create a hello endpoint to verify the web layer works.
  • Define a Task entity and database repository.
  • Configure the H2 database and verify the table exists.
Create the hello endpoint

A REST controller is a Java class that handles incoming HTTP requests and returns data. You will create one that responds to a simple GET request with a text message.

  • In VS Code, create a new file called HelloController.java inside src/main/java/com/example/taskmanager/.
  • Paste the following code into the file:
package com.example.taskmanager;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

// Marks this class as a REST controller that handles HTTP requests
@RestController
public class HelloController {

    // Maps GET requests to /hello to this method
    @GetMapping("/hello")
    public String hello() {
        return "Hello, Task Manager!";
    }

}

What does this code do?

  • @RestController tells Spring Boot this class handles HTTP requests and returns data directly (not a web page template).
  • @GetMapping("/hello") maps any GET request to /hello to the hello() method.
  • The method returns the plain string "Hello, Task Manager!", which Spring Boot sends back as the HTTP response body.
  • Save HelloController.java.
  • If the app is still running from the previous step, press Ctrl+C in your terminal to stop it.
  • Restart the app by running:

macOS

./mvnw spring-boot:run

Windows

.\mvnw.cmd spring-boot:run
  • Open http://localhost:8080/hello in your browser.

You should see the text Hello, Task Manager! displayed in the browser. Your web layer is working.

Not seeing 'Hello, Task Manager!'?

  • Make sure you saved HelloController.java before restarting the app.
  • Check that the file is in the correct directory: src/main/java/com/example/taskmanager/.
  • Look for red error messages in the terminal output after restarting.
  • Help me debug my /hello endpoint.

✔️ Awesome, I've got everything!

Great. Make sure you have saved HelloController.java before moving on.

ⓧ I'd like to double check the full code

package com.example.taskmanager;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello, Task Manager!";
    }

}
Define the Task entity and repository

Every API needs a data model. In Spring Boot, a JPA entity is a Java class that maps directly to a database table. Each instance of the class becomes a row in the table.

  • Create a new file called Task.java inside src/main/java/com/example/taskmanager/.
  • Paste the following code to define the entity class and its fields:
package com.example.taskmanager;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.LocalDate;

// Maps this class to a TASK table in the database
@Entity
public class Task {

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

    private String title;

    private String description;

    private boolean completed;

    private LocalDate dueDate;

What do these annotations mean?

  • @Entity marks the class as a JPA entity. Spring Boot creates a corresponding database table named TASK.
  • @Id marks the id field as the primary key for each row.
  • @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-generate unique IDs for each new task.
  • The remaining fields (title, description, completed, dueDate) each become a column in the table.
  • Below the field declarations, add the constructors and the first group of getter and setter methods:
    // No-argument constructor required by JPA
    public Task() {
    }

    public Task(String title, String description, boolean completed, LocalDate dueDate) {
        this.title = title;
        this.description = description;
        this.completed = completed;
        this.dueDate = dueDate;
    }

    // Getters and setters for each field
    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;
    }
  • Continue by adding the remaining getter and setter methods, then close the class:
    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 LocalDate getDueDate() {
        return dueDate;
    }

    public void setDueDate(LocalDate dueDate) {
        this.dueDate = dueDate;
    }

}

What are the constructors and getters/setters for?

The no-argument constructor is required by JPA to create entity instances when reading rows from the database. The parameterized constructor lets you create new tasks with all fields in one line of code.

Getters and setters allow Spring Boot to read and write each field when converting between Java objects and JSON. Without them, the API could not serialize or deserialize task data.

  • Save Task.java.

✔️ Awesome, I've got everything!

Great. Confirm you have saved Task.java with all three sections: the entity fields at the top, constructors in the middle, and getters/setters at the bottom.

ⓧ I'd like to double check the full code

Since Task.java is a longer file, scroll up and confirm you have all three code blocks in order:

  • The package declaration, imports, @Entity annotation, and five field declarations (id, title, description, completed, dueDate).
  • The no-argument constructor, parameterized constructor, and getters/setters for id and title.
  • The remaining getters/setters for description, completed, and dueDate, plus the closing } brace.

Now create the repository that gives this entity database operations.

  • Create a new file called TaskRepository.java in the same src/main/java/com/example/taskmanager/ directory.
  • Paste the following code:
package com.example.taskmanager;

import org.springframework.data.jpa.repository.JpaRepository;

// Gives Task full CRUD database operations with no extra code
public interface TaskRepository extends JpaRepository<Task, Long> {
}

What is JpaRepository?

JpaRepository<Task, Long> is a Spring Data interface that provides built-in methods for database operations. By extending it, your TaskRepository automatically gets methods like findAll(), findById(), save(), and delete() with zero extra code.

✔️ Awesome, I've got everything!

Great. Make sure you have saved TaskRepository.java.

ⓧ I'd like to double check the full code

package com.example.taskmanager;

import org.springframework.data.jpa.repository.JpaRepository;

public interface TaskRepository extends JpaRepository<Task, Long> {
}
  • Save TaskRepository.java.
  • Press Ctrl+C in your terminal to stop the running app, then restart it:

macOS

./mvnw spring-boot:run

Windows

.\mvnw.cmd spring-boot:run

You should see Started TaskmanagerApplication in the terminal with no errors. This confirms that Spring Boot recognized your entity and repository.

Seeing compilation errors?

  • Make sure you used jakarta.persistence (not javax.persistence) for the entity imports. Spring Boot 4.1.0 uses the Jakarta namespace.
  • Check that both Task.java and TaskRepository.java declare package com.example.taskmanager; at the top.
  • Confirm that Task.java has the closing } brace at the very end of the file.
  • Help me fix my Task entity or repository errors.
Configure H2 and verify the database

H2 is a lightweight in-memory database that Spring Boot can run without any external setup. You need to configure a few properties to give the database a fixed name and enable its web console for browsing tables.

  • In VS Code, open application.properties inside src/main/resources/.
  • Replace the file contents with the following configuration:
spring.application.name=taskmanager

# Database connection settings (defaults to H2 for local dev)
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:h2:mem:taskdb}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:sa}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:}
spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER:org.h2.Driver}

# Auto-create database tables from entity classes
spring.jpa.hibernate.ddl-auto=update
# Print SQL statements to the terminal
spring.jpa.show-sql=true

# Enable the H2 web console at /h2-console
spring.h2.console.enabled=true

What does each property do?

  • spring.datasource.url sets the database connection URL. jdbc:h2:mem:taskdb creates an in-memory database named taskdb.
  • The ${VARIABLE:default} syntax is a Spring Boot feature that lets environment variables override these values. For now, the defaults point to H2 for local development.
  • spring.jpa.hibernate.ddl-auto=update tells Hibernate to automatically create or update database tables based on your entity classes.
  • spring.jpa.show-sql=true prints SQL statements to the terminal so you can see what Hibernate does behind the scenes.
  • spring.h2.console.enabled=true activates a web-based database browser at /h2-console.

✔️ Awesome, I've got everything!

Great. Make sure you have saved application.properties.

ⓧ I'd like to double check the full code

spring.application.name=taskmanager

spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:h2:mem:taskdb}
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

spring.h2.console.enabled=true
  • Save application.properties.
  • Press Ctrl+C in your terminal to stop the app, then restart it:

macOS

./mvnw spring-boot:run

Windows

.\mvnw.cmd spring-boot:run

Before you check the H2 console, think about this: did you write any SQL to create a database table? What do you expect to find?

  • Open http://localhost:8080/h2-console in your browser.
  • In the JDBC URL field, replace the default value with jdbc:h2:mem:taskdb.
  • Leave the User Name as sa and the Password blank.
  • Click Connect.

In the left panel, you should see a TASK table. Click on it to see its columns: ID, TITLE, DESCRIPTION, COMPLETED, and DUE_DATE.

The TASK table appeared automatically. Hibernate read your @Entity class and created the matching table structure. You never wrote a single SQL statement.

Don't see the TASK table?

  • Make sure the JDBC URL in the H2 console is exactly jdbc:h2:mem:taskdb. The default value will not match your database.
  • Confirm that spring.jpa.hibernate.ddl-auto=update is in your application.properties.
  • Check that Task.java has the @Entity annotation on the class.
  • Help me debug my H2 console connection.
  • Commit your progress and push to GitHub by running these commands:
git add .
git commit -m "Add hello endpoint, Task entity, and repository"
git push

What is this commit saving?

This commit captures the three new files you created (HelloController.java, Task.java, TaskRepository.java) and the updated application.properties. Your GitHub repository now reflects a working endpoint and data model.

Your API has its first endpoint and a database-backed Task model. Next up, you will build five CRUD endpoints to create, read, update, and delete tasks through the API.

Build the CRUD REST API

Your Task entity is living in the database and the H2 console proves it. But the only endpoint your API has is /hello, which does nothing with tasks.

You need a way to create, read, update, and delete tasks over HTTP. In this step, you will build five REST endpoints that turn your Task entity into a fully usable API.

In this step, get ready to:
  • Create a service layer that handles task business logic.
  • Wire up a REST controller with five CRUD endpoints.
  • Test every endpoint with curl to confirm real data flows through the API.
Create the task service layer

Spring Boot uses a layered architecture. The controller handles HTTP requests, the service handles business logic, and the repository talks to the database. You will build the service layer first.

  • Create a new file called TaskService.java in src/main/java/com/example/taskmanager/.
  • Add the class shell, constructor, and two read methods by pasting this code:
package com.example.taskmanager;

import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

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

    private final TaskRepository taskRepository;

    // Spring injects the TaskRepository automatically
    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    // Returns every task in the database
    public List<Task> getAllTasks() {
        return taskRepository.findAll();
    }

    // Finds one task by ID, or throws a 404 error
    public Task getTaskById(Long id) {
        return taskRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Task not found"));
    }

}

What does this code do?

  • @Service tells Spring to manage this class as a bean, making it injectable into other components.
  • The constructor accepts a TaskRepository. Spring automatically injects it because there is only one constructor.
  • getTaskById() uses orElseThrow() to return a 404 NOT_FOUND response if no task matches the ID.

The service also needs methods to create, update, and delete tasks.

  • In TaskService.java, find the closing brace } of the class.
  • Add these three write methods above that closing brace:
    // Saves a new task to the database
    public Task createTask(Task task) {
        return taskRepository.save(task);
    }

    // Updates all fields of an existing task
    public Task updateTask(Long id, Task updatedTask) {
        Task task = getTaskById(id);
        task.setTitle(updatedTask.getTitle());
        task.setDescription(updatedTask.getDescription());
        task.setCompleted(updatedTask.isCompleted());
        task.setDueDate(updatedTask.getDueDate());
        return taskRepository.save(task);
    }

    // Deletes a task by ID, throwing 404 if not found
    public void deleteTask(Long id) {
        Task task = getTaskById(id);
        taskRepository.delete(task);
    }

What does this code do?

  • createTask() passes the new task straight to the repository's save() method, which inserts it into the database.
  • updateTask() first fetches the existing task (throwing 404 if missing), copies every field from the incoming data, then saves the modified task.
  • deleteTask() also fetches first to verify the task exists before deleting it.
  • Save TaskService.java.
  • If your app is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
  • Restart the app by running:
.\mvnw.cmd spring-boot:run

The app should boot with the Spring Boot banner and show Started TaskmanagerApplication. The service layer compiled correctly.

Seeing a compilation error?

  • Check that your import lines match exactly. ResponseStatusException comes from org.springframework.web.server, not org.springframework.web.
  • Make sure the three write methods are inside the class braces, not after the closing }.

Help me fix a compilation error in my TaskService class.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Since TaskService.java is a long file, scroll up and confirm these two blocks are present in order:

  • The class shell with the constructor, getAllTasks(), and getTaskById() (first code block).
  • createTask(), updateTask(), and deleteTask() (second code block), all inside the class braces.
Wire up the REST controller

The service holds all the business logic. Now you need a controller that maps incoming HTTP requests to the right service method.

  • Create a new file called TaskController.java in src/main/java/com/example/taskmanager/.
  • Add the class shell, constructor, and the first GET endpoint by pasting this code:
package com.example.taskmanager;

import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

// All endpoints in this controller start with /api/tasks
@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final TaskService taskService;

    // Spring injects the TaskService automatically
    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    // GET /api/tasks - returns all tasks as JSON
    @GetMapping
    public List<Task> getAllTasks() {
        return taskService.getAllTasks();
    }

}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and returns data directly (not a web page).
  • @RequestMapping("/api/tasks") sets the base URL path. Every endpoint in this controller starts with /api/tasks.
  • @GetMapping on getAllTasks() maps HTTP GET requests to /api/tasks. Spring converts the returned list to JSON automatically.
  • Save TaskController.java.
  • Stop the running app with Ctrl+C and restart it by running:
.\mvnw.cmd spring-boot:run
  • Open http://localhost:8080/api/tasks in your browser.

You should see an empty JSON array: []. That is correct. The database has no tasks yet, so the list is empty.

Seeing a Whitelabel Error Page?

  • Make sure you are hitting /api/tasks (not /tasks or /).
  • Check that @RequestMapping("/api/tasks") is on the class, not on the method.

Help me debug my TaskController endpoint.

Now you need four more endpoints to complete the CRUD operations.

  • In TaskController.java, find the closing brace } of the class.
  • Add these four endpoints above that closing brace:
    // GET /api/tasks/{id} - returns one task by its ID
    @GetMapping("/{id}")
    public Task getTaskById(@PathVariable Long id) {
        return taskService.getTaskById(id);
    }

    // POST /api/tasks - creates a new task, returns 201
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Task createTask(@RequestBody Task task) {
        return taskService.createTask(task);
    }

    // PUT /api/tasks/{id} - replaces an existing task
    @PutMapping("/{id}")
    public Task updateTask(@PathVariable Long id, @RequestBody Task task) {
        return taskService.updateTask(id, task);
    }

    // DELETE /api/tasks/{id} - removes a task, returns 204
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteTask(@PathVariable Long id) {
        taskService.deleteTask(id);
    }

What does this code do?

  • @PathVariable extracts the {id} value from the URL and passes it to the method as a Long.
  • @RequestBody on the POST and PUT methods tells Spring to parse the incoming JSON body into a Task object.
  • @ResponseStatus(HttpStatus.CREATED) makes the POST endpoint return a 201 status instead of the default 200. This follows REST conventions for resource creation.
  • @ResponseStatus(HttpStatus.NO_CONTENT) makes the DELETE endpoint return 204 with no response body.
  • Save TaskController.java.

Compilation error on save?

  • Make sure the four new endpoint methods are inside the class braces, not after the final }.
  • Verify all imports at the top of the file are present. You need DeleteMapping, PostMapping, PutMapping, PathVariable, RequestBody, and ResponseStatus.

Help me fix a compilation error in TaskController.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Since TaskController.java is a long file, scroll up and confirm these two blocks are present in order:

  • The class shell with the constructor and the getAllTasks() GET endpoint (first code block).
  • The four remaining endpoints: getTaskById(), createTask(), updateTask(), and deleteTask() (second code block), all inside the class braces.

Why a separate service and controller?

You could put all the logic directly in the controller. But separating it into a service layer means the business logic lives in one place, independent of how it is accessed.

If you later add a scheduled job or a messaging consumer that also manages tasks, they can reuse TaskService without duplicating code.

Test all five endpoints with curl

Seeing an empty list is a good start, but you need to prove all five operations work. You will use curl to send HTTP requests from the terminal.

Windows users can use Git Bash

If you are on Windows, open Git Bash (installed with Git) to run the curl commands below. The quoting syntax works the same as macOS and Linux.

  • Stop the running app with Ctrl+C and restart it by running:
.\mvnw.cmd spring-boot:run
  • Open a second terminal window (keep the app running in the first one).
  • Create your first task by running this curl command:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"Learn Spring Boot\", \"description\": \"Build a REST API\", \"completed\": false, \"dueDate\": \"2026-09-01\"}"

You should see a JSON response containing the task you just created, complete with an auto-generated id field.

  • Create a second task by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"Learn Docker\", \"description\": \"Containerize the API\", \"completed\": false, \"dueDate\": \"2026-09-15\"}"
  • List all tasks to confirm both were saved by running:
curl http://localhost:8080/api/tasks

You should see a JSON array containing both tasks.

  • Fetch just the first task by its ID by running:
curl http://localhost:8080/api/tasks/1

You should see only the first task returned as a single JSON object.

  • Update the first task to mark it as completed by running:
curl -X PUT http://localhost:8080/api/tasks/1 -H "Content-Type: application/json" -d "{\"title\": \"Learn Spring Boot\", \"description\": \"Build a REST API\", \"completed\": true, \"dueDate\": \"2026-09-01\"}"

The response shows the same task, but "completed": true now.

  • Delete the second task by running:
curl -X DELETE http://localhost:8080/api/tasks/2

Before you confirm the delete worked, what do you expect to see if you try to fetch the deleted task?

  • Try to fetch the deleted task by running:
curl http://localhost:8080/api/tasks/2

You should see a 404 error response. That is the ResponseStatusException you wrote in TaskService doing its job.

  • Confirm the final state of your database by listing all tasks one more time:
curl http://localhost:8080/api/tasks

You should see a JSON array with only one task remaining: the updated "Learn Spring Boot" task with "completed": true.

Getting connection refused or empty responses?

  • Make sure the app is still running in your first terminal. You should see log output there as each request comes in.
  • Check that you are using port 8080 in the URL. If another app is using that port, stop it first.
  • If the POST command gives a 415 error, make sure the -H "Content-Type: application/json" header is included.

Help me debug my curl requests to the Task Manager API.

  • Commit your work and push to GitHub by running these commands:
git add .
git commit -m "Implement full CRUD API for tasks"
git push

Your API is fully functional. You can create tasks, retrieve them, update them, and delete them. Next up, you will package this API into a Docker container so it can run anywhere.

Containerize the API

Your Task Manager API runs perfectly on your machine. Every endpoint responds, every task saves and retrieves exactly as expected. But what happens when someone else needs to run this API on their machine, or when you want to deploy it to a server?

Docker solves this by packaging your app, its runtime, and all its dependencies into a single, portable container image. In this step, you will write a Dockerfile, build that image, and run your API inside a container.

In this step, get ready to:
  • Write a multi-stage Dockerfile to build and package the API.
  • Build a Docker image and run the API inside a container.
  • Test what happens to your data when a container restarts.
Write a multi-stage Dockerfile

A Dockerfile is a recipe that tells Docker how to build your application into a container image. A multi-stage build splits this recipe into two stages: one to compile the code and another to run it.

Why use a multi-stage build?

The build stage needs the full JDK, Maven, and all your source code. That is a large image. The runtime stage only needs the JRE and the compiled JAR file.

By splitting the build into two stages, the final image is much smaller and contains only what is needed to run the app. Nothing from the build stage (source code, Maven, JDK) ends up in the production image.

  • In VS Code, create a new file called Dockerfile in the root of your taskmanager folder (the same level as pom.xml).
  • Paste the following code into the file:
# Stage 1: Build the application using the full JDK
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app

# Copy dependency config first for Docker layer caching
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline

# Copy source code and build the JAR
COPY src src
RUN ./mvnw clean package -DskipTests

# Stage 2: Create a lightweight runtime image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

What does this Dockerfile do?

  • Stage 1 (builder) starts from the eclipse-temurin:17-jdk image, which has Java 17 and a full JDK. It copies the Maven wrapper and pom.xml first to download dependencies. Then it copies the source code and builds the JAR.
  • Copying dependency files before source code lets Docker cache the dependency layer. If you only change your source code, Docker skips re-downloading dependencies on the next build.
  • Stage 2 (runtime) starts from the much smaller eclipse-temurin:17-jre image, copies the built JAR from stage 1, and runs it.
  • chmod +x mvnw makes the Maven wrapper script executable inside the Linux container, since Windows file permissions do not carry over.
  • Save Dockerfile.
  • Confirm the file exists by running this in your terminal from the taskmanager folder:
ls Dockerfile

You should see Dockerfile printed in the output.

Don't see the Dockerfile?

  • Make sure the file is named exactly Dockerfile with a capital D and no file extension.
  • Check that you created it in the root of the taskmanager folder, next to pom.xml.
  • Make sure your terminal is in the taskmanager directory. Run pwd to confirm.

Why isn't my Dockerfile showing up?

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete Dockerfile. Compare yours line by line:

FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline
COPY src src
RUN ./mvnw clean package -DskipTests

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Build and run the containerized API

With the Dockerfile written, you can now build a Docker image and start a container from it. The build process compiles your code inside the container, so you do not need Java or Maven on the target machine at all.

  • If your Spring Boot app is still running from the previous step, stop it by pressing Ctrl+C in that terminal. Port 8080 must be free for the container to use it.
  • Make sure Docker Desktop is running.
  • Build the Docker image by running this command:
docker build -t task-api .

What does this command do?

  • docker build reads the Dockerfile and executes each instruction to create an image.
  • -t task-api tags the image with the name task-api so you can reference it later.
  • The . at the end tells Docker to use the current directory as the build context.

The first build takes a few minutes because Docker downloads the base images and all Maven dependencies. Future builds will be much faster thanks to the layer caching you set up in the Dockerfile.

  • Once the build finishes, start a container from the image by running:
docker run -p 8080:8080 task-api

What does -p 8080:8080 mean?

The -p 8080:8080 flag maps port 8080 on your machine to port 8080 inside the container. Without it, the API would be running but unreachable from your browser or terminal.

This command runs in the foreground, so the Spring Boot logs appear directly in your terminal. You should see the Spring Boot banner and a line that says Started TaskmanagerApplication.

  • Open a second terminal window (the first one is occupied by the running container).
  • Create a task inside the container by running this command in the second terminal:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title": "Dockerized task", "description": "Created inside a container", "completed": false, "dueDate": "2026-09-01"}'

You should see a JSON response with the task you just created, including an id field.

  • Confirm the task exists by listing all tasks:
curl http://localhost:8080/api/tasks

You should see a JSON array containing the task you just created. The containerized API works identically to when you ran it locally.

Getting a connection refused error?

  • Check that the first terminal shows Started TaskmanagerApplication. If the app is still starting, wait a few seconds and try again.
  • Make sure no other process is using port 8080. If your local Spring Boot app is still running, stop it first.
  • Confirm Docker Desktop is running. The container cannot start without it.

Why can't I connect to my containerized API?

Test data persistence across container restarts

Your containerized API just created a task and returned it. The container is running, the data is there. But what happens if the container stops and you start a fresh one?

  • Switch to the terminal where the container is running (the one showing Spring Boot logs).
  • Press Ctrl+C to stop the container.
  • Start a brand new container by running the same command again:
docker run -p 8080:8080 task-api

Before you test, think about this: you created a task in the previous container. Do you think it will still be there in this new container?

  • In your second terminal, list all tasks by running:
curl http://localhost:8080/api/tasks

The response is an empty list: []. Your task is gone.

Why did the data disappear?

The H2 database is an in-memory database. It lives entirely inside the running container's memory. When you stopped the container, the H2 database was destroyed along with it.

Starting a new container creates a completely fresh environment with an empty database. This is the fundamental limitation of using an in-memory database inside a container. You need a real, persistent database running separately from the API container.

  • Stop the container by pressing Ctrl+C in the first terminal.
  • Commit your new file and push to GitHub by running these commands:
git add Dockerfile
git commit -m "Add Dockerfile for standalone container build"
git push

You have successfully containerized your API. It runs identically inside a Docker container. But you have also discovered a critical gap: the in-memory database does not survive restarts. Next up, you will solve this by orchestrating your API alongside a real PostgreSQL database using Docker Compose.

Orchestrate with Docker Compose

In the last step, you discovered the critical flaw with running a standalone container: the H2 database lives inside the container, so all your data vanishes the moment the container stops.

The fix is to run your API and database as separate containers, each with its own job. Docker Compose orchestrates both containers together, wires them onto a shared network, and persists your database data in a volume that survives restarts.

In this step, get ready to:
  • Add the PostgreSQL driver to your project dependencies.
  • Create a Docker Compose configuration that runs the API and PostgreSQL side by side.
  • Prove that data persists across container restarts.
Add the PostgreSQL driver dependency

Before Docker Compose can connect the API to a PostgreSQL container, your Java project needs the PostgreSQL JDBC driver on the classpath.

Your application.properties already reads the datasource URL, username, password, and driver from environment variables, falling back to H2 defaults when those variables are not set. That means you only need to add the driver dependency to pom.xml.

  • In VS Code, open pom.xml and scroll down to the <dependencies> section.
  • Find the H2 dependency block that ends with </dependency> (the one containing com.h2database).
  • Add the following PostgreSQL dependency directly below it:
<dependency>
	<groupId>org.postgresql</groupId>
	<artifactId>postgresql</artifactId>
	<scope>runtime</scope>
</dependency>

What does this dependency do?

The PostgreSQL JDBC driver lets Spring Boot talk to a PostgreSQL database. The runtime scope means this library is only needed when the application runs, not when it compiles.

Because both H2 and PostgreSQL drivers are now on the classpath, your app can use either one depending on which environment variables are set.

  • Save pom.xml.
Create the Docker Compose configuration

Docker Compose uses a single YAML file to define every container your application needs, how they connect to each other, and where their data is stored.

  • Create a new file called compose.yml in the root of your taskmanager project folder (the same folder that contains your Dockerfile).
  • Paste the following code to define the database service:
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: taskdb
      POSTGRES_USER: taskuser
      POSTGRES_PASSWORD: taskpass
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskuser -d taskdb"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

What does this code do?

  • image: postgres:16-alpine pulls a lightweight PostgreSQL 16 image based on Alpine Linux.
  • The environment block creates the database taskdb with user taskuser and password taskpass when the container first starts.
  • The volumes line maps a named volume called postgres_data to /var/lib/postgresql/data inside the container. This is the key to persistence.
  • The healthcheck runs pg_isready every 10 seconds to confirm PostgreSQL is accepting connections.
  • Save compose.yml and verify the database starts by running this command:
docker compose up db

You should see PostgreSQL pull its image (if this is the first time), then print log lines ending with database system is ready to accept connections.

Don't see PostgreSQL start?

  • Make sure Docker Desktop is running.
  • Confirm your file is named compose.yml (not docker-compose.yml) and sits in the project root next to your Dockerfile.
  • Check for YAML indentation errors. YAML uses spaces, not tabs.

Still stuck? Why won't my Docker Compose db service start?

  • Press Ctrl+C in the terminal to stop the database container.
  • Now add the API service. In compose.yml, place the following code below the db service block (after the retries: 5 line) and above the volumes: section at the bottom:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/taskdb
      SPRING_DATASOURCE_USERNAME: taskuser
      SPRING_DATASOURCE_PASSWORD: taskpass
      SPRING_DATASOURCE_DRIVER: org.postgresql.Driver
    depends_on:
      db:
        condition: service_healthy

What does this code do?

  • build: . tells Compose to build the API image from the Dockerfile in the current directory.
  • The environment block overrides the H2 defaults in application.properties, pointing Spring Boot at the PostgreSQL container instead.
  • SPRING_DATASOURCE_URL uses db as the hostname because Docker Compose puts both containers on the same network and lets them find each other by service name.
  • depends_on with condition: service_healthy ensures the API container only starts after the PostgreSQL health check passes.

Why health checks instead of just depends_on?

Without the service_healthy condition, Compose only waits until the database container is running, not until PostgreSQL is actually ready for connections. Your API could start, try to connect, and crash because PostgreSQL is still initializing.

The health check solves this by running pg_isready repeatedly until it gets a success response. Only then does Compose start the API.

  • Save compose.yml.

✔️ Awesome, I've got everything!

Great. Make sure you have saved compose.yml and that the api service sits inside the services: block, at the same indentation level as db.

ⓧ I'd like to double check the full code

Your compose.yml was built in two chunks above. Scroll up and confirm both are present in this order:

  • The db service block (image, environment, ports, volumes, healthcheck).
  • The api service block (build, ports, environment, depends_on).
  • The top-level volumes: section with postgres_data: at the very bottom of the file.

Make sure both services are indented under services: and use spaces (not tabs) for indentation.

Launch the full stack with Docker Compose

Both containers are defined. Time to bring everything up and confirm the API talks to PostgreSQL.

  • Make sure no previous containers are running on port 8080 or 5432. If you have a local Spring Boot process or an old docker run container still active, stop it first.
  • Launch both services by running:
docker compose up --build

Compose builds the API image from your Dockerfile, pulls the PostgreSQL image if needed, starts the database, waits for the health check, then starts the API. You should see the Spring Boot banner followed by Started TaskmanagerApplication in the logs.

Seeing connection refused or build errors?

  • If the API fails to connect to PostgreSQL, check that your SPRING_DATASOURCE_URL uses db as the hostname (not localhost).
  • If the Maven build fails during docker compose up --build, make sure you saved pom.xml with the new PostgreSQL dependency.
  • If port 8080 is already in use, stop any running Spring Boot process or Docker container using that port.

Still stuck? Help me debug my Docker Compose startup

  • Open a new terminal window (Compose is still running in the first one).
  • Create a task by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"Learn Docker Compose\", \"description\": \"Orchestrate multi-container apps\", \"completed\": false, \"dueDate\": \"2026-09-01\"}"
  • Confirm it exists by running:
curl http://localhost:8080/api/tasks

You should see a JSON array containing your task with an id, title, description, and dueDate. The API is now talking to PostgreSQL instead of H2.

Prove data persists across restarts

The whole reason you switched to Docker Compose was to solve the data loss problem you hit in the previous step. Time to prove it actually works.

  • In the terminal running Compose, press Ctrl+C to stop both containers.
  • Tear down the containers by running:
docker compose down

What does docker compose down do?

This command stops and removes the containers, but it does NOT remove the named volume postgres_data where PostgreSQL stored its data. The volume persists on disk unless you explicitly delete it with the -v flag.

Before you bring the stack back up, think about this: when the container with H2 restarted in the last step, the task list came back empty. What do you think will happen this time?

  • Bring the stack back up by running:
docker compose up
  • Once you see Started TaskmanagerApplication in the logs, open your other terminal and run:
curl http://localhost:8080/api/tasks

Your task is still there. The data survived the restart because PostgreSQL stores it in the Docker volume, not inside the API container.

The persistence difference in one sentence

H2 stored data in memory inside the container, so it died with the container. PostgreSQL stores data in the postgres_data volume, which lives on your host machine and outlasts any individual container.

Task list came back empty?

  • Make sure you ran docker compose down without the -v flag. The -v flag deletes volumes, which removes all your data.
  • Check that compose.yml has the volumes: section at the bottom declaring postgres_data:.

Still stuck? Help me figure out why my data didn't persist across Docker Compose restarts

  • Stop the Compose stack with Ctrl+C in the terminal running it.
  • Stage and commit your changes by running:
git add .
git commit -m "Switch to Postgres and add Docker Compose multi-container setup"
  • Push to GitHub by running:
git push

Secret mission

Write Professional Project Documentation

Your API works, your containers are orchestrated, and data persists across restarts. But if you shared this project with a hiring manager right now, they would have to dig through your source code to understand what it does. Can you write a README that tells the whole story?

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 with a free GitHub repository, so there are no ongoing costs.

Resources you used:

  • Docker image task-api stored locally on your machine.
  • Docker image postgres:16-alpine pulled by Docker Compose.
  • Docker volume postgres_data storing PostgreSQL data.
  • Local project folder taskmanager with all source code and configuration files.
  • GitHub repository for your project.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with the API.

  • Your Docker images and the postgres_data volume stay on disk.
  • Run docker compose up from your taskmanager folder anytime to restart the project.
  • Your GitHub repository is free to keep.

Pause - I'll come back to this later

Shut down running containers to free up memory and ports, but keep your files and data volume for later.

  • Stop and remove the running containers by running this command from inside your taskmanager folder:
docker compose down

This stops the API and PostgreSQL containers but keeps the postgres_data volume intact. Your task data will still be there when you run docker compose up again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Stop and remove the containers and the data volume by running this command from inside your taskmanager folder:
docker compose down -v
  • Remove the Docker images by running these commands:
docker rmi task-api
docker rmi postgres:16-alpine
  • Delete the local project folder. On macOS or Linux:
rm -rf taskmanager
  • On Windows (PowerShell):
Remove-Item -Recurse -Force taskmanager
  • To delete the GitHub repository, open your repository page on GitHub.
  • Click Settings in the top navigation bar.
  • Scroll to the bottom to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

You just built a fully containerized REST API from scratch and deployed it alongside a real database using Docker Compose. That is a production-grade pattern you will see in real-world deployments.

You've learned how to:

  • Built a Spring Boot REST API with five CRUD endpoints, using the layered architecture pattern (@RestController for HTTP, @Service for business logic, @Repository for data access, @Entity for the data model).
  • Wrote a multi-stage Dockerfile that builds a production-style container image from source, then hit the in-memory database limitation when the container restarted and lost all data.
  • Solved the data persistence problem by orchestrating the API and PostgreSQL containers with Docker Compose, health checks, and a persistent volume.
  • Secret Mission: Wrote professional README documentation with API examples and a "Why Compose?" explanation.

Ready to quiz yourself?