URL Shortener with Spring Boot and Redis
Build a containerized URL shortener with PostgreSQL and Redis caching.
Introduction
30 Second Summary
Every time you click a shortened link, a service instantly looks up the original URL and redirects you. Behind that simple redirect is a system handling millions of lookups, and the secret to its speed is a cache that prevents the database from being overwhelmed.
In this project, you will build a URL shortener API with Spring Boot, back it with PostgreSQL for persistence and Redis for caching, then containerize the entire stack with Docker Compose.
What You'll Build
A running URL shortener where you paste in a long link, get back a short code, and watch the redirect happen in your browser while application logs reveal whether each request hit the cache or the database.
By the end of this project, you'll have:
- A live URL shortener API where you POST a long link, receive a short code, and visit that code to get redirected to the original URL.
- Real-time logs proving the cache-in-front-of-database pattern is working. You will see "Cache MISS" on the first request and silence on repeated hits as Redis absorbs the load.
- A multi-container Docker setup (app + PostgreSQL + Redis) you start with one command and tear down with another.
- Secret Mission: Add click analytics that track how many times each short URL has been visited, powered by Redis atomic counters.
Are there any prerequisites?
This project assumes basic Java knowledge and familiarity with Spring Boot fundamentals (controllers, services, dependency injection). You do not need prior experience with Docker or Redis.
Before We Start
This project uses Spring Boot with Redis and PostgreSQL running in Docker containers.
Before you can write any application code, you need the right Java version, Docker on your machine, and a generated project skeleton. In this step you will install JDK 21, set up Docker Desktop, and generate a Spring Boot project from Spring Initializr.
In this step, get ready to:
- Install JDK 21 and Docker Desktop.
- Generate a Spring Boot project with the required dependencies.
- Verify the project starts on port 8080.
Install JDK 21 and Docker Desktop
Spring Boot 3.5 requires Java SDK version 17 or higher. This project pins to JDK 21, the recommended long-term support version.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check your current Java version by running:
java -version
✔️ I see version 21 or higher
Your JDK is ready. Skip ahead to the Docker Desktop check below.
ⓧ I see an older version
You have Java installed but it is an older version. You need JDK 21 for this project.
macOS
- Install JDK 21 via Homebrew by running:
brew install openjdk@21
- Create the system symlink so macOS can find the new JDK:
sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk
What does the symlink do?
Homebrew installs JDK 21 in a keg-only location. The symlink registers it with macOS so that java -version resolves to version 21 instead of your old system Java.
- Close and reopen your terminal, then verify by running:
java -version
You should now see output containing openjdk version "21.
Windows
- Download the JDK 21 installer from the Adoptium website at https://adoptium.net.
- Run the installer and check the option to set JAVA_HOME automatically.
- Close and reopen your terminal, then verify by running:
java -version
You should now see output containing version 21.
ⓧ Command not found
Java is not installed on your machine. Install JDK 21 now.
macOS
- Install JDK 21 via Homebrew by running:
brew install openjdk@21
- Create the system symlink so macOS can find the JDK:
sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk
- Close and reopen your terminal, then verify by running:
java -version
You should now see output containing openjdk version "21.
Windows
- Download the JDK 21 installer from the Adoptium website at https://adoptium.net.
- Run the installer and check the option to set JAVA_HOME automatically.
- Close and reopen your terminal, then verify by running:
java -version
You should now see output containing version 21.
Next, check whether Docker Desktop is installed.
- Check your Docker installation by running:
docker --version
✔️ I see a version number
Docker is installed and ready. Move on to generating your Spring Boot project.
ⓧ Command not found
Docker Desktop is not installed. Install it now.
- Download Docker Desktop from the official install page at https://docs.docker.com/desktop/setup/install/mac-install/ (macOS) or https://docs.docker.com/desktop/setup/install/windows-install/ (Windows).
- Open the downloaded file and follow the installer instructions.
- Launch Docker Desktop from your Applications folder (macOS) or Start menu (Windows).
- Wait for Docker Desktop to finish starting (the whale icon in your menu bar or system tray stops animating).
- Verify by running:
docker --version
You should see a version number printed in the terminal.
Docker command still not found?
Make sure Docker Desktop is fully launched and running (look for the whale icon in your menu bar or system tray). If you just installed it, you may need to close and reopen your terminal.
Help me troubleshoot Docker Desktop not being found in my terminal.
Generate a Spring Boot project
Spring Initializr is a web tool that scaffolds a complete Spring Boot project with your chosen dependencies. You will use it to create the project skeleton.
- Open https://start.spring.io in your browser.
- Set Project to Maven.
- Set Language to Java.
- Set Spring Boot to the latest 3.5.x version available in the dropdown.
- Under Project Metadata, set Group to com.example.
- Set Artifact to url-shortener.
- Click Add Dependencies and search for each of these, adding them one at a time:
- Spring Web.
- Spring Data JPA.
- PostgreSQL Driver.
- Spring Data Redis (Access+Driver).
- Spring cache abstraction (search for "cache").
- Click Generate to download the project as a ZIP file.
- Unzip the downloaded url-shortener.zip file to a location you can find easily (such as your Desktop).
What did Spring Initializr create?
The generated project includes a pom.xml with all your dependencies, a main application class at src/main/java/com/example/urlshortener/UrlShortenerApplication.java, and an application.properties file.
It also includes a Maven wrapper (mvnw) so you can build and run the project without installing Maven globally.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type IntelliJ IDEA and press Enter to open it.
- Click File in the top menu bar.
- Click Open.
- Select the unzipped url-shortener folder and click OK.
IntelliJ may take a minute to index the project and download dependencies. Wait for the progress bar in the bottom status bar to finish before continuing.
Verify the project runs
Time to start the application and confirm everything is wired up correctly.
- Open the terminal inside IntelliJ by clicking View, then Tool Windows, then Terminal.
The project has a PostgreSQL driver dependency but no database running yet. What do you think will happen when you try to start the app?
- Try starting the application by running:
./mvnw spring-boot:run
The app fails to start. You see an error about the datasource in the terminal output.
Why does it fail?
Spring Boot auto-detects the PostgreSQL driver on your classpath and tries to configure a database connection. Since no PostgreSQL server is running, it cannot connect and throws an error.
You will add a real database in a later step. For now, you will temporarily exclude the datasource auto-configuration so the app can start.
- Stop the failed process by pressing Ctrl+C in the terminal.
- Open src/main/resources/application.properties in IntelliJ.
- Add this line below the existing spring.application.name line:
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
What does this line do?
This tells Spring Boot to skip configuring a database connection on startup. You will remove this line later when you add PostgreSQL in Docker.
- Save the file.
- Run the application again:
./mvnw spring-boot:run
This time, what do you expect to see? You excluded the problematic datasource configuration, so the app should have nothing blocking it.
You should see a line containing Started UrlShortenerApplication in the terminal output.
- Open your browser and visit http://localhost:8080.
You see a "Whitelabel Error Page" in the browser. This confirms Spring Boot is running and listening for requests.
Why a Whitelabel Error Page?
You have not added any endpoints yet, so Spring Boot has nothing to serve at the root path. The error page proves the server is running. You will add real endpoints in the next step.
✔️ Awesome, I've got everything!
Great. Make sure you have saved application.properties and that the app is still running in the terminal.
ⓧ I'd like to double check the full code
Your application.properties file should look exactly like this:
spring.application.name=url-shortener
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
Still seeing an error on startup?
Make sure the exclusion line in application.properties is spelled exactly as shown, with no extra spaces or line breaks in the middle of the class name.
If IntelliJ is still indexing (progress bar at the bottom), wait for it to finish, then try the run command again.
Help me debug my Spring Boot app failing to start.
Your development environment is ready. Next up, you will build the URL shortener API with an in-memory store and see it handle real requests.
Build the URL Shortener API
Your development environment is ready. Now you need a working API that actually shortens URLs.
The fastest way to get there is to store everything in a Java HashMap in memory. You will have a functional shortener in minutes and can test it immediately. This approach has a fatal flaw, but you need to experience it firsthand.
In this step, get ready to:
- Create a service that generates short codes and stores URL mappings.
- Create a REST controller with POST and GET endpoints.
- Discover the critical limitation of in-memory storage.
Create the service and controller
A Spring Boot REST API follows a layered pattern. The controller handles HTTP requests and responses. The service contains the business logic. You will create both now.
- In IntelliJ IDEA, right-click the com.example.urlshortener package in the src/main/java directory.
- Select New then Package.
- Name it service and press Enter.
- Right-click the new service package, select New then Java Class.
- Name it UrlService and press Enter.
- Replace the entire file content with the following code:
package com.example.urlshortener.service;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class UrlService {
// In-memory store: maps short codes to original URLs
private final ConcurrentHashMap<String, String> urlStore = new ConcurrentHashMap<>();
// Store a URL and return its generated short code
public String createShortUrl(String originalUrl) {
String shortCode = generateShortCode();
urlStore.put(shortCode, originalUrl);
return shortCode;
}
// Look up the original URL by short code
public String getOriginalUrl(String shortCode) {
return urlStore.get(shortCode);
}
// Generate a random 7-character code from a UUID
private String generateShortCode() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 7);
}
}
What does this code do?
The @Service annotation tells Spring Boot to manage this class as a bean, making it available for injection into other components.
A ConcurrentHashMap stores short-code-to-URL mappings in memory. The generateShortCode() method takes a UUID, strips the dashes, and grabs the first 7 characters as the short code.
- Save UrlService.java.
Now create the controller that exposes the HTTP endpoints. The POST endpoint accepts a URL and returns a short code. The GET endpoint receives a short code and redirects the browser.
- Right-click the com.example.urlshortener package again, select New then Package.
- Name it controller and press Enter.
- Right-click the controller package, select New then Java Class.
- Name it UrlController and press Enter.
- Replace the file content with the following code for the POST endpoint:
package com.example.urlshortener.controller;
import com.example.urlshortener.service.UrlService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class UrlController {
private final UrlService urlService;
public UrlController(UrlService urlService) {
this.urlService = urlService;
}
// Accept a JSON body with a "url" field and return a short code
@PostMapping("/api/shorten")
public ResponseEntity<Map<String, String>> shortenUrl(@RequestBody Map<String, String> request) {
String originalUrl = request.get("url");
if (originalUrl == null || originalUrl.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "URL is required"));
}
String shortCode = urlService.createShortUrl(originalUrl);
return ResponseEntity.ok(Map.of(
"shortCode", shortCode,
"shortUrl", "http://localhost:8080/" + shortCode
));
}
}
What does this code do?
The @RestController annotation tells Spring this class handles HTTP requests and returns JSON responses directly.
The @PostMapping("/api/shorten") maps POST requests to this method. It reads the JSON body using @RequestBody, extracts the url field, generates a short code through the service, and returns it as JSON.
- Save UrlController.java.
Before adding the redirect endpoint, test that the POST endpoint works on its own.
- Start the app by running this command in your terminal from the project root:
./mvnw spring-boot:run
- Open a second terminal tab and test the POST endpoint by running:
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 containing a shortCode and a shortUrl field. The short code is a random 7-character string.
Not getting a JSON response?
- Make sure you saved both UrlService.java and UrlController.java before running.
- Check that the package names match exactly: com.example.urlshortener.service and com.example.urlshortener.controller.
- If you see a compilation error, make sure the import statement for UrlService in the controller matches the service's package.
Help me debug why my POST endpoint isn't returning JSON.
Now add the redirect endpoint. When someone visits the short URL, the API looks up the original URL and sends an HTTP 302 redirect.
- Stop the running app with Ctrl+C in the first terminal.
- In UrlController.java, add these imports at the top of the file, below the existing imports:
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.net.URI;
- Add the redirect method inside the UrlController class, below the shortenUrl method and before the closing }:
// Redirect to the original URL using HTTP 302
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
String originalUrl = urlService.getOriginalUrl(shortCode);
if (originalUrl == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(originalUrl))
.build();
}
What does the redirect method do?
The @GetMapping("/{shortCode}") captures the path segment as a variable. If the short code exists in the store, it returns an HTTP 302 response with a Location header pointing to the original URL. The browser follows this header automatically.
If the code does not exist, it returns a 404 Not Found.
- Save UrlController.java.
- Restart the app by running:
./mvnw spring-boot:run
- In your second terminal, create a new short URL by running:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d '{"url":"https://www.google.com"}'
Copy the shortCode value from the response. You will use it to test the redirect.
- Test the redirect by running this command, replacing the short code with your actual value:
curl -i http://localhost:8080/[[SHORTCODE="your-short-code-from-the-response"]]
You should see an HTTP 302 status with a Location: https://www.google.com header. Your URL shortener is working.
Seeing a 404 instead of 302?
- Make sure you copied the exact short code from the POST response. It is case-sensitive.
- Check that you restarted the app after adding the GET endpoint. Short codes from a previous run are lost because the data is only in memory.
Help me troubleshoot my redirect endpoint.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved and the app is still running for the next substep.
ⓧ I'd like to double check the full code
Your UrlController.java should contain these parts in order:
- Package declaration and all imports (UrlService, HttpStatus, ResponseEntity, GetMapping, PathVariable, PostMapping, RequestBody, RestController, URI, Map).
- Class declaration with @RestController, a urlService field, and constructor injection.
- The shortenUrl POST method that validates the URL and returns JSON with shortCode and shortUrl.
- The redirect GET method that looks up the short code and returns a 302 with the Location header.
Your UrlService.java should have a ConcurrentHashMap field, a createShortUrl method, a getOriginalUrl method, and a private generateShortCode method.
Test it and discover the problem
Your shortener works. You can create codes and follow redirects. But what happens if the app restarts?
- Note the short code from your last successful test. You will need it in a moment.
- Stop the app by pressing Ctrl+C in the terminal running the Spring Boot process.
- Start it again by running:
./mvnw spring-boot:run
Before you test, predict what will happen when you try the same short code again. Will it still redirect to Google?
- Try the same short code you used before:
curl -i http://localhost:8080/[[SHORTCODE="your-short-code-from-earlier"]]
You get a 404 Not Found. Your data is gone. Every URL you created vanished when the app stopped.
Why did the data disappear?
The ConcurrentHashMap lives in the Java process's memory. When you stop the app, that memory is released. When you restart, a fresh empty map is created.
This is the fundamental problem with in-memory storage. It is fast and simple, but your data does not survive restarts. For a URL shortener, this is unacceptable. Every link you create becomes dead the next time you deploy.
You have a working URL shortener, but it forgets everything the moment it restarts. Next, you will fix this by adding a real database with PostgreSQL running in a Docker container.
Add PostgreSQL for Persistence
You proved in the last step that your URL shortener works. But you also saw its fatal flaw: stop the app, restart it, and every short code returns 404.
The data lives only in Java memory. You need a real database. In this step, you will spin up PostgreSQL inside a Docker container using Docker Compose, then rewire your service to persist URLs in the database so they survive restarts.
In this step, get ready to:
- Create a Docker Compose file that runs PostgreSQL in a container.
- Replace the in-memory HashMap with JPA entities and a repository.
- Verify that short URLs survive an app restart.
Create a Docker Compose file with PostgreSQL
Docker Compose lets you define infrastructure services in a YAML file and start them with one command. You will define a PostgreSQL container so the database runs alongside your app without a manual install.
- Create a new file called compose.yaml in the root of your url-shortener project folder (the same level as pom.xml).
- Paste the following configuration into compose.yaml:
services:
db:
image: postgres:17
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: urlshortener
POSTGRES_PASSWORD: urlshortener
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U urlshortener"]
interval: 5s
timeout: 3s
retries: 5
volumes:
db-data:
What does this configuration do?
The db service pulls the official postgres:17 image and creates a database called urlshortener with matching credentials.
The volumes section mounts a named volume (db-data) so your data persists even if you remove the container. The healthcheck uses pg_isready to confirm PostgreSQL is accepting connections before dependent services start.
- Save compose.yaml.
- Start the PostgreSQL container by running this command in your project root:
docker compose up -d
- Verify the container is healthy by running:
docker compose ps
You should see the db container listed with a status of healthy.
Container not starting or showing unhealthy?
- Make sure Docker Desktop is running. The Docker daemon must be active for containers to start.
- Check if port 5432 is already in use by another process. Run lsof -i :5432 (macOS) to see what is using it.
- If you see a permission error, try prefixing the command with sudo.
Why won't my Docker container start?
Replace the in-memory store with JPA
With PostgreSQL running, you can now point your Spring Boot app at a real database. This involves updating the connection config, creating a JPA entity to map your URLs table, and swapping the HashMap for a repository.
- Open src/main/resources/application.properties in IntelliJ IDEA.
- Replace the entire contents with the following database configuration:
spring.application.name=url-shortener
# PostgreSQL
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:5432/urlshortener
spring.datasource.username=urlshortener
spring.datasource.password=urlshortener
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
What changed here?
- The spring.autoconfigure.exclude line is gone. Spring Boot can now auto-configure the datasource.
- The spring.datasource.* properties tell Spring Boot where PostgreSQL is. The ${DB_HOST:localhost} syntax defaults to localhost now but can be overridden later when the app runs in Docker.
- The ddl-auto=update tells Hibernate to auto-create or update database tables based on your entity classes.
- The show-sql=true prints every SQL statement Hibernate executes, so you can see database activity in the logs.
- Save application.properties.
- Now create the JPA entity that maps to a urls table. Create a new package model inside com.example.urlshortener, then create a file called Url.java with this class declaration and fields:
package com.example.urlshortener.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "urls")
public class Url implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 10)
private String shortCode;
@Column(nullable = false, length = 2048)
private String originalUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
- Below the fields, add the constructors and getter/setter methods to complete the class:
public Url() {
}
public Url(String shortCode, String originalUrl) {
this.shortCode = shortCode;
this.originalUrl = originalUrl;
this.createdAt = LocalDateTime.now();
}
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; }
}
What does this entity do?
The @Entity and @Table annotations tell Hibernate this class maps to a database table called urls. Each field becomes a column.
The class implements Serializable because Redis caching (added in a later step) requires objects to be serializable. The two-argument constructor sets createdAt automatically so you never forget a timestamp.
- Save Url.java.
- Create a new package repository inside com.example.urlshortener, then create UrlRepository.java with the following interface:
package com.example.urlshortener.repository;
import com.example.urlshortener.model.Url;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UrlRepository extends JpaRepository<Url, Long> {
Optional<Url> findByShortCode(String shortCode);
}
What does this interface do?
By extending JpaRepository, Spring Data JPA automatically provides save(), findAll(), and other CRUD methods. The custom findByShortCode method is derived from its name. Spring generates the SQL query for you.
- Save UrlRepository.java.
- Run your app to confirm it connects to PostgreSQL and creates the table:
./mvnw spring-boot:run
In the terminal output, look for a line starting with Hibernate: create table urls. This confirms Hibernate detected your entity and created the table in PostgreSQL.
- Stop the app with Ctrl+C.
- Now update UrlService.java to use the repository instead of the HashMap. Replace the entire contents of the file with the new class header and constructor:
package com.example.urlshortener.service;
import com.example.urlshortener.model.Url;
import com.example.urlshortener.repository.UrlRepository;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class UrlService {
private final UrlRepository urlRepository;
public UrlService(UrlRepository urlRepository) {
this.urlRepository = urlRepository;
}
- Below the constructor, add the service methods and closing brace:
public Url createShortUrl(String originalUrl) {
String shortCode = generateShortCode();
Url url = new Url(shortCode, originalUrl);
return urlRepository.save(url);
}
public String getOriginalUrl(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.map(Url::getOriginalUrl)
.orElse(null);
}
private String generateShortCode() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 7);
}
}
What changed in the service?
The ConcurrentHashMap is gone. Now createShortUrl returns a Url entity (saved to the database), and getOriginalUrl queries PostgreSQL via the repository.
- Save UrlService.java.
- Finally, update UrlController.java to work with the new return type. Open UrlController.java and find this import line at the top:
import com.example.urlshortener.service.UrlService;
- Add this import directly above it:
import com.example.urlshortener.model.Url;
- Now find the shortenUrl method body. Locate these three lines:
String shortCode = urlService.createShortUrl(originalUrl);
return ResponseEntity.ok(Map.of(
"shortCode", shortCode,
"shortUrl", "http://localhost:8080/" + shortCode
));
- Replace those lines with this updated version that uses the Url entity:
Url url = urlService.createShortUrl(originalUrl);
return ResponseEntity.ok(Map.of(
"shortCode", url.getShortCode(),
"shortUrl", "http://localhost:8080/" + url.getShortCode()
));
- Save UrlController.java.
Seeing compilation errors in IntelliJ?
- Make sure you added the import com.example.urlshortener.model.Url; line at the top of UrlController.java.
- Confirm that Url.java is in the com.example.urlshortener.model package, not in a different location.
- If IntelliJ shows red underlines, try clicking File then Invalidate Caches / Restart.
Help me fix compilation errors in my Spring Boot URL shortener project.
Test persistence across restarts
The whole point of adding PostgreSQL was to survive restarts. Time to prove it works.
- Start the app by running:
./mvnw spring-boot:run
- Create a short URL by running this curl command:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d '{"url":"https://www.google.com"}'
You will see a JSON response with a shortCode and shortUrl. Copy the short code value.
- Verify the redirect works by running (replace the short code with your value):
curl -i http://localhost:8080/[[SHORTCODE="your short code from the response above"]]
You should see a 302 response with Location: https://www.google.com.
- Now stop the app with Ctrl+C.
- Restart it by running:
./mvnw spring-boot:run
Before you test, think about what you expect. Will the same short code still redirect, or will it return 404 like it did with the HashMap?
- Test the same short code again:
curl -i http://localhost:8080/[[SHORTCODE="your short code from the response above"]]
You get 302 with Location: https://www.google.com again. Your data survived the restart.
Notice the SQL logs
Check your terminal output. Every time you hit the redirect endpoint, you see a line like Hibernate: select ... in the logs. Every single redirect triggers a database query.
For a URL shortener where the same link might be visited thousands of times, this means thousands of identical SELECT queries. The next step fixes this with Redis caching.
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files: compose.yaml, application.properties, Url.java, UrlRepository.java, UrlService.java, and UrlController.java.
ⓧ I'd like to double check the full code
Here is each file you should have at this point. Scroll through and compare with yours.
compose.yaml:
services:
db:
image: postgres:17
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: urlshortener
POSTGRES_PASSWORD: urlshortener
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U urlshortener"]
interval: 5s
timeout: 3s
retries: 5
volumes:
db-data:
application.properties:
spring.application.name=url-shortener
# PostgreSQL
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:5432/urlshortener
spring.datasource.username=urlshortener
spring.datasource.password=urlshortener
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
Url.java:
package com.example.urlshortener.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "urls")
public class Url implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 10)
private String shortCode;
@Column(nullable = false, length = 2048)
private String originalUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
public Url() {
}
public Url(String shortCode, String originalUrl) {
this.shortCode = shortCode;
this.originalUrl = originalUrl;
this.createdAt = LocalDateTime.now();
}
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; }
}
UrlRepository.java:
package com.example.urlshortener.repository;
import com.example.urlshortener.model.Url;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UrlRepository extends JpaRepository<Url, Long> {
Optional<Url> findByShortCode(String shortCode);
}
UrlService.java:
package com.example.urlshortener.service;
import com.example.urlshortener.model.Url;
import com.example.urlshortener.repository.UrlRepository;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class UrlService {
private final UrlRepository urlRepository;
public UrlService(UrlRepository urlRepository) {
this.urlRepository = urlRepository;
}
public Url createShortUrl(String originalUrl) {
String shortCode = generateShortCode();
Url url = new Url(shortCode, originalUrl);
return urlRepository.save(url);
}
public String getOriginalUrl(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.map(Url::getOriginalUrl)
.orElse(null);
}
private String generateShortCode() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 7);
}
}
UrlController.java:
package com.example.urlshortener.controller;
import com.example.urlshortener.model.Url;
import com.example.urlshortener.service.UrlService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
import java.util.Map;
@RestController
public class UrlController {
private final UrlService urlService;
public UrlController(UrlService urlService) {
this.urlService = urlService;
}
@PostMapping("/api/shorten")
public ResponseEntity<Map<String, String>> shortenUrl(@RequestBody Map<String, String> request) {
String originalUrl = request.get("url");
if (originalUrl == null || originalUrl.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "URL is required"));
}
Url url = urlService.createShortUrl(originalUrl);
return ResponseEntity.ok(Map.of(
"shortCode", url.getShortCode(),
"shortUrl", "http://localhost:8080/" + url.getShortCode()
));
}
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
String originalUrl = urlService.getOriginalUrl(shortCode);
if (originalUrl == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(originalUrl))
.build();
}
}
Your URL shortener now persists data in PostgreSQL. But every redirect hammers the database with a SELECT query, even for the same URL visited a thousand times. Next up, you will add Redis caching to absorb those repeated lookups and keep your database fast.
Add Redis Caching
Your URL shortener now persists data in PostgreSQL. Restarting the app no longer wipes your short codes.
There is still a performance problem hiding in plain sight. Every time someone visits a short URL, your app fires a SELECT query against PostgreSQL. A popular link visited thousands of times per minute hammers the database with the same lookup over and over. In a real URL shortener, reads vastly outnumber writes. Redis caching absorbs those repeated lookups so your database handles only the first request for each code.
In this step, get ready to:
- Add a Redis container to your Docker Compose stack.
- Enable Spring Cache backed by Redis in your application.
- Verify that repeated redirects are served from cache without querying the database.
Add Redis to Docker Compose
Just like you used Docker Compose to run PostgreSQL without installing it locally, you will do the same for Redis. A few lines in compose.yaml give you a production-grade Redis instance.
- Open compose.yaml in IntelliJ IDEA.
- Add the following redis service block below the db service (before the volumes: section at the bottom):
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
What does this code do?
- redis:7-alpine pulls a lightweight Redis 7 image based on Alpine Linux.
- The port mapping 6379:6379 lets your Spring Boot app (running on your host) connect to Redis inside the container.
- The health check runs redis-cli ping every 5 seconds. Redis responds with PONG when it is ready to accept connections.
- Save compose.yaml.
- Start both containers by running this command in your terminal (from the project root):
docker compose up -d
- Verify both containers are healthy by running:
docker compose ps
You should see two containers listed, both with a healthy status: db (PostgreSQL) and redis.
Only seeing one container?
Make sure the redis block is indented at the same level as db inside services:. YAML is whitespace-sensitive and a single extra space breaks the structure.
Also confirm that nothing else on your machine is already using port 6379. Run docker compose down and then docker compose up -d again after fixing.
Help me debug my Docker Compose Redis setup.
Enable caching in the Spring Boot app
Redis is running, but your app does not know it exists yet. You need to tell Spring Boot where Redis lives and turn on its caching layer.
- Open src/main/resources/application.properties and add these Redis configuration lines at the bottom of the file:
# Redis
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=6379
spring.cache.type=redis
spring.cache.redis.time-to-live=600000
What do these properties do?
- spring.data.redis.host tells Spring where Redis is running. The ${REDIS_HOST:localhost} pattern defaults to localhost for local development but can be overridden later when the app itself runs in Docker.
- spring.cache.type=redis switches Spring's cache abstraction from the default (no-op) to Redis.
- spring.cache.redis.time-to-live=600000 sets cached entries to expire after 10 minutes (600,000 milliseconds).
- Save application.properties.
- Open src/main/java/com/example/urlshortener/UrlShortenerApplication.java.
- Find this line:
@SpringBootApplication
public class UrlShortenerApplication {
- Add the @EnableCaching annotation between @SpringBootApplication and the class declaration, and add its import at the top of the file. The result looks like this:
package com.example.urlshortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class UrlShortenerApplication {
public static void main(String[] args) {
SpringApplication.run(UrlShortenerApplication.class, args);
}
}
What does @EnableCaching do?
This single annotation activates Spring's caching infrastructure. Without it, @Cacheable annotations on your service methods would be silently ignored. Spring scans for cache annotations only when this switch is on.
- Save UrlShortenerApplication.java.
Now you will update UrlService.java to actually use the cache on the method that looks up URLs by short code.
- Open src/main/java/com/example/urlshortener/service/UrlService.java.
- Add these three new imports below the existing imports at the top of the file:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
- Inside the class body, add a Logger field above the existing urlRepository field:
private static final Logger log = LoggerFactory.getLogger(UrlService.class);
- Find the getOriginalUrl method. It currently looks like this:
public String getOriginalUrl(String shortCode) {
return urlRepository.findByShortCode(shortCode)
.map(Url::getOriginalUrl)
.orElse(null);
}
- Replace it with this cached version that logs when the database is actually queried:
@Cacheable(value = "urls", key = "#shortCode")
public String getOriginalUrl(String shortCode) {
log.info("Cache MISS - querying database for shortCode: {}", shortCode);
return urlRepository.findByShortCode(shortCode)
.map(Url::getOriginalUrl)
.orElse(null);
}
What does @Cacheable do here?
When a request calls getOriginalUrl("abc1234"), Spring checks Redis for a cache entry keyed by that short code. If it finds one, it returns the cached value immediately and the method body never runs.
If Redis has no entry for that key, Spring runs the method body (which queries PostgreSQL), stores the result in Redis, and then returns it. The log.info line only fires when the method body executes, making cache misses visible in your terminal.
- Save UrlService.java.
Seeing a red underline on the imports?
Make sure your pom.xml contains both spring-boot-starter-data-redis and spring-boot-starter-cache dependencies. These were added when you generated the project from Spring Initializr. If they are missing, add them and reload your Maven project in IntelliJ (click the refresh icon in the Maven tool window).
Help me fix unresolved imports in my Spring Boot caching setup.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: application.properties, UrlShortenerApplication.java, and UrlService.java.
ⓧ I'd like to double check the full code
Here is the full application.properties file:
spring.application.name=url-shortener
# PostgreSQL
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:5432/urlshortener
spring.datasource.username=urlshortener
spring.datasource.password=urlshortener
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
# Redis
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=6379
spring.cache.type=redis
spring.cache.redis.time-to-live=600000
Here is the full UrlShortenerApplication.java:
package com.example.urlshortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class UrlShortenerApplication {
public static void main(String[] args) {
SpringApplication.run(UrlShortenerApplication.class, args);
}
}
For UrlService.java, confirm you have all of the following in order:
- The three new imports (Logger, LoggerFactory, Cacheable) alongside the existing ones.
- The private static final Logger log field above urlRepository.
- The @Cacheable(value = "urls", key = "#shortCode") annotation on getOriginalUrl.
- The log.info("Cache MISS - querying database for shortCode: {}", shortCode) line as the first statement inside that method.
Verify caching behavior
Time to see the cache in action. You will create a short URL, visit it twice, and watch the logs to confirm Redis is absorbing the second lookup.
- Restart the app by running:
./mvnw spring-boot:run
You should see Started UrlShortenerApplication in the terminal output with no errors.
- Create a new short URL by running this in a separate terminal:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d '{"url":"https://www.google.com"}'
You will get a JSON response with a shortCode value. Copy that code for the next commands.
- Hit the redirect endpoint for the first time by running (replace the short code with yours):
curl -i http://localhost:8080/[[SHORTCODE="your-short-code"]]
Switch back to the terminal running your app. You should see a log line containing Cache MISS - querying database for shortCode: followed by a Hibernate select query. This is the first lookup. Redis had nothing cached, so Spring ran the method body and queried PostgreSQL.
Before you run the same curl command again, what do you think the logs will show this time?
- Hit the same redirect endpoint a second time with the same short code:
curl -i http://localhost:8080/[[SHORTCODE="your-short-code"]]
Check the app terminal again. No new Cache MISS log line appears. No select query appears. The method body was never called. Spring found the cached result in Redis and returned it directly.
What just happened?
This is the cache-in-front-of-database pattern in action. The first request was a cache miss, so the method ran, queried PostgreSQL, and stored the result in Redis. Every subsequent request for the same short code skips the database entirely and reads from Redis.
In a production URL shortener, popular links might be visited millions of times. Without caching, each visit is a database query. With this pattern, the database handles only the first request per unique short code.
Still seeing Cache MISS on the second request?
Confirm that your Redis container is healthy by running docker compose ps. If Redis is not running, Spring silently falls back to calling the method every time.
Also check that spring.cache.type=redis is present in your application.properties. Without it, Spring does not know to use Redis as the cache store.
Help me debug why my Spring Cache with Redis is not working.
Your URL shortener now has a full caching layer. Repeated lookups fly out of Redis in microseconds instead of hitting PostgreSQL every time. Next up, you will containerize the entire application so the whole stack starts with a single command.
Containerize the Application
Right now your Spring Boot app runs on your host machine while PostgreSQL and Redis run in Docker. A teammate cloning this project would need to install JDK 21 and Maven just to start the server.
Containerizing the application means the entire 3-service system starts with one command and runs identically on any machine. In this step, you will write a Dockerfile that packages your app into an image, then wire it into Docker Compose so everything launches together.
In this step, get ready to:
- Write a multi-stage Dockerfile that builds and runs your Spring Boot app.
- Add the app as a service in your Docker Compose file.
- Start the full 3-container stack with a single command.
Write the Dockerfile
A multi-stage Dockerfile keeps your final image small. The first stage compiles the code using a full JDK. The second stage copies only the compiled JAR into a lightweight JRE image.
- Create a new file called Dockerfile in your project root (the same folder where compose.yaml lives) and paste the following:
# Stage 1: Build
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
COPY src/ src/
RUN ./mvnw package -DskipTests -B
What does this stage do?
- eclipse-temurin:21-jdk provides the full Java 21 development kit needed to compile your code.
- Dependencies are downloaded first (dependency:go-offline) before copying source code. This means Docker caches the dependency layer, so rebuilds are fast when only your code changes.
- ./mvnw package -DskipTests -B compiles your Java source into a JAR file without running tests.
- Below the build stage, add the run stage:
# Stage 2: Run
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this stage do?
- eclipse-temurin:21-jre is a much smaller image. It can run Java apps but cannot compile them.
- COPY --from=build pulls only the finished JAR from the first stage. The JDK, Maven, and source code are discarded.
- EXPOSE 8080 documents the port the app listens on.
- ENTRYPOINT defines the command Docker runs when the container starts.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile printed in the output.
Don't see the file?
- Make sure you created the file in the project root, not inside src/ or another subfolder.
- The filename must be exactly Dockerfile with a capital D and no file extension.
Still stuck? Why can't Docker find my Dockerfile?
✔️ Awesome, I've got everything!
Great. Make sure you have saved Dockerfile before moving on.
ⓧ I'd like to double check the full code
# Stage 1: Build
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
COPY src/ src/
RUN ./mvnw package -DskipTests -B
# Stage 2: Run
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Add the app service to Docker Compose
Your compose.yaml already defines the db and redis services. Now you will add an app service that builds from your Dockerfile and depends on both.
- Open compose.yaml in IntelliJ.
- Find the line that says services: at the top of the file.
- Add the following app service directly below services: and above the existing db: block:
app:
build: .
ports:
- "8080:8080"
environment:
- DB_HOST=db
- REDIS_HOST=redis
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
What does this service definition do?
- build: . tells Docker Compose to build the image from the Dockerfile in the current directory.
- environment sets DB_HOST=db and REDIS_HOST=redis. These override the localhost defaults in application.properties so the app connects to the other containers by their service name.
- depends_on with service_healthy ensures the app only starts after PostgreSQL and Redis pass their health checks.
Why don't I need to edit application.properties?
You already set up environment variable substitution in the previous step: ${DB_HOST:localhost} and ${REDIS_HOST:localhost}. When the app runs on your host, the defaults resolve to localhost. When Docker Compose sets DB_HOST=db, Spring picks up the container service name instead. One config file works for both scenarios.
- Save compose.yaml.
- Make sure the indentation is consistent. The app: key should be at the same level as db: and redis: (two spaces of indentation under services:).
YAML indentation errors?
- YAML is whitespace-sensitive. Use spaces, not tabs.
- Every nested level adds exactly 2 more spaces. If app: is at 2 spaces, build: must be at 4 spaces.
- IntelliJ highlights YAML errors with a red squiggle. Check the bottom of the editor for error messages.
Need help? My compose.yaml has a parsing error after I added the app service.
✔️ Awesome, I've got everything!
Great. Confirm compose.yaml is saved and the app service sits above the db service.
ⓧ I'd like to double check the full code
Your compose.yaml should contain the following sections in order. Scroll through your file and confirm each is present:
- services: at the top.
- app: service with build: ., ports 8080:8080, environment variables DB_HOST=db and REDIS_HOST=redis, and depends_on with both db and redis set to service_healthy.
- db: service with postgres:17, environment variables, ports, volume, and healthcheck.
- redis: service with redis:7-alpine, ports, and healthcheck.
- volumes: section declaring db-data at the bottom.
Run the full stack
Time to start all three containers together. Docker Compose will build your app image, then start the services in dependency order.
- If you still have the Spring Boot app running locally (from the previous step), stop it with Ctrl+C in that terminal. Port 8080 must be free.
- Start the full stack by running:
docker compose up --build
What does this command do?
- --build forces Docker to rebuild your app image from the Dockerfile using your latest source code.
- Without -d, the logs stream directly in your terminal so you can watch each service start.
- You will see the db and redis containers pass their health checks first, then the app container boots and connects to both.
Wait until you see a log line containing Started UrlShortenerApplication. This confirms the app is ready.
- Open a new terminal tab or window (the current one is streaming logs).
- Before you test, predict: will the curl command below succeed or fail? The app is running inside Docker instead of on your host. Does that change anything for port 8080?
- Test the API by running:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d '{"url":"https://github.com"}'
You should receive a JSON response with a shortCode and a shortUrl. The port mapping in your Compose file forwards host port 8080 to the container, so it works exactly the same as before.
- Verify the redirect still works by running (replace the short code with your actual value):
curl -i http://localhost:8080/[[SHORTCODE="your-short-code-from-above"]]
You should see a 302 status with Location: https://github.com in the response headers.
- Return to the terminal streaming Docker logs.
- Press Ctrl+C to stop the containers.
- Clean up the stopped containers by running:
docker compose down
All three containers stop and are removed. Your data persists in the db-data volume for next time.
Seeing a port conflict or build failure?
- If you see port is already allocated, make sure you stopped the locally running Spring Boot app (Ctrl+C in that terminal) before running docker compose up.
- If the build fails on ./mvnw, check that mvnw has execute permissions. Run chmod +x mvnw and try again.
- If the app container exits immediately, check the logs with docker compose logs app. A common cause is mismatched indentation in compose.yaml causing the environment variables to be ignored.
Still stuck? My Docker Compose build is failing for the Spring Boot app.
Your entire URL shortener now runs in Docker. A teammate can clone this repo, run one command, and have the full system running without installing Java or Maven on their machine. That is the power of containerization.
Secret mission
Add Click Analytics with Redis
Your URL shortener works, but you have no idea which links are popular. In this bonus challenge, you'll use Redis as a real-time counter to track how many times each short URL gets clicked. One atomic command. Zero database queries. Pure speed.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers: app, db (PostgreSQL), and redis.
- Docker volume: db-data (stores your PostgreSQL data).
- Project folder: url-shortener on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your URL shortener.
- Your three containers (app, db, redis) continue running in the background.
- The API remains accessible at http://localhost:8080.
- Docker Desktop uses system memory while containers run. If your machine feels slow, consider pausing.
Pause - I'll come back to this later
Shut down running containers to free up memory while keeping your data intact for later.
- Stop all containers by running this in the url-shortener project directory:
docker compose stop
- Your containers and the db-data volume are preserved. All your shortened URLs remain in PostgreSQL.
- When you're ready to resume, run this from the same directory:
docker compose start
- Everything picks up exactly where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible.
- Stop and remove all containers, networks, and volumes by running this in the url-shortener project directory:
docker compose down -v
- This removes the app, db, and redis containers along with the db-data volume. All shortened URLs stored in PostgreSQL are deleted permanently.
- Delete the project folder from your Desktop:
macOS
cd ~/Desktop
rm -rf url-shortener
Windows
cd ~\Desktop
rmdir /s /q url-shortener
- Uninstall Docker Desktop if you no longer need it. Open Docker Desktop, click the **Troubleshoot** icon (top right), then click **Uninstall**.
- Uninstall JDK 21 if you no longer need it:
brew uninstall openjdk@21
Nice Work!
Nice Work!
Nice work! You just built a production-style URL shortener that demonstrates one of the most common system design patterns in the real world.
You've learned how to:
- Build a REST API URL shortener with Spring Boot and implement the cache-in-front-of-database pattern using Spring Cache and Redis.
- Orchestrate a 3-service stack (Spring Boot, PostgreSQL, and Redis) with Docker Compose, starting and stopping the entire system with a single command.
- Containerize a Java application with a multi-stage Dockerfile that separates the build environment from the runtime environment.
- Secret Mission: Add real-time click analytics using Redis atomic INCR operations to track how many times each short URL has been visited.
Ready to quiz yourself?