URL Shortener: Full System Design

Build a containerized URL shortener with Spring Boot, Redis, and CI/CD.

Introduction

30 Second Summary

Every time you click a shortened link, a system takes that tiny code, looks up the original URL, and redirects you in milliseconds. Behind that simple experience is a full production system with databases, caches, containers, and deployment pipelines.

In this project, you will build a URL shortener service from scratch and take it through the complete system design lifecycle. You'll document requirements, build the API, add caching, containerize everything with Docker, and set up CI/CD with GitHub Actions.

What You'll Build

A production-style URL shortener service that accepts long URLs, returns shortened versions, redirects visitors to the original, and tracks hit counts for analytics.

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

  • A working URL shortener API built with Spring Boot, PostgreSQL for persistent storage, and Redis for fast cached lookups.
  • A multi-container Docker application orchestrated with Docker Compose, plus a CI/CD pipeline on GitHub Actions that automatically builds, tests, and packages your application.
  • A complete system design document (DESIGN.md) articulating requirements, API contracts, data model, and architecture decisions.
  • Secret Mission: Implement API rate limiting using Redis to prevent abuse of your shortening endpoint.

Are there any prerequisites?

You should be comfortable with Java and REST API concepts. You'll also need Docker installed on your machine.

Set Up the Project

Every production system starts with a clean project foundation. Spring Initializr generates a ready-to-run Spring Boot project with all the dependencies you need, pre-configured and compatible.

In this step, you'll generate a Spring Boot project, verify it compiles, and push it to GitHub so you have version control from day one.

In this step, get ready to:
  • Generate the Spring Boot project with all required dependencies.
  • Verify the project compiles using the Maven Wrapper.
  • Initialize Git and push to a public GitHub repository.
Generate the project from Spring Initializr

Spring Initializr is a web tool that generates pre-configured Spring Boot projects. You pick your dependencies and it gives you a ready-to-build ZIP file.

  • Open your browser and navigate to https://start.spring.io.
  • Under **Project**, select Maven.
  • Under Language, select Java.
  • Under Spring Boot, select 3.5.14.
  • Set Group to com.urlshortener.
  • Set Artifact to url-shortener.
  • Set Packaging to Jar.
  • Set Java to 17.
  • Click ADD DEPENDENCIES to open the dependency search.
  • Search for and add Spring Web.
  • Search for and add Spring Data JPA.
  • Search for and add Spring Data Redis.
  • Search for and add Spring Boot Actuator.
  • Search for and add PostgreSQL Driver.
  • Click GENERATE to download the ZIP file.
  • Extract the downloaded url-shortener.zip to your Desktop.

What is Spring Initializr?

Spring Initializr is a project generator maintained by the Spring team. It creates a fully configured project skeleton with the correct directory structure, build files, and dependency declarations.

The generated project includes a Maven Wrapper (mvnw), so you don't need Maven installed globally. The wrapper downloads and manages the correct Maven version automatically.

Why Java 17 when I have Java 19?

The Java version in Spring Initializr sets the compilation target, not the JDK you run. Setting it to 17 means the compiled code is compatible with Java 17 and above. Your Java 19 installation runs it perfectly.

Verify the project builds

Before writing any code, you need to confirm the generated project compiles successfully. The app won't fully start yet (it needs a database), but the source code should compile without errors.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the url-shortener folder on your Desktop, then click Open.
  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, top-left of keyboard).
  • Verify the project compiles by running this command:

macOS

./mvnw compile

Windows

.\mvnw.cmd compile

You should see output ending with BUILD SUCCESS. This confirms your project structure and dependencies are valid.

Seeing permission denied on macOS?

If you get a permission error running ./mvnw, make it executable first by running chmod +x mvnw in the terminal, then try again.

If you see a different error, help me troubleshoot my Maven Wrapper error.

Now let's add the server port to the application configuration. Spring Boot defaults to port 8080, but it's good practice to be explicit.

  • In the VS Code Explorer sidebar, expand src > main > resources.
  • Open application.properties.
  • Add server.port=8080 on a new line below spring.application.name=url-shortener.
  • Save the file.

Your application.properties should now look like this:

spring.application.name=url-shortener
server.port=8080

✔️ Awesome, I've got everything!

Great. Make sure you've saved application.properties before moving on.

ⓧ I'd like to double check the full code

Here are the two key files in your project at this point.

package com.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);
    }
}
spring.application.name=url-shortener
server.port=8080
Initialize a Git repository and push to GitHub

Version control from day one means you can track every change as you build. The generated project already includes a .gitignore file that excludes build artifacts and IDE files.

  • Initialize a Git repository by running this command in the VS Code terminal:
git init
  • Stage all files by running:
git add .
  • Create your first commit by running:
git commit -m "Initial commit: Spring Boot project with dependencies"

Now create a remote repository on GitHub so your code lives in the cloud.

  • Open your browser and go to https://github.com/new.
  • Set Repository name to url-shortener.
  • Select Public.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.
  • Back in your VS Code terminal, connect to GitHub and push by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/url-shortener.git"]]
git branch -M main
git push -u origin main

Where do I find my repo URL?

After creating the repository on GitHub, the page shows a Quick Setup section with your repository URL. Copy the HTTPS URL (ending in .git) and paste it in place of the placeholder above.

  • Refresh the GitHub repository page in your browser. You should see all your project files listed.

Push failed with authentication error?

GitHub no longer accepts password authentication for git push. You need either a Personal Access Token or SSH key configured.

If you see an authentication error, help me set up GitHub authentication for git push.

Your project foundation is solid. Next up, you'll write a system design document that captures the requirements, API contracts, and architecture decisions before writing any application code.

Document the System Design

Before writing a single line of application code, professional engineers document their system design. This document becomes the blueprint that guides implementation decisions and communicates your architecture to others.

In this step, you will create a DESIGN.md file that captures requirements, API contracts, the data model, architecture decisions, and scalability considerations for your URL shortener.

In this step, get ready to:
  • Define functional and non-functional requirements alongside an API contract.
  • Design the data model and three-tier architecture.
  • Document scalability considerations and CAP theorem trade-offs.
Define requirements and API contract

Every system design starts with clearly stating what the system must do (functional requirements) and how well it must do it (non-functional requirements). The API contract then formalizes how clients interact with the system.

  • Create a new file called DESIGN.md at the root of your url-shortener project (the same level as pom.xml).
  • Add the document heading and the Requirements section by pasting the following:
# URL Shortener - System Design Document

## 1. Requirements

### Functional Requirements
- Users can submit a long URL and receive a shortened URL
- Visiting the short URL redirects to the original URL
- Users can view analytics (hit count) for a short URL

