Build a URL Shortener with Spring Boot
Build a REST API that shortens URLs and persists data in PostgreSQL.
Introduction
30 Second Summary
Every time you click a shortened link on social media or in an email, a service is translating that tiny code back into the full destination URL. It happens billions of times a day, and the logic behind it is surprisingly learnable.
In this project, you will build a URL shortener REST API from scratch using Spring Boot. You will shorten URLs, redirect visitors, persist data in a real database, and expose production health checks.
What You'll Build
A working API you can demo from any HTTP client: paste in a long URL, get back a short code, visit that code, and watch your browser land on the original page.
By the end of this project, you'll have:
- A shorten endpoint that accepts any long URL via a POST request and hands back a unique 8-character short code you can share.
- A redirect endpoint where visiting the short code in a browser instantly sends you (HTTP 302) to the original destination.
- A PostgreSQL database running inside a Docker container so all your shortened URLs survive application restarts.
- Production-ready health checks and metrics you can query in real time to prove your API is alive and performing.
- Secret Mission: Add link expiration with automatic TTL and a click-count tracking endpoint.
Are there any prerequisites?
You need basic Java knowledge (classes, interfaces, annotations). No prior Spring Boot experience is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to articulate your goal turns this from a tutorial you follow into a project you own.
Install Tools and Scaffold the Project
To build a URL shortener API, you need three things: a build tool to compile your Java code, a container runtime to host your database, and IDE extensions that understand Spring Boot.
In this step, you will install Apache Maven (your build tool), Docker Desktop (your container runtime), and VS Code extensions that give you Spring Boot superpowers. Then you will scaffold a fresh Spring Boot project and confirm it starts.
In this step, get ready to:
- Install Maven and Docker Desktop on your machine.
- Add Java and Spring Boot extensions to VS Code.
- Generate and run a Spring Boot project from scratch.
Install Maven and Docker Desktop
Maven is the build tool that compiles your Java code, manages dependencies, and packages your app into a runnable JAR.
Docker Desktop lets you run a PostgreSQL database inside a container later in the project.
- Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check if Maven is already installed by running:
mvn -version
✔️ I see version 3.9.16 or higher
Maven is ready. Skip ahead to the Docker Desktop check below.
ⓧ I see an older version
You have Maven installed but need to upgrade to 3.9.16 or higher.
macOS
- Upgrade Maven by running:
brew upgrade maven
- Close and reopen your terminal, then run mvn -version again to confirm you see 3.9.16.
Windows
- Upgrade Maven with Scoop by running:
scoop update main/maven
- Close and reopen your terminal, then run mvn -version again to confirm you see 3.9.16.
ⓧ Command not found
Maven is not installed yet. Follow the instructions for your operating system.
macOS
- Install Maven using Homebrew by running:
brew install maven
- Close and reopen your terminal, then verify by running:
mvn -version
You should see Apache Maven 3.9.16 in the output along with your Java version.
Windows
- Install Maven using Scoop by running:
scoop install main/maven
Don't have Scoop?
Download apache-maven-3.9.16-bin.zip from maven.apache.org/download.cgi. Extract it to a folder like C:\Program Files\Maven. Add the bin directory inside it to your system PATH.
- Close and reopen your terminal, then verify by running:
mvn -version
You should see Apache Maven 3.9.16 in the output along with your Java version.
What is Maven?
Maven reads a file called pom.xml in your project to know which libraries to download and how to build your code. Think of it like a recipe card that lists every ingredient your project needs.
- Check if Docker is already installed by running:
docker --version
✔️ I see a version number
Docker is ready. Move on to the next substep.
ⓧ Command not found
Docker Desktop is not installed. Follow the instructions for your operating system.
macOS
- Go to docker.com/products/docker-desktop and click Download for Mac.
- Double-click Docker.dmg to open the installer.
- Drag the Docker icon into the Applications folder.
- Open Docker from your Applications folder.
- Accept the Docker Subscription Service Agreement when prompted.
- Wait for the Docker engine to start. You will see the Docker whale icon in your menu bar when it is ready.
- Confirm by running docker --version in a new terminal window.
Windows
- Go to docker.com/products/docker-desktop and click Download for Windows.
- Run the downloaded Docker Desktop Installer.exe.
- When prompted, ensure Use WSL 2 instead of Hyper-V is checked.
- Click Ok to proceed with the installation.
- Restart your computer if prompted.
- Open Docker Desktop from the Start menu and accept the service agreement.
- Wait for the Docker engine to start. You will see a green status indicator in the bottom-left of the Docker Desktop window.
- Confirm by running docker --version in a new terminal window.
Docker command still not found?
Make sure Docker Desktop is running (not just installed). The docker CLI only works when the Docker engine is active.
On Windows, close and reopen your terminal after installation so the PATH update takes effect. Help me troubleshoot Docker installation
Install VS Code extensions
Two extension packs give VS Code everything it needs to compile Java and navigate Spring Boot projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Press Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows) to open the Extensions sidebar.
- Search for Extension Pack for Java and click Install.
- Search for Spring Boot Extension Pack and click Install.
You should see both extensions listed as installed in the sidebar. The Java extension may take a moment to download language server components.
What do these extensions do?
The Extension Pack for Java (ID: vscjava.vscode-java-pack) adds Java IntelliSense, debugging, and Maven support.
The Spring Boot Extension Pack (ID: vmware.vscode-boot-dev-pack) adds Spring Initializr integration, Boot Dashboard, and Spring-aware code navigation.
Extensions not appearing?
Try searching by the extension ID directly: vscjava.vscode-java-pack or vmware.vscode-boot-dev-pack. If VS Code shows a reload button, click it to activate the extensions. Help me install VS Code extensions
Generate the Spring Boot project
Spring Initializr generates a ready-to-run project skeleton with all your dependencies pre-configured. You will use it directly from VS Code thanks to the Spring Boot Extension Pack you just installed.
- Open the Command Palette by pressing Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows).
- Type Spring Initializr: Create a Maven Project and select it.
The wizard asks you a series of questions. Enter the following values one at a time:
- Select the default Spring Boot version offered (the latest 3.4.x release).
- Select Java as the project language.
- Enter com.example for the Group Id.
- Enter urlshortener for the Artifact Id.
- Select Jar as the packaging type.
- Select 17 as the Java version.
Next, the wizard asks you to choose dependencies. Search for and select each of these:
- Select Spring Web.
- Select Spring Data JPA.
- Select H2 Database.
- Select Spring Boot Actuator.
- Press Enter to confirm your dependency selection and generate the project.
- When prompted for a save location, select your Desktop.
- VS Code will ask if you want to open the generated project. Click Open.
What did Spring Initializr generate?
The generated project includes a pom.xml with your chosen dependencies, a main application class, and the Maven Wrapper (mvnw/mvnw.cmd) which lets you build and run without a global Maven install.
The H2 Database dependency gives you a lightweight in-memory database for fast prototyping. You will swap it for PostgreSQL later in the project.
Before you run the app, what do you think will happen when Spring Boot starts with an in-memory database and no custom code?
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on both macOS and Windows).
- Start the application by running:
macOS
./mvnw spring-boot:run
Windows
mvnw.cmd spring-boot:run
First run takes a while
The first time you run this, Maven downloads all project dependencies. This can take 1-2 minutes depending on your internet speed. You will see download progress in the terminal.
When the app finishes starting, you should see a line like this near the bottom of the terminal output:
Started UrlshortenerApplication in X.XXX seconds
The exact time varies, but the key phrase is Started UrlshortenerApplication. This confirms Spring Boot is running on port 8080.
- Stop the app by pressing Ctrl+C in the terminal. You will restart it in the next step.
App won't start?
If you see an error about JAVA_HOME, make sure Java 17 is installed and the JAVA_HOME environment variable points to your JDK installation directory.
If you see a port conflict error, another process is using port 8080. Close any other server running on that port and try again. Help me fix Spring Boot startup errors
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and your terminal shows the startup message.
ⓧ I'd like to double check the full code
Check that your pom.xml contains these key dependencies:
- spring-boot-starter-web for REST endpoints.
- spring-boot-starter-data-jpa for database access.
- h2 with scope runtime for the in-memory database.
- spring-boot-starter-actuator for health checks.
- spring-boot-starter-test with scope test.
Your src/main/java/com/example/urlshortener/UrlshortenerApplication.java should look like this:
package com.example.urlshortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class UrlshortenerApplication {
public static void main(String[] args) {
SpringApplication.run(UrlshortenerApplication.class, args);
}
}
Your development environment is ready and your Spring Boot app starts cleanly. Next up, you will build the core URL shortening and redirect endpoints.
Build Your First Shortening Endpoint
In the previous step you got your Spring Boot project running on an empty canvas. The app starts, but it does nothing useful yet.
The fastest way to see your API come alive is building the core shorten-and-redirect cycle. You will create a JPA entity that maps to a database table, a repository that queries it, a service that generates short codes, and a controller that handles HTTP requests. By the end of this step you will POST a long URL and get a short code back, then visit that short code and land on the original page.
In this step, get ready to:
- Create the URL entity and repository that store shortened links in H2.
- Create the service and controller that power the shorten and redirect endpoints.
- Test the full shorten-then-redirect flow with a real HTTP request.
Create the URL entity and repository
A JPA entity is a Java class that maps directly to a database table. Each field becomes a column, and each instance becomes a row. Spring Boot auto-creates the table at startup when it detects an entity on the classpath.
- In VS Code, create a new folder called model inside src/main/java/com/example/urlshortener/.
- Inside the model folder, create a new file called ShortenedUrl.java and paste the following code:
package com.example.urlshortener.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "shortened_urls")
public class ShortenedUrl {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 8)
private String shortCode;
@Column(nullable = false, length = 2048)
private String originalUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column
private Long clickCount = 0L;
@Column
private LocalDateTime expiresAt;
}
What do these annotations do?
The @Entity annotation tells Spring this class maps to a database table. @Table(name = "shortened_urls") names that table explicitly.
Each @Column annotation describes constraints for that column. The unique = true on shortCode ensures no two rows share the same short code.
- Now add the constructors and getter/setter methods below the field declarations (before the closing } of the class). Replace the closing brace with this code:
public ShortenedUrl() {}
public ShortenedUrl(String shortCode, String originalUrl) {
this.shortCode = shortCode;
this.originalUrl = originalUrl;
this.createdAt = LocalDateTime.now();
this.clickCount = 0L;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getShortCode() { return shortCode; }
public void setShortCode(String shortCode) { this.shortCode = shortCode; }
public String getOriginalUrl() { return originalUrl; }
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public Long getClickCount() { return clickCount; }
public void setClickCount(Long clickCount) { this.clickCount = clickCount; }
public LocalDateTime getExpiresAt() { return expiresAt; }
public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; }
}
Why two constructors?
JPA requires a no-argument constructor to create entity instances from database rows. The second constructor is for your code to create new entries with a short code and URL already set.
- Save ShortenedUrl.java.
- Create a new folder called repository inside src/main/java/com/example/urlshortener/.
- Inside the repository folder, create UrlRepository.java and paste this code:
package com.example.urlshortener.repository;
import com.example.urlshortener.model.ShortenedUrl;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UrlRepository extends JpaRepository<ShortenedUrl, Long> {
Optional<ShortenedUrl> findByShortCode(String shortCode);
}
What does this interface do?
By extending JpaRepository, Spring Data JPA automatically generates all the basic CRUD operations (save, find, delete) at runtime. You only declare custom queries like findByShortCode and Spring writes the SQL for you.
- Save UrlRepository.java.
- Run your app to confirm the entity and repository compile correctly by running this in your terminal:
mvnw.cmd spring-boot:run
You should see "Started UrlshortenerApplication" in the terminal output with no errors. Spring Boot detected your @Entity and auto-created the shortened_urls table in H2.
Seeing a compilation error?
Check that your ShortenedUrl.java file is in the com.example.urlshortener.model package (matching the folder structure). A wrong package declaration causes Spring to miss the entity entirely.
Also confirm the closing braces match. You should have one } at the very end of the file to close the class.
Still stuck? Help me debug my ShortenedUrl entity compilation error
- Stop the app with Ctrl+C in the terminal.
Create the service and controller
With the data layer ready, you need two more layers. A service handles business logic (generating short codes, looking up URLs). A controller maps HTTP requests to service calls.
First, create the exception classes the service will throw when a short code is not found or has expired.
- Create a new folder called exception inside src/main/java/com/example/urlshortener/.
- Inside the exception folder, create UrlNotFoundException.java with this code:
package com.example.urlshortener.exception;
public class UrlNotFoundException extends RuntimeException {
public UrlNotFoundException(String shortCode) {
super("Short URL not found: " + shortCode);
}
}
- In the same exception folder, create UrlExpiredException.java with this code:
package com.example.urlshortener.exception;
public class UrlExpiredException extends RuntimeException {
public UrlExpiredException(String shortCode) {
super("Short URL has expired: " + shortCode);
}
}
- Save both exception files.
- Next, create a folder called dto inside src/main/java/com/example/urlshortener/. DTOs (Data Transfer Objects) define the shape of your API request and response bodies.
- Inside the dto folder, create ShortenRequest.java with this code:
package com.example.urlshortener.dto;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.URL;
public class ShortenRequest {
@NotBlank(message = "URL must not be blank")
@URL(message = "Must be a valid URL")
private String url;
private Integer ttlSeconds;
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public Integer getTtlSeconds() { return ttlSeconds; }
public void setTtlSeconds(Integer ttlSeconds) { this.ttlSeconds = ttlSeconds; }
}
What are the validation annotations for?
The @NotBlank and @URL annotations mark what a valid request looks like. They will not enforce anything yet. You will activate them in a later step when you add proper error handling.
- In the same dto folder, create ShortenResponse.java with this code:
package com.example.urlshortener.dto;
public class ShortenResponse {
private String shortCode;
private String shortUrl;
public ShortenResponse(String shortCode, String shortUrl) {
this.shortCode = shortCode;
this.shortUrl = shortUrl;
}
public String getShortCode() { return shortCode; }
public void setShortCode(String shortCode) { this.shortCode = shortCode; }
public String getShortUrl() { return shortUrl; }
public void setShortUrl(String shortUrl) { this.shortUrl = shortUrl; }
}
- Save both DTO files.
Now for the main business logic. The service generates random 8-character codes, saves URL mappings, and retrieves them.
- Create a new folder called service inside src/main/java/com/example/urlshortener/.
- Inside the service folder, create UrlService.java and paste this code to set up the class structure:
package com.example.urlshortener.service;
import com.example.urlshortener.exception.UrlExpiredException;
import com.example.urlshortener.exception.UrlNotFoundException;
import com.example.urlshortener.model.ShortenedUrl;
import com.example.urlshortener.repository.UrlRepository;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
@Service
public class UrlService {
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final int CODE_LENGTH = 8;
private static final SecureRandom RANDOM = new SecureRandom();
private final UrlRepository urlRepository;
public UrlService(UrlRepository urlRepository) {
this.urlRepository = urlRepository;
}
}
What does this code do?
The @Service annotation registers this class in Spring's dependency injection container. The CHARACTERS string defines the alphabet for random short codes. SecureRandom generates cryptographically strong random values so codes are unpredictable.
- Now add the service methods inside the class (before the closing }). Replace the closing brace with this code:
public ShortenedUrl shortenUrl(String originalUrl, Integer ttlSeconds) {
String shortCode = generateShortCode();
ShortenedUrl shortenedUrl = new ShortenedUrl(shortCode, originalUrl);
if (ttlSeconds != null && ttlSeconds > 0) {
shortenedUrl.setExpiresAt(LocalDateTime.now().plusSeconds(ttlSeconds));
}
return urlRepository.save(shortenedUrl);
}
public ShortenedUrl getAndTrackClick(String shortCode) {
ShortenedUrl shortenedUrl = urlRepository.findByShortCode(shortCode)
.orElseThrow(() -> new UrlNotFoundException(shortCode));
if (shortenedUrl.getExpiresAt() != null && LocalDateTime.now().isAfter(shortenedUrl.getExpiresAt())) {
throw new UrlExpiredException(shortCode);
}
shortenedUrl.setClickCount(shortenedUrl.getClickCount() + 1);
urlRepository.save(shortenedUrl);
return shortenedUrl;
}
public ShortenedUrl getStats(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.orElseThrow(() -> new UrlNotFoundException(shortCode));
}
private String generateShortCode() {
StringBuilder code = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
code.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length())));
}
return code.toString();
}
}
What do these methods do?
- shortenUrl generates a random 8-character code, wraps it with the original URL in an entity, and saves it to the database.
- getAndTrackClick looks up a short code, checks expiration, increments the click counter, and returns the entity for redirect.
- generateShortCode picks 8 random characters from the alphanumeric alphabet using SecureRandom.
- Save UrlService.java.
- Run the app to confirm the service compiles:
mvnw.cmd spring-boot:run
You should see "Started UrlshortenerApplication" again with no errors. The service bean is now registered in the application context.
- Stop the app with Ctrl+C.
Finally, the controller wires HTTP endpoints to the service. It accepts POST requests to shorten URLs and GET requests to redirect.
- Create a new folder called controller inside src/main/java/com/example/urlshortener/.
- Inside the controller folder, create UrlController.java and paste this code:
package com.example.urlshortener.controller;
import com.example.urlshortener.dto.ShortenRequest;
import com.example.urlshortener.dto.ShortenResponse;
import com.example.urlshortener.model.ShortenedUrl;
import com.example.urlshortener.service.UrlService;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
public class UrlController {
private final UrlService urlService;
public UrlController(UrlService urlService) {
this.urlService = urlService;
}
@PostMapping("/api/shorten")
public ResponseEntity<ShortenResponse> shortenUrl(@Valid @RequestBody ShortenRequest request) {
ShortenedUrl shortened = urlService.shortenUrl(request.getUrl(), request.getTtlSeconds());
String shortUrl = "http://localhost:8080/" + shortened.getShortCode();
return ResponseEntity.status(HttpStatus.CREATED)
.body(new ShortenResponse(shortened.getShortCode(), shortUrl));
}
}
What does this endpoint do?
The @PostMapping("/api/shorten") annotation maps POST requests to this method. It reads the JSON body into a ShortenRequest object, calls the service, and returns a 201 Created response with the short code and full short URL.
- Now add the redirect endpoint. Place this method inside the UrlController class, before the closing }:
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
ShortenedUrl shortenedUrl = urlService.getAndTrackClick(shortCode);
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.LOCATION, shortenedUrl.getOriginalUrl());
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}
How does the redirect work?
When a browser hits GET /{shortCode}, Spring calls this method. It looks up the original URL, sets it as the Location header, and returns HTTP 302 Found. The browser automatically follows the redirect to the original destination.
- Save UrlController.java.
Test the full flow
Every layer is in place. Time to see the complete shorten-then-redirect cycle work end to end.
- Start the app by running:
mvnw.cmd spring-boot:run
Wait until you see "Started UrlshortenerApplication" in the terminal.
Before you send your first request, what do you think the response will contain? A short code? An error? Take a guess.
- Open a second terminal and shorten a URL by running this command:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\": \"https://www.google.com\"}"
You should see a JSON response like this:
{"shortCode":"Ab3xK9mZ","shortUrl":"http://localhost:8080/Ab3xK9mZ"}
What should I see?
Your short code will be different since it is randomly generated. The key thing is you got a 201 response with both a shortCode and a full shortUrl you can visit.
- Copy the shortUrl value from your response and paste it into your browser's address bar.
Your browser should redirect you to Google. That is your URL shortener working end to end.
Not getting redirected?
Make sure the app is still running in the first terminal. If you stopped it, the short code is gone because H2 is in-memory.
If curl returns an error about connecting to localhost, confirm the app started on port 8080. Look for "Tomcat initialized with port 8080" in the startup logs.
On Windows, if curl gives a JSON parse error, try using double quotes around the data with escaped inner quotes as shown in the command above.
Still stuck? Help me debug my URL shortener POST or redirect
✔️ Awesome, I've got everything!
Great. Make sure you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Your project now has multiple files across several packages. Scroll up and confirm each file matches what you pasted:
- ShortenedUrl.java in model/ with fields, constructors, and getters/setters.
- UrlRepository.java in repository/ with the findByShortCode method.
- UrlNotFoundException.java and UrlExpiredException.java in exception/.
- ShortenRequest.java and ShortenResponse.java in dto/.
- UrlService.java in service/ with shortenUrl, getAndTrackClick, getStats, and generateShortCode methods.
- UrlController.java in controller/ with the POST and GET endpoints.
Your URL shortener works. You can shorten any URL and get redirected. There is one problem though. Restart the app and try visiting that same short code again. It is gone. Next up, you will add a real database that survives restarts.
Persist Data with PostgreSQL in Docker
Your URL shortener works. You can shorten a link, visit the short code, and get redirected. But there is a hidden problem lurking underneath.
Right now your app stores everything in an H2 in-memory database. "In-memory" means the data lives only as long as the app is running. The moment you stop and restart, every shortened URL vanishes.
In this step, get ready to:
- Experience the data-loss problem first-hand by restarting your app.
- Launch a PostgreSQL database inside a Docker container.
- Switch your Spring Boot app from H2 to PostgreSQL so data survives restarts.
See the problem: data disappears on restart
Before you fix anything, you need to see the problem with your own eyes.
- Make sure your app is running. If you stopped it after Step 2, start it again by running:
- Shorten a URL so you have fresh data to test with. Run this in a second terminal:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\": \"https://spring.io\"}"
- Note the shortCode value in the response. You will need it in a moment.
- Now stop the app by pressing Ctrl+C in the terminal where it is running.
- Restart the app by running:
mvnw.cmd spring-boot:run
Before you test your short code, predict: do you think visiting it will still redirect you to spring.io, or will it fail?
- Try visiting your short code in a browser or with curl:
curl -i http://localhost:8080/[[SHORTCODE="your short code from earlier"]]
You get an error. The short code is gone. H2 wiped the entire database when the app stopped.
Why did the data disappear?
H2 in "in-memory" mode stores data in your application's RAM. When the Java process shuts down, that RAM is released and your data disappears with it. Every restart gives you a completely blank database.
A production app needs a database that lives independently of the application process. That is exactly what PostgreSQL provides.
Start PostgreSQL in Docker
Instead of installing PostgreSQL directly on your machine, you will run it inside a Docker container. One command gives you a fully configured database server.
- Stop your Spring Boot app with Ctrl+C if it is still running.
- Start the PostgreSQL container by running this command:
docker run --name urlshortener-db -e POSTGRES_USER=urluser -e POSTGRES_PASSWORD=urlpass -e POSTGRES_DB=urlshortener -p 5432:5432 -d postgres:16
What does this command do?
- --name urlshortener-db gives your container a memorable name so you can stop or remove it later.
- -e POSTGRES_USER=urluser sets the database username.
- -e POSTGRES_DB=urlshortener creates a database named urlshortener on first startup.
- -p 5432:5432 maps the container's internal port to your machine so your app can connect on localhost:5432.
- -d postgres:16 runs PostgreSQL 16 in the background.
- Confirm the container is running by executing:
docker ps
You should see a row showing urlshortener-db with status Up and port 0.0.0.0:5432->5432/tcp.
Don't see the container running?
- Make sure Docker Desktop is running. Check your system tray (Windows) or menu bar (macOS) for the Docker whale icon.
- If you see a "port already in use" error, another process is using port 5432. Run docker rm -f urlshortener-db and try the docker run command again.
Need more help? Troubleshoot my Docker container
Switch Spring Boot to PostgreSQL
Your database is ready. Now you need to tell Spring Boot to connect to PostgreSQL instead of H2. This involves two changes: updating your dependencies and adding connection settings.
- In VS Code, open pom.xml from the root of your project.
- Find the H2 dependency block:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
- Delete those 4 lines entirely and replace them with the PostgreSQL driver and the validation starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
What do these dependencies do?
- postgresql is the JDBC driver that lets Java talk to PostgreSQL databases.
- spring-boot-starter-validation enables the @NotBlank and @URL annotations you already have on your request DTO. Without it, those annotations are silently ignored at runtime.
- Save pom.xml.
- Now create the connection settings file. In the src/main/resources folder, open (or create) a file called application.properties.
- Paste the following configuration into application.properties:
# PostgreSQL connection settings
spring.datasource.url=jdbc:postgresql://localhost:5432/urlshortener
spring.datasource.username=urluser
spring.datasource.password=urlpass
spring.datasource.driver-class-name=org.postgresql.Driver
# Tell JPA to auto-create/update tables from your entity classes
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
What do these properties do?
- spring.datasource.url points Spring Boot at your Docker PostgreSQL instance on localhost:5432.
- spring.jpa.hibernate.ddl-auto=update tells Spring Data JPA to automatically create or modify tables to match your entity classes. You never write SQL by hand.
- The dialect property tells Hibernate to generate PostgreSQL-specific SQL.
- Save application.properties.
- Start your app again to confirm it connects to PostgreSQL:
mvnw.cmd spring-boot:run
You should see Started UrlshortenerApplication in the terminal with no database connection errors.
Seeing a connection refused error?
- Run docker ps and confirm the urlshortener-db container is still running. If it is not listed, start it again with docker start urlshortener-db.
- Double-check that the username, password, and database name in application.properties match exactly what you passed to docker run (urluser, urlpass, urlshortener).
Still stuck? Help me fix my PostgreSQL connection
Verify persistence survives restarts
The real test: can your data survive an app restart now that PostgreSQL is in charge?
- Shorten a new URL by running:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\": \"https://docs.spring.io\"}"
- Copy the shortCode from the response. You will test it after a restart.
- Stop the Spring Boot app with Ctrl+C.
- Restart it:
mvnw.cmd spring-boot:run
Predict: will your short code still work this time, or will you see the same failure as before?
- Test your short code after the restart:
curl -i http://localhost:8080/[[SHORTCODE="your short code from earlier"]]
You see an HTTP 302 redirect with a Location header pointing to https://docs.spring.io. Your data survived the restart.
Why does it work now?
PostgreSQL runs in its own Docker container, completely independent of your Spring Boot process. When you stop and restart your Java app, the database keeps running and retains all its data. This is how production applications work.
✔️ Awesome, I've got everything!
Great. Double check you have saved both pom.xml and application.properties.
ⓧ I'd like to double check the full code
Confirm these pieces are in place:
- In pom.xml: the H2 dependency is gone. You have spring-boot-starter-validation and postgresql (runtime scope) in its place.
- application.properties contains all 6 property lines (datasource URL, username, password, driver, ddl-auto, and dialect).
- The Docker container urlshortener-db is running (docker ps shows it).
Here is the complete application.properties for reference:
spring.datasource.url=jdbc:postgresql://localhost:5432/urlshortener
spring.datasource.username=urluser
spring.datasource.password=urlpass
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
Your data is safe. Next up, you will add proper input validation and error handling so your API returns clean error messages instead of raw stack traces.
Add Validation and Error Handling
Your API handles the happy path well. Shorten a valid URL, get a short code, visit it, get redirected.
But what happens when someone sends garbage? Try shortening an empty string or visiting a short code that does not exist. Right now the app crashes with a raw 500 error and leaked internal details. Production APIs need clear error messages and input validation, not stack traces.
In this step, get ready to:
- See how the API handles invalid input and missing short codes without a custom error handler.
- Build a global exception handler that returns consistent JSON error responses.
- Test that bad requests return clean 400 and 404 errors instead of leaked internals.
See how the API handles bad input today
Your controller already uses @Valid on the POST endpoint. Your ShortenRequest DTO has @NotBlank and @URL annotations. But without a custom exception handler, Spring Boot returns its own default error format when validation fails or an exception is thrown.
- Make sure your app is running. If it is not, start it by running:
mvnw.cmd spring-boot:run
Before you send invalid input, predict what you think will happen. Will the API return a clean JSON error, or something messier?
- Open a new terminal and test invalid input by running this curl command:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{}"
You get back Spring Boot's default error response. It includes a timestamp, status, error, and path fields in a format you did not design. The structure is inconsistent with what a clean API should return.
- Now try visiting a short code that does not exist:
curl http://localhost:8080/nonexistent
You see a 500 Internal Server Error. Your UrlNotFoundException is thrown but nothing catches it and translates it into a proper 404. This is the gap you will close now.
Why does this matter?
API consumers (frontend apps, mobile clients, other services) rely on predictable error formats to display meaningful messages to users. A raw stack trace forces every consumer to write special handling code.
A consistent JSON structure like {"error": "...", "status": 404} is immediately usable by any client.
Create the error response DTO and exception handler
You need two things: a simple DTO that defines your error response shape, and a global exception handler that catches exceptions before Spring's default handler does.
- Create a new file at src/main/java/com/example/urlshortener/dto/ErrorResponse.java and paste this code:
package com.example.urlshortener.dto;
public class ErrorResponse {
private String error;
private int status;
public ErrorResponse(String error, int status) {
this.error = error;
this.status = status;
}
public String getError() { return error; }
public void setError(String error) { this.error = error; }
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
}
What does this code do?
- ErrorResponse is a plain Java class with two fields: error (a human-readable message) and status (the HTTP status code as an integer).
- Spring automatically serializes this object to JSON when returned from a controller or exception handler.
- Every error your API returns will use this same two-field structure, making it predictable for consumers.
- Save ErrorResponse.java.
Now for the global exception handler. This class intercepts exceptions thrown anywhere in your controllers and converts them into your clean ErrorResponse format.
- Create a new file at src/main/java/com/example/urlshortener/exception/GlobalExceptionHandler.java and paste this code:
package com.example.urlshortener.exception;
import com.example.urlshortener.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UrlNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(UrlNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getMessage(), 404));
}
@ExceptionHandler(UrlExpiredException.class)
public ResponseEntity<ErrorResponse> handleExpired(UrlExpiredException ex) {
return ResponseEntity.status(HttpStatus.GONE)
.body(new ErrorResponse(ex.getMessage(), 410));
}
}
What does this code do?
- @RestControllerAdvice tells Spring this class handles exceptions globally across all controllers.
- Each @ExceptionHandler method catches a specific exception type and returns a ResponseEntity with the correct HTTP status and your clean ErrorResponse body.
- handleNotFound catches UrlNotFoundException and returns a 404. handleExpired catches UrlExpiredException and returns a 410 Gone.
- Save GlobalExceptionHandler.java.
- Stop the running app with Ctrl+C and restart it:
mvnw.cmd spring-boot:run
- Test the nonexistent short code again:
curl http://localhost:8080/nonexistent
You now see a clean JSON response: {"error":"Short URL not found: nonexistent","status":404}. That is your custom ErrorResponse format in action.
Still seeing a 500 or default error page?
- Make sure GlobalExceptionHandler.java is in the com.example.urlshortener.exception package. Spring's component scan must find it.
- Verify the @RestControllerAdvice annotation is on the class. Without it, Spring will not register the handler.
- Confirm you restarted the app after saving the file.
Why is my exception handler not catching UrlNotFoundException?
The 404 is handled. Now you need to handle validation errors (invalid input) and a generic catch-all for unexpected failures.
- In GlobalExceptionHandler.java, find the closing brace of the handleExpired method. Add these two methods below it, before the final closing brace of the class:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.findFirst()
.orElse("Validation failed");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(message, 400));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Internal server error", 500));
}
What do these handlers do?
- handleValidation catches MethodArgumentNotValidException (thrown when @Valid fails on a request body). It extracts the first field error and returns a 400 Bad Request with a clear message.
- handleGeneric is the safety net. It catches any unhandled exception and returns a generic 500 without leaking stack traces or internal details.
- Save GlobalExceptionHandler.java.
Test clean error handling end to end
- Stop the app with Ctrl+C and restart it:
mvnw.cmd spring-boot:run
Before you test, predict: what HTTP status and message do you expect when you POST an empty JSON body to /api/shorten?
- Test validation by sending an empty body:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{}"
You see a clean 400 response with your custom format. The error field tells the caller exactly which field failed and why.
- Test with an invalid URL string:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\": \"not-a-url\"}"
You get another clean 400 with a message indicating the URL format is invalid.
- Confirm the 404 still works by running:
curl http://localhost:8080/doesnotexist
You see {"error":"Short URL not found: doesnotexist","status":404}. Every error path now returns a consistent, predictable JSON structure.
Seeing a 405 Method Not Allowed instead of 400?
- Make sure your curl command includes -X POST and the Content-Type: application/json header. Without the header, Spring may reject the request before validation runs.
- Verify the -d flag contains valid JSON (even if the fields are empty or wrong). Malformed JSON triggers a different error path.
Why am I getting unexpected HTTP status codes from my API?
✔️ Awesome, I've got everything!
Great. Double check you have saved both ErrorResponse.java and GlobalExceptionHandler.java.
ⓧ I'd like to double check the full code
Your GlobalExceptionHandler.java was built from two chunks above. Scroll up and confirm you have all four handler methods in order:
- handleNotFound (returns 404).
- handleExpired (returns 410).
- handleValidation (returns 400).
- handleGeneric (returns 500).
Here is the complete ErrorResponse.java for reference:
package com.example.urlshortener.dto;
public class ErrorResponse {
private String error;
private int status;
public ErrorResponse(String error, int status) {
this.error = error;
this.status = status;
}
public String getError() { return error; }
public void setError(String error) { this.error = error; }
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
}
Your API now handles errors like a production service. Invalid input gets a 400 with a clear field-level message. Missing short codes get a 404 with the code in the error. No more leaked stack traces. Next up, you will add production observability with health checks and metrics endpoints.
Add Production Observability with Actuator
Your API shortens URLs, redirects visitors, validates input, and handles errors cleanly. But you have zero visibility into whether it is healthy or how it is performing.
In production, monitoring software pings your app to check if it is alive. Spring Boot Actuator gives you ready-made endpoints for health checks and performance metrics with just a few lines of configuration.
In this step, get ready to:
- Expose Actuator health and metrics endpoints.
- Create a custom health indicator that verifies the PostgreSQL connection.
- Confirm request metrics appear after sending traffic.
Configure Actuator endpoints
The spring-boot-starter-actuator dependency is already in your pom.xml from when you scaffolded the project. However, Spring Boot hides most Actuator endpoints by default for security reasons.
- In VS Code, open src/main/resources/application.properties.
- Add these two lines at the bottom of the file to expose the health, metrics, and info endpoints:
management.endpoints.web.exposure.include=health,metrics,info
management.endpoint.health.show-details=always
What do these properties do?
The first line tells Spring Boot which Actuator endpoints to make accessible over HTTP. By default, only /actuator/health is exposed with minimal output.
The second line tells the health endpoint to reveal per-component details (database status, disk space) instead of just the top-level UP or DOWN status.
- Save application.properties.
- Stop the running app (Ctrl+C in the terminal) and restart it by running:
mvnw.cmd spring-boot:run
- Once you see "Started UrlshortenerApplication" in the terminal, test the health endpoint by opening a new terminal and running:
curl http://localhost:8080/actuator/health
You should see a JSON response with "status":"UP" and a components section showing database and disk space details.
Seeing a 404 or empty response?
- Confirm the two new lines are at the bottom of application.properties after the existing PostgreSQL properties. Make sure there are no typos in the property names.
- Make sure you restarted the application after saving. Spring Boot only reads properties at startup.
Help me debug my Actuator health endpoint returning 404.
The built-in health checks confirm Spring Boot can reach your database. Now you will add a custom health indicator that performs its own explicit connection test.
Add a custom database health indicator
Spring Boot auto-configures a basic db health indicator when JPA is present. A custom HealthIndicator lets you control the check logic and attach your own detail messages so operators can diagnose issues from the endpoint alone.
- In VS Code's Explorer sidebar, right-click the com.example.urlshortener package and create a new folder named health.
- Inside the health folder, create a new file named DatabaseHealthIndicator.java.
- Paste the following code into DatabaseHealthIndicator.java:
package com.example.urlshortener.health;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(2)) {
return Health.up().withDetail("database", "PostgreSQL is reachable").build();
}
} catch (Exception e) {
return Health.down().withDetail("database", "Cannot reach PostgreSQL: " + e.getMessage()).build();
}
return Health.down().withDetail("database", "Connection invalid").build();
}
}
What does this code do?
- The @Component annotation registers this class as a Spring bean. Spring Boot automatically discovers it and adds it to the /actuator/health response.
- The health() method opens a JDBC connection and calls isValid(2) with a 2-second timeout. If the connection works, it reports UP with a custom detail message.
- If the connection fails, it reports DOWN and includes the error message so you can diagnose the problem from the health endpoint alone.
- Save DatabaseHealthIndicator.java.
- Stop the running app (Ctrl+C) and restart it:
mvnw.cmd spring-boot:run
Before you check the health endpoint again, what do you expect to see differently from last time?
- Once the app starts, run this in your second terminal:
curl http://localhost:8080/actuator/health
You should now see a databaseHealthIndicator component in the response with "status":"UP" and the detail "database":"PostgreSQL is reachable".
Health indicator not appearing?
- Verify the file is inside src/main/java/com/example/urlshortener/health/. If the package path is wrong, Spring Boot will not detect the component.
- Make sure the Docker container is still running. Run docker ps and confirm urlshortener-db is listed. If it stopped, start it with docker start urlshortener-db.
My custom health indicator is not showing up in the /actuator/health response.
✔️ Awesome, I've got everything!
Great. Double check you have saved both application.properties and DatabaseHealthIndicator.java.
ⓧ I'd like to double check the full code
Here is the complete application.properties file:
spring.datasource.url=jdbc:postgresql://localhost:5432/urlshortener
spring.datasource.username=urluser
spring.datasource.password=urlpass
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
management.endpoints.web.exposure.include=health,metrics,info
management.endpoint.health.show-details=always
And here is the complete DatabaseHealthIndicator.java:
package com.example.urlshortener.health;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(2)) {
return Health.up().withDetail("database", "PostgreSQL is reachable").build();
}
} catch (Exception e) {
return Health.down().withDetail("database", "Cannot reach PostgreSQL: " + e.getMessage()).build();
}
return Health.down().withDetail("database", "Connection invalid").build();
}
}
Verify metrics after traffic
Actuator also tracks performance metrics for every HTTP request your API handles. To see real data, you need to generate some traffic first.
- Send a few requests to your shorten endpoint by running this command 3 to 5 times:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"https://www.example.com\"}"
- Now check the request metrics by running:
curl http://localhost:8080/actuator/metrics/http.server.requests
What do you expect a production monitoring system would need from this response?
You should see a JSON response containing measurements for count, total time, and max time. The count reflects all the requests you just sent to your API.
Why do these metrics matter?
In production, these metrics feed into dashboards like Grafana or Datadog. Teams set alerts when response times spike or error rates climb.
Having these endpoints ready out of the box shows employers you understand operational concerns, not just feature development.
Metrics endpoint returning 404?
- Double check that management.endpoints.web.exposure.include includes metrics in the comma-separated list.
- Make sure you restarted the app after editing application.properties.
The /actuator/metrics/http.server.requests endpoint is not working for me.
Your URL shortener is complete. It shortens URLs, redirects visitors, validates input, handles errors cleanly, persists data across restarts, and reports its own health and performance. Next up is the Secret Mission where you can add link expiration and click tracking.
Secret mission
Add Link Expiration and Click Tracking
Real URL shorteners expire links and track usage. Can you wire up a stats endpoint that reports click counts and expiration status for any short code?
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, so there are no ongoing costs.
Resources you used:
- A PostgreSQL Docker container named urlshortener-db running on port 5432.
- The urlshortener project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your API.
- Leave Docker Desktop running and the urlshortener-db container active.
- Your shortened URLs and click data remain in PostgreSQL, ready for your next session.
Pause - I'll come back to this later
Stop the Docker container to free up memory while preserving your data. You can restart it any time without losing your shortened URLs.
- Stop the PostgreSQL container by running:
docker stop urlshortener-db
- When you are ready to resume, start it again by running:
docker start urlshortener-db
- Your data is preserved inside the container. Run mvnw.cmd spring-boot:run from the project folder to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your shortened URL data and the project files.
- Remove the PostgreSQL container and all its data by running:
docker rm -f urlshortener-db
- Delete the project folder from your Desktop.
macOS
rm -rf ~/Desktop/urlshortener
Windows
rmdir /s /q %USERPROFILE%\Desktop\urlshortener
- Verify the container is gone by running:
docker ps -a
- You should no longer see urlshortener-db in the output.
Nice Work!
Nice Work!
You just built a production-grade URL shortener REST API from scratch using Spring Boot and PostgreSQL. You can shorten any URL, redirect visitors with a single GET request, and monitor your application's health in real time.
You've learned how to:
- Build a REST API with Spring Boot using the layered architecture pattern of controllers, services, and repositories.
- Persist data in a PostgreSQL database running inside a Docker container that survives application restarts.
- Add production observability with Spring Boot Actuator health checks, custom health indicators, and request metrics.
- Secret Mission: Added time-based link expiration with automatic TTL and a click-count tracking endpoint.
Ready to quiz yourself?