### Non-Functional Requirements
- Low latency for redirects (< 50ms with cache hit)
- High read-to-write ratio (100:1 estimated)
- Short codes should be unique and non-sequential (Base62 encoding)
- System should handle concurrent requests safely

What do these requirements capture?

Functional requirements describe what the system does from a user's perspective. Non-functional requirements describe quality attributes like latency and safety.

The 100:1 read-to-write ratio tells you this is a read-heavy system. That insight drives your caching strategy later.

  • Below the Requirements section, add the API Contract section that defines your three endpoints:
## 2. API Contract

### POST /api/shorten
Request:
```json
{ "url": "https://example.com/very/long/url" }
```
Response (201 Created):
```json
{ "shortCode": "abc123", "shortUrl": "http://localhost:8080/abc123", "originalUrl": "https://example.com/very/long/url" }
```

### GET /{shortCode}
Response: 302 Redirect to original URL
Error: 404 Not Found if short code does not exist

### GET /api/stats/{shortCode}
Response (200 OK):
```json
{ "shortCode": "abc123", "originalUrl": "https://example.com/very/long/url", "hitCount": 42, "createdAt": "2026-05-29T10:00:00" }
```

Why define the API contract before writing code?

The API contract acts as a shared agreement between frontend and backend teams. By locking down endpoints, HTTP methods, and response shapes upfront, you avoid rework when you start implementing.

Notice how each endpoint specifies exact status codes (201, 302, 404). These are not decorative. They communicate intent to clients programmatically.

  • Save DESIGN.md.
  • Open the Markdown preview by pressing Cmd+Shift+V (macOS) or Ctrl+Shift+V (Windows) to confirm the formatting looks correct.

Preview looks like raw text?

Make sure your file is saved with the .md extension. VS Code only renders Markdown preview for recognized Markdown files.

Why isn't my DESIGN.md rendering as formatted Markdown in VS Code preview?

Design the data model and architecture

With the external contract defined, you now design the internals. The data model specifies how data is stored, and the architecture diagram shows how components interact.

  • In DESIGN.md, add the Data Model section below the API Contract:
## 3. Data Model

### urls table
| Column       | Type         | Constraints          |
|-------------|--------------|---------------------|
| id          | BIGINT       | PRIMARY KEY, AUTO    |
| short_code  | VARCHAR(10)  | UNIQUE, NOT NULL     |
| original_url| VARCHAR(2048)| NOT NULL             |
| hit_count   | BIGINT       | DEFAULT 0            |
| created_at  | TIMESTAMP    | NOT NULL             |

Why a single table?

A URL shortener has a simple domain. One table handles all the data you need: the mapping between short codes and original URLs, plus basic analytics (hit count) and metadata (creation timestamp).

The short_code column is marked UNIQUE because each short code must resolve to exactly one URL. The id column serves double duty as the database primary key and the input to Base62 encoding.

  • Add the Architecture section below the Data Model:
## 4. Architecture

```
Client -> [Spring Boot API] -> [Redis Cache]
                            -> [PostgreSQL]
```

- **API Layer**: Spring Boot handles HTTP requests, validation, and business logic
- **Cache Layer**: Redis stores shortCode -> originalUrl mappings for fast reads
- **Storage Layer**: PostgreSQL persists all URL data permanently

### Flow: Redirect (GET /{shortCode})
1. Check Redis cache for shortCode
2. If cache hit: return URL, increment hit count async
3. If cache miss: query PostgreSQL, populate cache, return URL

### Flow: Shorten (POST /api/shorten)
1. Validate URL format
2. Save to PostgreSQL (generates ID)
3. Encode ID to Base62 short code
4. Update record with short code
5. Write to Redis cache
6. Return response

What is cache-aside?

The redirect flow uses the cache-aside pattern. The application checks the cache first. On a miss, it queries the database and then writes the result into the cache for next time.

This pattern works well for read-heavy workloads where you want low latency on repeated lookups. Your 100:1 read-to-write ratio makes this a perfect fit.

  • Save DESIGN.md.
  • Check the Markdown preview again. You should see a formatted table for the data model and a clear ASCII diagram for the architecture.

Table not rendering properly?

Markdown tables require the header separator row (the line with dashes and pipes). Make sure you have |---|---|---| between the header row and the data rows.

Help me fix my markdown table formatting

Document scalability considerations

The final section of your design document addresses how the system would grow beyond a single server. This demonstrates systems thinking to anyone reviewing your work.

  • Add the Scalability Considerations section at the bottom of DESIGN.md:
## 5. Scalability Considerations

- **Horizontal scaling**: Stateless API layer can scale behind a load balancer
- **Redis cluster**: Distribute cache across nodes for higher throughput
- **Read replicas**: PostgreSQL read replicas for analytics queries
- **CDN**: Short URL redirects could be cached at CDN edge nodes
- **CAP trade-off**: Favoring AP (Availability + Partition Tolerance). A stale cache entry is acceptable briefly.

What is the CAP theorem?

The CAP theorem states that a distributed system can guarantee at most two of three properties: Consistency, Availability, and Partition Tolerance.

Your URL shortener favors Availability and Partition Tolerance (AP). If the cache serves a slightly stale URL for a few seconds, that is acceptable. Users would rather get a fast redirect than wait for perfect consistency.

  • Save DESIGN.md.

✔️ Awesome, I've got everything!

Great. Make sure DESIGN.md is saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete DESIGN.md file. Compare yours against it:

# URL Shortener - System Design Document

## 1. Requirements

### Functional Requirements
- Users can submit a long URL and receive a shortened URL
- Visiting the short URL redirects to the original URL
- Users can view analytics (hit count) for a short URL

### Non-Functional Requirements
- Low latency for redirects (< 50ms with cache hit)
- High read-to-write ratio (100:1 estimated)
- Short codes should be unique and non-sequential (Base62 encoding)
- System should handle concurrent requests safely

## 2. API Contract

### POST /api/shorten
Request:
```json
{ "url": "https://example.com/very/long/url" }
```
Response (201 Created):
```json
{ "shortCode": "abc123", "shortUrl": "http://localhost:8080/abc123", "originalUrl": "https://example.com/very/long/url" }
```

### GET /{shortCode}
Response: 302 Redirect to original URL
Error: 404 Not Found if short code does not exist

### GET /api/stats/{shortCode}
Response (200 OK):
```json
{ "shortCode": "abc123", "originalUrl": "https://example.com/very/long/url", "hitCount": 42, "createdAt": "2026-05-29T10:00:00" }
```

## 3. Data Model

### urls table
| Column       | Type         | Constraints          |
|-------------|--------------|---------------------|
| id          | BIGINT       | PRIMARY KEY, AUTO    |
| short_code  | VARCHAR(10)  | UNIQUE, NOT NULL     |
| original_url| VARCHAR(2048)| NOT NULL             |
| hit_count   | BIGINT       | DEFAULT 0            |
| created_at  | TIMESTAMP    | NOT NULL             |

## 4. Architecture

```
Client -> [Spring Boot API] -> [Redis Cache]
                            -> [PostgreSQL]
```

- **API Layer**: Spring Boot handles HTTP requests, validation, and business logic
- **Cache Layer**: Redis stores shortCode -> originalUrl mappings for fast reads
- **Storage Layer**: PostgreSQL persists all URL data permanently

### Flow: Redirect (GET /{shortCode})
1. Check Redis cache for shortCode
2. If cache hit: return URL, increment hit count async
3. If cache miss: query PostgreSQL, populate cache, return URL

### Flow: Shorten (POST /api/shorten)
1. Validate URL format
2. Save to PostgreSQL (generates ID)
3. Encode ID to Base62 short code
4. Update record with short code
5. Write to Redis cache
6. Return response

## 5. Scalability Considerations

- **Horizontal scaling**: Stateless API layer can scale behind a load balancer
- **Redis cluster**: Distribute cache across nodes for higher throughput
- **Read replicas**: PostgreSQL read replicas for analytics queries
- **CDN**: Short URL redirects could be cached at CDN edge nodes
- **CAP trade-off**: Favoring AP (Availability + Partition Tolerance). A stale cache entry is acceptable briefly.
  • Commit and push your design document by running these commands:
git add DESIGN.md
git commit -m "Add system design document"
git push
  • Check the terminal output. You should see a message confirming the commit was pushed to your remote repository.

Push rejected?

If git push is rejected, run git pull --rebase first to sync with any remote changes, then try pushing again.

Help me resolve my rejected git push

Your system design is documented and pushed to GitHub. Next up, you will bring this design to life by building the URL shortener API with Spring Boot.

Build the URL Shortener API

You have a system design document that maps out exactly how your URL shortener should work. Now it is time to turn that design into running code.

In this step, you will build the full Spring Boot API layer. This includes the database entity, the encoding logic, the service that orchestrates everything, and the REST endpoints that clients will call.

In this step, get ready to:
  • Create the JPA entity and repository for URL storage.
  • Build the service layer with Base62 encoding and caching logic.
  • Wire up REST endpoints for shortening, redirecting, and stats.
Create the entity and repository

Every URL you shorten needs a permanent home in the database. JPA lets you map a Java class directly to a database table. Spring Data then generates all the query logic for you from a simple interface.

  • Create a new folder at src/main/java/com/urlshortener/model.
  • Create a file called Url.java inside the model folder and paste the following code:
package com.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.time.LocalDateTime;

@Entity
@Table(name = "urls")
public class Url {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "short_code", unique = true, length = 10)
    private String shortCode;

    @Column(name = "original_url", nullable = false, length = 2048)
    private String originalUrl;

    @Column(name = "hit_count")
    private long hitCount = 0;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    public Url() {
    }

    public Url(String originalUrl) {
        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 long getHitCount() {
        return hitCount;
    }

    public void setHitCount(long hitCount) {
        this.hitCount = hitCount;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }
}

What does this code do?

This class maps directly to the urls table you designed in DESIGN.md. Each field corresponds to a column.

  • @Entity and @Table(name = "urls") tell JPA this class represents the urls database table.
  • @GeneratedValue(strategy = GenerationType.IDENTITY) means the database auto-generates the ID. This ID becomes the input for Base62 encoding.
  • The two constructors let JPA create empty instances (required) and let your code create URLs with the original URL pre-filled.
  • Save Url.java.
  • You will not see a change yet. The next file uses this entity.
  • Create a new folder at src/main/java/com/urlshortener/repository.
  • Create a file called UrlRepository.java inside the repository folder and paste the following code:
package com.urlshortener.repository;

import com.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 code do?

By extending JpaRepository, Spring Data automatically generates all basic CRUD operations (save, find, delete) at runtime. You get a full database access layer without writing SQL.

The findByShortCode method follows Spring Data's naming convention. Spring parses the method name and generates the query SELECT * FROM urls WHERE short_code = ? automatically.

  • Save UrlRepository.java.
  • You will not see a change yet. The service layer in the next substep uses this repository.

Seeing red underlines in your editor?

If VS Code shows import errors for jakarta.persistence or org.springframework.data, that is expected before the first build. The dependencies are in your pom.xml but your editor may not have indexed them yet.

help me fix import errors in my Spring Boot project

Create the service layer

The service layer contains your business logic. It coordinates between the repository (database access) and the cache (Redis) to implement the URL shortening flow you documented in DESIGN.md.

  • Create a new folder at src/main/java/com/urlshortener/service.
  • Create a file called Base62Encoder.java inside the service folder and paste the following code:
package com.urlshortener.service;

public class Base62Encoder {

    private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    private static final int BASE = ALPHABET.length();

    public static String encode(long number) {
        if (number == 0) {
            return String.valueOf(ALPHABET.charAt(0));
        }
        StringBuilder sb = new StringBuilder();
        while (number > 0) {
            sb.append(ALPHABET.charAt((int) (number % BASE)));
            number /= BASE;
        }
        return sb.reverse().toString();
    }
}

What is Base62 encoding?

Base62 uses 62 characters (0-9, A-Z, a-z) to represent numbers compactly. A database ID like 12345 becomes a short string like 3D7. This is how your short codes stay short.

The algorithm repeatedly divides the number by 62, using each remainder to pick a character from the alphabet. Reversing the result gives you the final short code.

  • Save Base62Encoder.java.
  • You will not see a change yet. The service class below uses this encoder.
  • Create a file called UrlService.java inside the service folder and paste the following code:
package com.urlshortener.service;

import com.urlshortener.model.Url;
import com.urlshortener.repository.UrlRepository;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Optional;

@Service
public class UrlService {

    private static final Duration CACHE_TTL = Duration.ofHours(24);
    private static final String CACHE_PREFIX = "url:";

    private final UrlRepository urlRepository;
    private final StringRedisTemplate redisTemplate;

    public UrlService(UrlRepository urlRepository, StringRedisTemplate redisTemplate) {
        this.urlRepository = urlRepository;
        this.redisTemplate = redisTemplate;
    }

    public Url shortenUrl(String originalUrl) {
        Url url = new Url(originalUrl);
        url = urlRepository.save(url);

        String shortCode = Base62Encoder.encode(url.getId());
        url.setShortCode(shortCode);
        url = urlRepository.save(url);

        redisTemplate.opsForValue().set(CACHE_PREFIX + shortCode, originalUrl, CACHE_TTL);

        return url;
    }

    public Optional<String> resolveUrl(String shortCode) {
        String cached = redisTemplate.opsForValue().get(CACHE_PREFIX + shortCode);
        if (cached != null) {
            urlRepository.findByShortCode(shortCode).ifPresent(url -> {
                url.setHitCount(url.getHitCount() + 1);
                urlRepository.save(url);
            });
            return Optional.of(cached);
        }

        Optional<Url> urlOptional = urlRepository.findByShortCode(shortCode);
        if (urlOptional.isPresent()) {
            Url url = urlOptional.get();
            url.setHitCount(url.getHitCount() + 1);
            urlRepository.save(url);

            redisTemplate.opsForValue().set(CACHE_PREFIX + shortCode, url.getOriginalUrl(), CACHE_TTL);

            return Optional.of(url.getOriginalUrl());
        }

        return Optional.empty();
    }

    public Optional<Url> getStats(String shortCode) {
        return urlRepository.findByShortCode(shortCode);
    }
}

What does this service do?

  • shortenUrl saves the URL to PostgreSQL, encodes the generated database ID into a Base62 short code, updates the record, then writes the mapping to Redis.
  • resolveUrl implements the cache-aside pattern from your design. It checks Redis first. On a cache miss, it falls back to PostgreSQL and populates the cache for next time.
  • getStats queries the database directly for analytics data (hit count, creation time).

The CACHE_TTL of 24 hours means cached entries expire after a day, keeping memory usage bounded. The CACHE_PREFIX namespaces URL entries in Redis so they do not collide with other data.

  • Save UrlService.java.
  • You will not see a change yet. The controller in the next substep connects this service to HTTP endpoints.

Unsure about StringRedisTemplate?

StringRedisTemplate is auto-configured by Spring Boot when spring-boot-starter-data-redis is on the classpath. You do not need to create a bean for it. Spring injects it through the constructor automatically.

explain how StringRedisTemplate auto-configuration works

Create the REST controller

The controller exposes your service methods as HTTP endpoints. It also needs DTO (Data Transfer Object) classes to define the shape of request and response JSON bodies.

  • Create a new folder at src/main/java/com/urlshortener/controller.
  • Create a file called ShortenRequest.java inside the controller folder and paste the following code:
package com.urlshortener.controller;

public class ShortenRequest {

    private String url;

    public ShortenRequest() {
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }
}

What does this code do?

This is a simple DTO. When a client sends a POST request with {"url": "https://..."}, Spring automatically deserializes the JSON into this object. The empty constructor is required for Jackson (the JSON library) to create instances.

  • Save ShortenRequest.java.
  • Create a file called ShortenResponse.java inside the same controller folder and paste the following code:
package com.urlshortener.controller;

public class ShortenResponse {

    private String shortCode;
    private String shortUrl;
    private String originalUrl;

    public ShortenResponse(String shortCode, String shortUrl, String originalUrl) {
        this.shortCode = shortCode;
        this.shortUrl = shortUrl;
        this.originalUrl = originalUrl;
    }

    public String getShortCode() {
        return shortCode;
    }

    public String getShortUrl() {
        return shortUrl;
    }

    public String getOriginalUrl() {
        return originalUrl;
    }
}

What does this code do?

This DTO shapes the JSON response for the shorten endpoint. It includes the short code, the full short URL (with the host), and the original URL so the client has everything it needs.

  • Save ShortenResponse.java.
  • Create a file called StatsResponse.java inside the same controller folder and paste the following code:
package com.urlshortener.controller;

import java.time.LocalDateTime;

public class StatsResponse {

    private String shortCode;
    private String originalUrl;
    private long hitCount;
    private LocalDateTime createdAt;

    public StatsResponse(String shortCode, String originalUrl, long hitCount, LocalDateTime createdAt) {
        this.shortCode = shortCode;
        this.originalUrl = originalUrl;
        this.hitCount = hitCount;
        this.createdAt = createdAt;
    }

    public String getShortCode() {
        return shortCode;
    }

    public String getOriginalUrl() {
        return originalUrl;
    }

    public long getHitCount() {
        return hitCount;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }
}

What does this code do?

This DTO represents the analytics response. It returns the hit count and creation timestamp alongside the URL data, matching the GET /api/stats/{shortCode} contract from your design document.

  • Save StatsResponse.java.
  • Now create the controller itself. Create a file called UrlController.java inside the controller folder and paste the following code:
package com.urlshortener.controller;

import com.urlshortener.model.Url;
import com.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.util.Optional;

@RestController
public class UrlController {

    private final UrlService urlService;

    public UrlController(UrlService urlService) {
        this.urlService = urlService;
    }

    @PostMapping("/api/shorten")
    public ResponseEntity<ShortenResponse> shorten(@RequestBody ShortenRequest request) {
        if (request.getUrl() == null || request.getUrl().isBlank()) {
            return ResponseEntity.badRequest().build();
        }

        Url url = urlService.shortenUrl(request.getUrl());
        String shortUrl = "http://localhost:8080/" + url.getShortCode();

        ShortenResponse response = new ShortenResponse(
                url.getShortCode(),
                shortUrl,
                url.getOriginalUrl()
        );

        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
        Optional<String> originalUrl = urlService.resolveUrl(shortCode);

        if (originalUrl.isPresent()) {
            return ResponseEntity.status(HttpStatus.FOUND)
                    .header("Location", originalUrl.get())
                    .build();
        }

        return ResponseEntity.notFound().build();
    }

    @GetMapping("/api/stats/{shortCode}")
    public ResponseEntity<StatsResponse> stats(@PathVariable String shortCode) {
        Optional<Url> urlOptional = urlService.getStats(shortCode);

        if (urlOptional.isPresent()) {
            Url url = urlOptional.get();
            StatsResponse response = new StatsResponse(
                    url.getShortCode(),
                    url.getOriginalUrl(),
                    url.getHitCount(),
                    url.getCreatedAt()
            );
            return ResponseEntity.ok(response);
        }

        return ResponseEntity.notFound().build();
    }
}

What does this controller do?

  • POST /api/shorten validates the incoming URL, calls the service to shorten it, and returns a 201 Created response with the short URL.
  • GET /{shortCode} resolves the short code to the original URL and returns a 302 redirect. If the code does not exist, it returns 404.
  • GET /api/stats/{shortCode} returns the analytics data (hit count and creation time) as JSON.

These three endpoints match the API contract you defined in your system design document.

  • Save UrlController.java.

The last piece is connecting your application to PostgreSQL and Redis. You need to update application.properties with the database and cache connection details.

  • Open src/main/resources/application.properties and replace its contents with the following:
# Application
spring.application.name=url-shortener
server.port=8080

# PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/urlshortener
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

# Redis
spring.data.redis.host=localhost
spring.data.redis.port=6379

# Actuator
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always

What do these properties configure?

  • The PostgreSQL section points Spring to a local database called urlshortener. ddl-auto=update tells Hibernate to create or update the table schema automatically from your entity class.
  • The Redis section configures the connection for StringRedisTemplate. Spring Boot auto-configures the template using these host and port values.
  • The Actuator section exposes health, info, and metrics endpoints for monitoring. You will use these later for Docker health checks.
  • Save application.properties.
  • Verify the project compiles by running this command in your terminal:
./mvnw compile

You should see BUILD SUCCESS in the output. The application will not fully start yet (no database is running), but successful compilation confirms all your classes and imports are correct.

Build failure?

If you see errors about missing imports, double-check that your package declarations match the folder structure. For example, Url.java must be in src/main/java/com/urlshortener/model/ and its package declaration must say package com.urlshortener.model.

On Windows, use .\mvnw.cmd compile instead.

help me debug my Maven build failure

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files before moving on.

ⓧ I'd like to double check the full code

Here is each file as it should look at this point.

package com.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.time.LocalDateTime;

@Entity
@Table(name = "urls")
public class Url {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "short_code", unique = true, length = 10)
    private String shortCode;

    @Column(name = "original_url", nullable = false, length = 2048)
    private String originalUrl;

    @Column(name = "hit_count")
    private long hitCount = 0;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    public Url() {
    }

    public Url(String originalUrl) {
        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 long getHitCount() { return hitCount; }
    public void setHitCount(long hitCount) { this.hitCount = hitCount; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.urlshortener.repository;

import com.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);
}
package com.urlshortener.service;

public class Base62Encoder {

    private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    private static final int BASE = ALPHABET.length();

    public static String encode(long number) {
        if (number == 0) {
            return String.valueOf(ALPHABET.charAt(0));
        }
        StringBuilder sb = new StringBuilder();
        while (number > 0) {
            sb.append(ALPHABET.charAt((int) (number % BASE)));
            number /= BASE;
        }
        return sb.reverse().toString();
    }
}
package com.urlshortener.service;

import com.urlshortener.model.Url;
import com.urlshortener.repository.UrlRepository;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Optional;

@Service
public class UrlService {

    private static final Duration CACHE_TTL = Duration.ofHours(24);
    private static final String CACHE_PREFIX = "url:";

    private final UrlRepository urlRepository;
    private final StringRedisTemplate redisTemplate;

    public UrlService(UrlRepository urlRepository, StringRedisTemplate redisTemplate) {
        this.urlRepository = urlRepository;
        this.redisTemplate = redisTemplate;
    }

    public Url shortenUrl(String originalUrl) {
        Url url = new Url(originalUrl);
        url = urlRepository.save(url);
        String shortCode = Base62Encoder.encode(url.getId());
        url.setShortCode(shortCode);
        url = urlRepository.save(url);
        redisTemplate.opsForValue().set(CACHE_PREFIX + shortCode, originalUrl, CACHE_TTL);
        return url;
    }

    public Optional<String> resolveUrl(String shortCode) {
        String cached = redisTemplate.opsForValue().get(CACHE_PREFIX + shortCode);
        if (cached != null) {
            urlRepository.findByShortCode(shortCode).ifPresent(url -> {
                url.setHitCount(url.getHitCount() + 1);
                urlRepository.save(url);
            });
            return Optional.of(cached);
        }
        Optional<Url> urlOptional = urlRepository.findByShortCode(shortCode);
        if (urlOptional.isPresent()) {
            Url url = urlOptional.get();
            url.setHitCount(url.getHitCount() + 1);
            urlRepository.save(url);
            redisTemplate.opsForValue().set(CACHE_PREFIX + shortCode, url.getOriginalUrl(), CACHE_TTL);
            return Optional.of(url.getOriginalUrl());
        }
        return Optional.empty();
    }

    public Optional<Url> getStats(String shortCode) {
        return urlRepository.findByShortCode(shortCode);
    }
}
package com.urlshortener.controller;

public class ShortenRequest {
    private String url;
    public ShortenRequest() {}
    public String getUrl() { return url; }
    public void setUrl(String url) { this.url = url; }
}
package com.urlshortener.controller;

public class ShortenResponse {
    private String shortCode;
    private String shortUrl;
    private String originalUrl;
    public ShortenResponse(String shortCode, String shortUrl, String originalUrl) {
        this.shortCode = shortCode;
        this.shortUrl = shortUrl;
        this.originalUrl = originalUrl;
    }
    public String getShortCode() { return shortCode; }
    public String getShortUrl() { return shortUrl; }
    public String getOriginalUrl() { return originalUrl; }
}
package com.urlshortener.controller;

import java.time.LocalDateTime;

public class StatsResponse {
    private String shortCode;
    private String originalUrl;
    private long hitCount;
    private LocalDateTime createdAt;
    public StatsResponse(String shortCode, String originalUrl, long hitCount, LocalDateTime createdAt) {
        this.shortCode = shortCode;
        this.originalUrl = originalUrl;
        this.hitCount = hitCount;
        this.createdAt = createdAt;
    }
    public String getShortCode() { return shortCode; }
    public String getOriginalUrl() { return originalUrl; }
    public long getHitCount() { return hitCount; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}
package com.urlshortener.controller;

import com.urlshortener.model.Url;
import com.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.util.Optional;

@RestController
public class UrlController {

    private final UrlService urlService;

    public UrlController(UrlService urlService) {
        this.urlService = urlService;
    }

    @PostMapping("/api/shorten")
    public ResponseEntity<ShortenResponse> shorten(@RequestBody ShortenRequest request) {
        if (request.getUrl() == null || request.getUrl().isBlank()) {
            return ResponseEntity.badRequest().build();
        }
        Url url = urlService.shortenUrl(request.getUrl());
        String shortUrl = "http://localhost:8080/" + url.getShortCode();
        ShortenResponse response = new ShortenResponse(url.getShortCode(), shortUrl, url.getOriginalUrl());
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
        Optional<String> originalUrl = urlService.resolveUrl(shortCode);
        if (originalUrl.isPresent()) {
            return ResponseEntity.status(HttpStatus.FOUND).header("Location", originalUrl.get()).build();
        }
        return ResponseEntity.notFound().build();
    }

    @GetMapping("/api/stats/{shortCode}")
    public ResponseEntity<StatsResponse> stats(@PathVariable String shortCode) {
        Optional<Url> urlOptional = urlService.getStats(shortCode);
        if (urlOptional.isPresent()) {
            Url url = urlOptional.get();
            StatsResponse response = new StatsResponse(url.getShortCode(), url.getOriginalUrl(), url.getHitCount(), url.getCreatedAt());
            return ResponseEntity.ok(response);
        }
        return ResponseEntity.notFound().build();
    }
}
# Application
spring.application.name=url-shortener
server.port=8080

# PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/urlshortener
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

# Redis
spring.data.redis.host=localhost
spring.data.redis.port=6379

# Actuator
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always
  • Commit your changes and push to GitHub by running these commands:
git add .
git commit -m "Add URL shortener API with entity, service, and controller"
git push

Your API is fully built. The entity maps to the database, the service handles encoding and caching, and the controller exposes everything over HTTP. Next up, you will containerize the whole stack with Docker so you can actually run it.

Containerize with Docker

Your URL shortener works as Java code, but running it requires a specific Java version, a PostgreSQL server, and a Redis server all configured correctly. That is a lot of setup to expect from anyone who wants to run your application.

Docker solves this by packaging your application and all its dependencies into containers. In this step, you will write a multi-stage Dockerfile to build and run the app, then orchestrate all three services with Docker Compose.

In this step, get ready to:
  • Write a multi-stage Dockerfile that builds and packages the app.
  • Write a Docker Compose file that orchestrates the app, PostgreSQL, and Redis.
  • Build and run the full stack, then test all endpoints.
Write the Dockerfile

A multi-stage Dockerfile separates the build environment from the runtime environment. The first stage compiles your code into a JAR file using the full JDK. The second stage copies that JAR into a slimmer JRE image for running.

  • Create a file called Dockerfile in the root of your url-shortener project folder (at the same level as pom.xml) and paste the following code:
# Stage 1: Build
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:resolve
COPY src/ src/
RUN ./mvnw package -DskipTests

# Stage 2: Run
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]

What does this Dockerfile do?

Stage 1 (Build) uses the full eclipse-temurin:17-jdk image. It copies the Maven Wrapper and pom.xml first to cache dependencies, then copies source code and builds the JAR.

Stage 2 (Run) uses the much smaller eclipse-temurin:17-jre image. It only contains the JRE needed to run your compiled application. The HEALTHCHECK tells Docker to periodically call the Spring Boot Actuator endpoint to confirm the app is healthy.

Why a multi-stage build?

The JDK image is large because it contains the compiler and build tools. Your running application only needs the JRE. By separating build and run stages, you get a much smaller final image (around 300MB vs 700MB+).

  • Save Dockerfile.

Filename issues?

The file must be named exactly Dockerfile with a capital D and no file extension. If you named it dockerfile or Dockerfile.txt, Docker will not find it.

Help me debug my Dockerfile

✔️ Awesome, I've got everything!

Great. Make sure you have saved your Dockerfile before continuing.

ⓧ I'd like to double check the full code

# Stage 1: Build
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:resolve
COPY src/ src/
RUN ./mvnw package -DskipTests

# Stage 2: Run
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
Write docker-compose.yml

Docker Compose lets you define multiple containers as a single unit. Instead of starting PostgreSQL, Redis, and the app separately, one command brings them all up together with proper networking.

  • Create a file called docker-compose.yml in the root of your url-shortener project folder (next to Dockerfile) and paste the following code:
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/urlshortener
      - SPRING_DATASOURCE_USERNAME=postgres
      - SPRING_DATASOURCE_PASSWORD=postgres
      - SPRING_DATA_REDIS_HOST=redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=urlshortener
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

What does this compose file do?

  • app builds from your Dockerfile, maps port 8080, and overrides the Spring Boot database/Redis connection properties to point to the container service names (postgres and redis) instead of localhost.
  • postgres runs PostgreSQL 16 with a named volume (postgres_data) so data survives container restarts.
  • redis runs Redis 7 for caching.
  • depends_on with condition: service_healthy ensures the app only starts after PostgreSQL and Redis are confirmed healthy.

Why environment variables override application.properties?

Spring Boot automatically converts environment variables like SPRING_DATASOURCE_URL to properties like spring.datasource.url. This means your application.properties still works for local development (pointing to localhost), while Docker Compose overrides those values to point to container service names.

  • Save docker-compose.yml.

YAML indentation errors?

YAML is sensitive to indentation. Make sure you use spaces (not tabs) and that nested keys are indented consistently with two spaces. If Docker Compose gives you a parsing error, check for mixed tabs and spaces.

Help me fix my docker-compose.yml

✔️ Awesome, I've got everything!

Great. Make sure you have saved your docker-compose.yml before continuing.

ⓧ I'd like to double check the full code

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/urlshortener
      - SPRING_DATASOURCE_USERNAME=postgres
      - SPRING_DATASOURCE_PASSWORD=postgres
      - SPRING_DATA_REDIS_HOST=redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=urlshortener
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
Build and run the full stack

Now you can bring up all three services with a single command. Docker Compose builds the app image, pulls PostgreSQL and Redis images, then starts everything in the correct order.

  • Make sure Docker Desktop is running on your machine.
  • Start the full stack by running this command in your terminal (from the url-shortener folder):
docker compose up --build -d

What does this command do?

  • docker compose up creates and starts all services defined in docker-compose.yml.
  • --build tells Docker to build the app image from the Dockerfile before starting.
  • -d runs the containers in the background (detached mode) so your terminal stays free.

The first build takes a few minutes as Docker downloads the base images and Maven downloads dependencies. Subsequent builds use cached layers and are much faster.

  • Wait about 1-2 minutes for everything to start, then verify all containers are healthy by running:
docker compose ps

You should see three containers listed, all with a status of "Up" or "healthy".

  • Test the shorten endpoint by running:
curl -X POST http://localhost:8080/api/shorten -H "Content-Type: application/json" -d '{"url": "https://www.example.com/very/long/path"}'

You should see a JSON response containing a shortCode, shortUrl, and originalUrl.

  • Test the redirect endpoint by running (replace 1 with the short code you received):
curl -v http://localhost:8080/1

You should see a 302 redirect with a Location header pointing to the original URL.

  • Test the stats endpoint by running:
curl http://localhost:8080/api/stats/1

You should see a JSON response with the hitCount incremented from the redirect you just performed.

  • Verify data persistence by restarting the stack. Run:
docker compose down
docker compose up -d
  • After the containers are back up, run the stats check again:
curl http://localhost:8080/api/stats/1

Your data should still be there because the postgres_data volume preserves the database between restarts.

Containers not starting?

  • Run docker compose logs app to check the Spring Boot application logs for errors.
  • If you see a connection refused error, the health checks may not have finished yet. Wait 30 seconds and try docker compose ps again.
  • If port 5432 or 6379 is already in use, stop any local PostgreSQL or Redis processes before running docker compose up.

Help me troubleshoot my Docker Compose setup

  • Commit and push your new Docker files by running:
git add Dockerfile docker-compose.yml
git commit -m "Add Docker containerization with multi-stage build and compose"
git push

Your application is now fully containerized. Next up, you will set up a CI/CD pipeline with GitHub Actions to automatically build, test, and package your app on every push.

Set Up CI/CD with GitHub Actions

Your URL shortener runs perfectly in Docker Compose on your machine. But every time you push new code, how do you know it still compiles, passes tests, and builds into a valid Docker image?

That's where CI/CD comes in. In this step, you'll create a GitHub Actions workflow that automatically builds, tests, and packages your application every time you push to main.

In this step, get ready to:
  • Create a GitHub Actions workflow file.
  • Configure service containers for integration testing.
  • Verify your pipeline runs successfully on GitHub.
Create the workflow file

GitHub Actions workflows live in a special directory at .github/workflows/ inside your repository. GitHub automatically detects any YAML file in this folder and runs it as a workflow.

  • Create the required directory structure by running these commands:
mkdir -p .github/workflows
  • Create a new file called ci.yml inside .github/workflows/ in VS Code.
  • Add the following workflow configuration to ci.yml:
# Workflow name displayed in the GitHub Actions tab
name: CI Pipeline

# Trigger on pushes and pull requests to main
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    # Run on a GitHub-hosted Ubuntu runner
    runs-on: ubuntu-latest

    steps:
      # Check out the repository code
      - uses: actions/checkout@v4

      # Install Java 17 with Maven caching
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      # Build and run all tests
      - name: Build and test
        run: ./mvnw clean verify
        env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/urlshortener
          SPRING_DATASOURCE_USERNAME: postgres
          SPRING_DATASOURCE_PASSWORD: postgres
          SPRING_DATA_REDIS_HOST: localhost

What does this workflow do?

The on section triggers this workflow every time you push to main or open a pull request targeting main.

The steps section checks out your code, installs Java 17 (with Maven dependency caching for faster builds), and runs ./mvnw clean verify to compile and test. The env block passes database connection details so Spring Boot can connect during tests.

  • Save ci.yml.

Why localhost for the database URL?

When a GitHub Actions job runs directly on the runner machine (not inside a container), service containers map their ports to the host. Your Spring Boot tests connect via localhost and the mapped port, just like running locally.

Add service containers for integration tests

The build step needs real PostgreSQL and Redis instances to run integration tests against. GitHub Actions can spin up Docker service containers alongside your job automatically.

  • In ci.yml, add the following services block between the runs-on: ubuntu-latest line and the steps: line:
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: urlshortener
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

What are service containers?

Service containers are Docker containers that GitHub spins up alongside your job. They provide real database instances for your tests to run against. The options field passes Docker health check flags so GitHub waits until the service is ready before running your steps.

The ports mapping exposes the container port to localhost on the runner. This matches the SPRING_DATASOURCE_URL you configured in the build step.

  • Now add the Docker build step at the end of the steps: list, after the "Build and test" step:
      - name: Build Docker image
        run: docker build -t url-shortener:latest .

Why build the Docker image in CI?

This verifies your Dockerfile still works after any code changes. If the multi-stage build breaks, the pipeline catches it before you try to deploy.

  • Save ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the indentation looks correct.

ⓧ I'd like to double check the full code

Here is the complete ci.yml file:

name: CI Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: urlshortener
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build and test
        run: ./mvnw clean verify
        env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/urlshortener
          SPRING_DATASOURCE_USERNAME: postgres
          SPRING_DATASOURCE_PASSWORD: postgres
          SPRING_DATA_REDIS_HOST: localhost

      - name: Build Docker image
        run: docker build -t url-shortener:latest .
Push and verify the pipeline

With the workflow file ready, you need to commit and push it to GitHub so the pipeline triggers.

  • Stage, commit, and push the workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI/CD pipeline with GitHub Actions"
git push origin main

What happens after you push?

Because the workflow triggers on push to main, GitHub immediately starts running your pipeline. It provisions an Ubuntu runner, starts your PostgreSQL and Redis service containers, builds the project, runs tests, and builds the Docker image.

  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of the repository.
  • Click on the latest workflow run titled Add CI/CD pipeline with GitHub Actions.
  • Click the build job to see the live logs.

You should see each step complete with a green checkmark: checkout, Java setup, build and test, and Docker image build.

Pipeline failing?

Check the error log in the Actions tab. Common issues:

  • Indentation errors in YAML. Make sure services is at the same indentation level as steps (both nested under build).
  • Permission denied on ./mvnw. Make sure the Maven Wrapper file is committed to your repository. Check that .gitignore is not excluding the .mvn/ directory.
  • Test failures related to database connections. Verify the env block under the "Build and test" step matches the credentials in the services section.

Help me debug my GitHub Actions pipeline failure.

Your code is now automatically built and tested on every push. Next up, you'll add health monitoring so both Docker and your team can see whether the application and its dependencies are healthy.

Add Monitoring and Health Checks

Your CI/CD pipeline is automated and passing. Now let's focus on the last piece of a production-ready system: knowing whether your application is actually healthy at runtime.

In production, containers crash, databases lose connections, and caches go offline. Health checks let both you and Docker detect these failures automatically. Spring Boot Actuator provides built-in health endpoints, and Docker uses its own health check mechanism to restart unhealthy containers.

In this step, get ready to:
  • Verify the Spring Boot Actuator health endpoint reports UP status with database and Redis details.
  • Confirm Docker health checks are monitoring all three services and restarting failures.
Verify the Spring Boot Actuator health endpoint

Spring Boot Actuator exposes HTTP endpoints that report on your application's health, metrics, and info. You already have the Actuator configuration in your application.properties file from earlier steps.

  • In VS Code, open src/main/resources/application.properties and find the Actuator section at the bottom:
# Actuator
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always

What do these Actuator properties do?

  • management.endpoints.web.exposure.include=health,info,metrics exposes three endpoints over HTTP: /actuator/health, /actuator/info, and /actuator/metrics.
  • management.endpoint.health.show-details=always tells the health endpoint to include component-level details (database status, Redis status) instead of just a simple UP/DOWN.

Now let's verify the health endpoint is working with your full stack running.

  • Return to the terminal in your project directory and rebuild the full stack by running:
docker compose up --build -d

What does this command do?

The --build flag rebuilds the application image to pick up any changes. The -d flag runs all containers in detached mode so you get your terminal back.

  • Wait about 30 seconds for all services to start up, then query the health endpoint by running:
curl http://localhost:8080/actuator/health

You should see a JSON response showing the overall status as UP, with details for both your database and Redis connections.

What should I see?

The response includes a top-level "status": "UP" field and a components object. Inside components, you'll see entries for db (PostgreSQL) and redis, each with their own "status": "UP" and connection details.

If either component reports DOWN, the overall status changes to DOWN. This is how monitoring systems detect problems.

Seeing connection refused or DOWN status?

  • If curl fails entirely, the app container may still be starting. Wait another 30 seconds and try again.
  • If db shows DOWN, check that the PostgreSQL container is running with docker compose ps.
  • If redis shows DOWN, the Redis container might have failed. Run docker compose logs redis to check for errors.

Help me debug my Actuator health endpoint returning DOWN status.

Confirm Docker health checks are working

Actuator tells you the application's perspective on health. Docker health checks give the container orchestrator its own independent way to verify each service is responsive.

  • In VS Code, open your Dockerfile and find the HEALTHCHECK instruction near the bottom:
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1

What does the Docker HEALTHCHECK do?

  • --interval=30s runs the check every 30 seconds.
  • --timeout=3s fails the check if it takes longer than 3 seconds to respond.
  • --start-period=30s gives the application 30 seconds to start up before health checks begin counting failures.
  • --retries=3 marks the container as unhealthy only after 3 consecutive failures.

The command itself uses curl to hit the same Actuator health endpoint. If it returns a non-200 response, Docker marks the container unhealthy.

  • Now open docker-compose.yml and review the health check configurations for the postgres and redis services:
  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=urlshortener
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

How do these health checks work together?

PostgreSQL uses pg_isready to verify it can accept connections. Redis uses redis-cli ping to confirm it's responsive.

The app service uses depends_on with condition: service_healthy so it only starts after both dependencies pass their health checks. Combined with restart: unless-stopped, Docker automatically restarts the app container if it becomes unhealthy.

  • Verify all containers are reporting healthy by running:
docker compose ps

You should see all three services listed with a status that includes (healthy) next to each one.

Containers showing as unhealthy or starting?

  • If a container shows (health: starting), it hasn't completed its first health check cycle yet. Wait 30-60 seconds and run docker compose ps again.
  • If the app container is unhealthy, check its logs with docker compose logs app. A missing curl binary in the JRE image is a common cause.
  • If containers are restarting in a loop, run docker compose down and then docker compose up --build -d for a clean start.

Help me troubleshoot my Docker containers not reporting healthy.

  • Commit and push your changes by running:
git add -A && git commit -m "Add monitoring and health checks" && git push

Why commit here?

Even though the health check configurations were part of your earlier file creation, this commit message explicitly documents that you've verified monitoring is working. Clean commit history helps teammates understand the state of the system at each point.

✔️ Awesome, I've got everything!

Great. Make sure all three containers show healthy status before moving on.

ⓧ I'd like to double check the full code

Here is the full application.properties file:

# Application
spring.application.name=url-shortener
server.port=8080

# PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/urlshortener
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

# Redis
spring.data.redis.host=localhost
spring.data.redis.port=6379

# Actuator
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always

Here is the full Dockerfile:

# Stage 1: Build
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:resolve
COPY src/ src/
RUN ./mvnw package -DskipTests

# Stage 2: Run
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]

Here is the full docker-compose.yml:

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/urlshortener
      - SPRING_DATASOURCE_USERNAME=postgres
      - SPRING_DATASOURCE_PASSWORD=postgres
      - SPRING_DATA_REDIS_HOST=redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=urlshortener
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

Your application now has full observability. Docker monitors each container independently, restarts failures, and your Actuator endpoint gives you a single URL to check the health of your entire system. Next up, you'll tackle the Secret Mission and add rate limiting to protect your API from abuse.

Secret mission

Implement Rate Limiting

Your URL shortener works great, but what happens when someone writes a script that fires thousands of shorten requests per second? Without rate limiting, a single abusive client could overwhelm your database and Redis cache. Time to add a defensive layer that protects your API from abuse.

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, PostgreSQL, Redis) running via Docker Compose.
  • Docker images and volumes consuming disk space.
  • Local project files in the url-shortener folder.
  • GitHub public repository (free, no ongoing cost).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the URL shortener.

  • Your Docker Compose stack stays running and uses RAM while active.
  • Your GitHub repository and Actions pipeline remain available for future pushes.
  • Data persists in the PostgreSQL volume between container restarts.

Pause - I'll come back to this later

Shut down running containers to free up memory without losing your data. You can restart everything later with a single command.

  • Stop all containers by running:
docker compose stop
  • Your volumes and images remain on disk. When you want to resume, run:
docker compose start

What does this keep?

Stopping preserves your containers, volumes, and images. Your shortened URLs and hit counts remain in the PostgreSQL volume. Only RAM is freed up.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes any stored URL data.

  • Stop and remove all containers and volumes by running:
docker compose down -v
  • Reclaim disk space from downloaded Docker images by running:
docker system prune

What does docker system prune remove?

This removes all stopped containers, unused networks, dangling images, and build cache. It does not remove named volumes (the previous command already handled that with the -v flag).

  • Delete the local project folder by running:
rm -rf url-shortener
  • To delete the GitHub repository, navigate to your repository page on GitHub.
  • Click Settings in the top menu.
  • Scroll down to the Danger Zone section at the bottom.
  • Click Delete this repository and follow the confirmation prompts.

That's a Wrap!

That's a Wrap!

Nice work! You just built a URL shortener from scratch and took it through the full system design lifecycle.

You've learned how to:

  • Document system requirements, API contracts, and architecture decisions in a DESIGN.md before writing any code.
  • Build a Spring Boot REST API with Base62 short code generation, PostgreSQL persistence, and Redis caching using the cache-aside pattern.
  • Containerize a multi-service application with a multi-stage Dockerfile and Docker Compose, then automate builds and tests with a GitHub Actions CI/CD pipeline.
  • Implement Redis-based API rate limiting to prevent abuse of public endpoints.

Ready to quiz yourself?