Dockerize a Spring Boot URL Shortener
Build and deploy a containerized URL shortener REST API with Docker and Spring Boot.
Introduction
30 Second Summary
Every time you click a shortened link, a server somewhere looks up a short code, finds the original URL, and redirects you in milliseconds. Building this system from scratch is one of the most common system design interview questions.
In this project, you will build a URL shortener REST API with Spring Boot, containerize it with Docker, and deploy it to a live public URL that anyone can use.
What You'll Build
A URL Shortener REST API that accepts long URLs, generates short Base62 codes, redirects visitors, and tracks basic click analytics.
By the end of this project, you'll have:
- A working Spring Boot REST API with endpoints for creating, resolving, and listing shortened URLs.
- A multi-stage Dockerfile and docker-compose.yml for local containerized development with PostgreSQL.
- A live deployment on Render with Swagger API documentation accessible at a public URL.
- Secret Mission: Add click analytics that track visitor metadata on each redirect.
Are there any prerequisites?
You should be comfortable with Spring Boot, Maven, and basic REST APIs. You will also need JDK 17+ and Maven installed on your machine.
Build the URL Shortener REST API
Your project skeleton is ready. Now you need to build the actual logic that turns long URLs into short codes and redirects visitors back.
In this step you will create the data model, the encoding logic, and the REST endpoints that make the URL shortener work. By the end you will have a running API you can test in your browser.
In this step, get ready to:
- Create a JPA entity and repository for storing shortened URLs.
- Implement a service that generates random Base62 short codes.
- Build a REST controller with endpoints for creating, resolving, and listing shortened URLs.
Create the data model and repository
Every shortened URL needs a home in the database. You will create a JPA entity that maps a Java object to a database table, plus a repository interface that handles all the database queries for you.
- In your IDE, create a new package called model inside src/main/java/com/urlshortener/urlshortener/.
- Create a new file called ShortenedUrl.java in that package and paste the following code:
package com.urlshortener.urlshortener.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "shortened_urls")
public class ShortenedUrl {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 10)
private String shortCode;
@Column(nullable = false, length = 2048)
private String originalUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
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 code do?
The @Entity annotation tells Spring Data JPA to map this class to a database table called shortened_urls. Each field becomes a column.
The @PrePersist method automatically sets createdAt to the current time whenever a new row is saved. The shortCode column is marked unique so the database rejects duplicate codes.
- Save ShortenedUrl.java.
- Create a new package called repository inside src/main/java/com/urlshortener/urlshortener/.
- Create a new file called ShortenedUrlRepository.java in that package and paste the following code:
package com.urlshortener.urlshortener.repository;
import com.urlshortener.urlshortener.model.ShortenedUrl;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ShortenedUrlRepository extends JpaRepository<ShortenedUrl, Long> {
Optional<ShortenedUrl> findByShortCode(String shortCode);
boolean existsByShortCode(String shortCode);
}
What does this code do?
By extending JpaRepository, Spring automatically generates all the standard CRUD methods (save, findAll, delete) at runtime. You only need to declare the custom queries.
The method findByShortCode looks up a URL by its short code. The method existsByShortCode checks for collisions when generating new codes.
- Save ShortenedUrlRepository.java.
Seeing import errors?
Make sure your package declaration matches the folder structure exactly. The package should be com.urlshortener.urlshortener.model for the entity and com.urlshortener.urlshortener.repository for the repository.
If your IDE still shows red underlines, try running mvn clean compile in your terminal to force a refresh.
Help me fix import errors in my Spring Boot entity or repository.
Implement the shortening service with Base62 encoding
The service layer holds the core business logic. It generates random short codes using Base62 encoding (characters a-z, A-Z, 0-9) and checks for collisions before saving.
- Create a new package called dto inside src/main/java/com/urlshortener/urlshortener/.
- Create a new file called ShortenRequest.java in the dto package and paste this code:
package com.urlshortener.urlshortener.dto;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.URL;
public class ShortenRequest {
@NotBlank(message = "URL is required")
@URL(message = "Must be a valid URL")
private String url;
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
}
What does this code do?
This is a Data Transfer Object (DTO) that defines the shape of incoming requests. The @NotBlank annotation rejects empty strings. The @URL annotation validates that the input is a properly formatted URL.
Spring automatically returns a 400 Bad Request if validation fails, so your API rejects invalid input before it ever reaches the service.
- Save ShortenRequest.java.
- Create another file called ShortenResponse.java in the same dto package and paste this code:
package com.urlshortener.urlshortener.dto;
import java.time.LocalDateTime;
public class ShortenResponse {
private String shortCode;
private String shortUrl;
private String originalUrl;
private LocalDateTime createdAt;
public ShortenResponse(String shortCode, String shortUrl, String originalUrl, LocalDateTime createdAt) {
this.shortCode = shortCode;
this.shortUrl = shortUrl;
this.originalUrl = originalUrl;
this.createdAt = createdAt;
}
public String getShortCode() { return shortCode; }
public String getShortUrl() { return shortUrl; }
public String getOriginalUrl() { return originalUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
What does this code do?
This DTO defines what the API sends back to callers. It includes the generated shortCode, the full shortUrl (base URL + code), the originalUrl, and a timestamp. This keeps internal database details (like the auto-generated ID) hidden from API consumers.
- Save ShortenResponse.java.
- Create a new package called service inside src/main/java/com/urlshortener/urlshortener/.
- Create a new file called UrlShortenerService.java in the service package and paste this code:
package com.urlshortener.urlshortener.service;
import com.urlshortener.urlshortener.model.ShortenedUrl;
import com.urlshortener.urlshortener.repository.ShortenedUrlRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.util.Optional;
@Service
public class UrlShortenerService {
private static final String BASE62_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final int CODE_LENGTH = 7;
private final SecureRandom random = new SecureRandom();
private final ShortenedUrlRepository repository;
public UrlShortenerService(ShortenedUrlRepository repository) {
this.repository = repository;
}
public ShortenedUrl shortenUrl(String originalUrl) {
String shortCode = generateUniqueCode();
ShortenedUrl shortenedUrl = new ShortenedUrl();
shortenedUrl.setShortCode(shortCode);
shortenedUrl.setOriginalUrl(originalUrl);
return repository.save(shortenedUrl);
}
public Optional<ShortenedUrl> resolveUrl(String shortCode) {
return repository.findByShortCode(shortCode);
}
public Page<ShortenedUrl> listAll(Pageable pageable) {
return repository.findAll(pageable);
}
private String generateUniqueCode() {
String code;
do {
code = generateBase62Code();
} while (repository.existsByShortCode(code));
return code;
}
private String generateBase62Code() {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(BASE62_CHARS.charAt(random.nextInt(BASE62_CHARS.length())));
}
return sb.toString();
}
}
What does this code do?
The generateBase62Code() method picks 7 random characters from the 62-character alphabet (a-z, A-Z, 0-9). With 62^7 possible combinations (over 3.5 trillion), collisions are extremely rare.
The generateUniqueCode() method uses a do-while loop to regenerate if a collision happens. The shortenUrl() method ties it all together by generating a code and persisting the new entry.
Why random codes instead of sequential IDs?
Sequential IDs (like encoding the database row number) are predictable. Anyone could guess what the next short URL will be or enumerate all existing ones.
Random Base62 codes are non-guessable, which adds a layer of privacy for your users. The trade-off is that you need collision checking, but with 3.5 trillion possible codes, the chance of a collision is negligible.
- Save UrlShortenerService.java.
SecureRandom import not resolving?
Make sure you import java.security.SecureRandom (not java.util.Random). SecureRandom is part of the standard JDK and does not require any extra dependencies.
Help me fix imports in UrlShortenerService.
Build the REST controller with three endpoints
The controller is the front door of your API. It maps HTTP requests to service methods and returns appropriate responses.
- Create a new package called controller inside src/main/java/com/urlshortener/urlshortener/.
- Create a new file called UrlShortenerController.java in the controller package and paste this code:
package com.urlshortener.urlshortener.controller;
import com.urlshortener.urlshortener.dto.ShortenRequest;
import com.urlshortener.urlshortener.dto.ShortenResponse;
import com.urlshortener.urlshortener.model.ShortenedUrl;
import com.urlshortener.urlshortener.service.UrlShortenerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@Tag(name = "URL Shortener", description = "API for shortening URLs and resolving short codes")
public class UrlShortenerController {
private final UrlShortenerService service;
public UrlShortenerController(UrlShortenerService service) {
this.service = service;
}
@PostMapping("/api/shorten")
@Operation(summary = "Shorten a URL", description = "Accepts a long URL and returns a short code")
public ResponseEntity<ShortenResponse> shortenUrl(
@Valid @RequestBody ShortenRequest request,
HttpServletRequest httpRequest) {
ShortenedUrl shortened = service.shortenUrl(request.getUrl());
String baseUrl = httpRequest.getRequestURL().toString().replace("/api/shorten", "");
ShortenResponse response = new ShortenResponse(
shortened.getShortCode(),
baseUrl + "/" + shortened.getShortCode(),
shortened.getOriginalUrl(),
shortened.getCreatedAt()
);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{shortCode}")
@Operation(summary = "Redirect to original URL", description = "Resolves a short code and redirects to the original URL")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
return service.resolveUrl(shortCode)
.map(url -> ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, url.getOriginalUrl())
.<Void>build())
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/api/urls")
@Operation(summary = "List all shortened URLs", description = "Returns a paginated list of all shortened URLs")
public ResponseEntity<Page<ShortenResponse>> listUrls(Pageable pageable, HttpServletRequest httpRequest) {
String baseUrl = httpRequest.getRequestURL().toString().replace("/api/urls", "");
Page<ShortenResponse> responses = service.listAll(pageable).map(url ->
new ShortenResponse(url.getShortCode(), baseUrl + "/" + url.getShortCode(),
url.getOriginalUrl(), url.getCreatedAt()));
return ResponseEntity.ok(responses);
}
}
What does this code do?
- POST /api/shorten accepts a JSON body with a URL, generates a short code, and returns a 201 Created response with the full short URL.
- GET /{shortCode} looks up the short code and sends an HTTP 302 redirect to the original URL. If the code does not exist, it returns 404.
- GET /api/urls returns a paginated list of all shortened URLs. Spring handles pagination automatically through the Pageable parameter.
The @Operation annotations feed into Swagger so each endpoint appears with a summary in the auto-generated docs.
- Save UrlShortenerController.java.
Now configure the application properties so Spring Boot uses H2 for local testing.
- Open src/main/resources/application.properties and replace its contents with the following:
spring.application.name=url-shortener
server.port=${PORT:8080}
# Default (local H2 for quick testing without Docker)
spring.datasource.url=${DATABASE_URL:jdbc:h2:mem:urlshortener}
spring.datasource.username=${DATABASE_USERNAME:sa}
spring.datasource.password=${DATABASE_PASSWORD:}
spring.datasource.driver-class-name=${DB_DRIVER:org.h2.Driver}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
# H2 console for local dev
spring.h2.console.enabled=true
What does this configuration do?
Each property uses the ${ENV_VAR:default} syntax. When no environment variable is set, Spring falls back to the H2 in-memory database. Later you will swap in PostgreSQL by setting environment variables without changing any code.
The ddl-auto=update setting tells Hibernate to create or update tables automatically based on your entity classes. The H2 console is enabled so you can browse your in-memory database at /h2-console during development.
- Save application.properties.
- Run the application by executing this command in your terminal from the project root:
mvn spring-boot:run
- Open your browser and go to http://localhost:8080/swagger-ui.html.
You should see the Swagger UI with three endpoints listed under the "URL Shortener" tag.
- Click on POST /api/shorten to expand it.
- Click Try it out.
- Replace the request body with the following JSON:
{
"url": "https://www.example.com/very/long/path/to/a/page"
}
- Click Execute.
You should see a 201 response with a JSON body containing the shortCode, shortUrl, and originalUrl fields.
Application fails to start?
Check that every file is in the correct package. The main application class UrlShortenerApplication.java must be in the root package com.urlshortener.urlshortener so Spring Boot can scan all sub-packages (model, repository, service, controller).
If you see a database connection error, make sure the H2 dependency is listed in your pom.xml with <scope>runtime</scope>.
Help me fix my Spring Boot application startup error.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files, then move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point in the project.
package com.urlshortener.urlshortener.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "shortened_urls")
public class ShortenedUrl {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 10)
private String shortCode;
@Column(nullable = false, length = 2048)
private String originalUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
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; }
}
package com.urlshortener.urlshortener.repository;
import com.urlshortener.urlshortener.model.ShortenedUrl;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ShortenedUrlRepository extends JpaRepository<ShortenedUrl, Long> {
Optional<ShortenedUrl> findByShortCode(String shortCode);
boolean existsByShortCode(String shortCode);
}
package com.urlshortener.urlshortener.dto;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.URL;
public class ShortenRequest {
@NotBlank(message = "URL is required")
@URL(message = "Must be a valid URL")
private String url;
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
}
package com.urlshortener.urlshortener.dto;
import java.time.LocalDateTime;
public class ShortenResponse {
private String shortCode;
private String shortUrl;
private String originalUrl;
private LocalDateTime createdAt;
public ShortenResponse(String shortCode, String shortUrl, String originalUrl, LocalDateTime createdAt) {
this.shortCode = shortCode;
this.shortUrl = shortUrl;
this.originalUrl = originalUrl;
this.createdAt = createdAt;
}
public String getShortCode() { return shortCode; }
public String getShortUrl() { return shortUrl; }
public String getOriginalUrl() { return originalUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
package com.urlshortener.urlshortener.service;
import com.urlshortener.urlshortener.model.ShortenedUrl;
import com.urlshortener.urlshortener.repository.ShortenedUrlRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.util.Optional;
@Service
public class UrlShortenerService {
private static final String BASE62_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final int CODE_LENGTH = 7;
private final SecureRandom random = new SecureRandom();
private final ShortenedUrlRepository repository;
public UrlShortenerService(ShortenedUrlRepository repository) {
this.repository = repository;
}
public ShortenedUrl shortenUrl(String originalUrl) {
String shortCode = generateUniqueCode();
ShortenedUrl shortenedUrl = new ShortenedUrl();
shortenedUrl.setShortCode(shortCode);
shortenedUrl.setOriginalUrl(originalUrl);
return repository.save(shortenedUrl);
}
public Optional<ShortenedUrl> resolveUrl(String shortCode) {
return repository.findByShortCode(shortCode);
}
public Page<ShortenedUrl> listAll(Pageable pageable) {
return repository.findAll(pageable);
}
private String generateUniqueCode() {
String code;
do {
code = generateBase62Code();
} while (repository.existsByShortCode(code));
return code;
}
private String generateBase62Code() {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(BASE62_CHARS.charAt(random.nextInt(BASE62_CHARS.length())));
}
return sb.toString();
}
}
package com.urlshortener.urlshortener.controller;
import com.urlshortener.urlshortener.dto.ShortenRequest;
import com.urlshortener.urlshortener.dto.ShortenResponse;
import com.urlshortener.urlshortener.model.ShortenedUrl;
import com.urlshortener.urlshortener.service.UrlShortenerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@Tag(name = "URL Shortener", description = "API for shortening URLs and resolving short codes")
public class UrlShortenerController {
private final UrlShortenerService service;
public UrlShortenerController(UrlShortenerService service) {
this.service = service;
}
@PostMapping("/api/shorten")
@Operation(summary = "Shorten a URL", description = "Accepts a long URL and returns a short code")
public ResponseEntity<ShortenResponse> shortenUrl(
@Valid @RequestBody ShortenRequest request,
HttpServletRequest httpRequest) {
ShortenedUrl shortened = service.shortenUrl(request.getUrl());
String baseUrl = httpRequest.getRequestURL().toString().replace("/api/shorten", "");
ShortenResponse response = new ShortenResponse(
shortened.getShortCode(),
baseUrl + "/" + shortened.getShortCode(),
shortened.getOriginalUrl(),
shortened.getCreatedAt()
);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{shortCode}")
@Operation(summary = "Redirect to original URL", description = "Resolves a short code and redirects to the original URL")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
return service.resolveUrl(shortCode)
.map(url -> ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, url.getOriginalUrl())
.<Void>build())
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/api/urls")
@Operation(summary = "List all shortened URLs", description = "Returns a paginated list of all shortened URLs")
public ResponseEntity<Page<ShortenResponse>> listUrls(Pageable pageable, HttpServletRequest httpRequest) {
String baseUrl = httpRequest.getRequestURL().toString().replace("/api/urls", "");
Page<ShortenResponse> responses = service.listAll(pageable).map(url ->
new ShortenResponse(url.getShortCode(), baseUrl + "/" + url.getShortCode(),
url.getOriginalUrl(), url.getCreatedAt()));
return ResponseEntity.ok(responses);
}
}
spring.application.name=url-shortener
server.port=${PORT:8080}
# Default (local H2 for quick testing without Docker)
spring.datasource.url=${DATABASE_URL:jdbc:h2:mem:urlshortener}
spring.datasource.username=${DATABASE_USERNAME:sa}
spring.datasource.password=${DATABASE_PASSWORD:}
spring.datasource.driver-class-name=${DB_DRIVER:org.h2.Driver}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
# H2 console for local dev
spring.h2.console.enabled=true
Your URL shortener API is live and working locally with an in-memory database. Next up, you will containerize it with Docker so it runs the same way everywhere.
Dockerize with a Multi-Stage Dockerfile and docker-compose
Your URL shortener API works locally, but it only runs on your machine with an in-memory database. To share it with the world, you need a portable, reproducible way to package and run your application.
Docker solves this by packaging your app and its dependencies into lightweight containers that run identically everywhere. In this step, you'll write a multi-stage Dockerfile, set up docker-compose for local development with a real PostgreSQL database, and see your URL shortener running in containers.
In this step, get ready to:
- Write a multi-stage Dockerfile that builds and runs your Spring Boot app.
- Create a docker-compose.yml that orchestrates your app and a PostgreSQL database together.
- Build and run the full stack locally, verifying everything works end-to-end.
Write a multi-stage Dockerfile
A multi-stage Docker build splits your image into two phases. The first stage compiles your code using a full JDK, then the second stage copies only the finished JAR into a minimal runtime image.
- Create a new file called Dockerfile in the root of your url-shortener project folder (the same level as pom.xml).
- Paste the following into Dockerfile:
# Stage 1: Build
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
COPY .mvn ./.mvn
COPY mvnw .
RUN chmod +x mvnw && ./mvnw clean package -DskipTests
# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this Dockerfile do?
Stage 1 (builder) uses a full JDK image to compile your source code and produce a JAR file with Maven. It uses the Maven wrapper (mvnw) so the container doesn't need Maven pre-installed.
Stage 2 (runtime) starts fresh from a tiny Alpine-based JRE image and copies only the finished JAR. This keeps your final image small and secure, since build tools and source code are discarded.
Why multi-stage instead of a single stage?
A single-stage build would include the full JDK, Maven, and all source code in the final image. That can balloon your image to 800+ MB. Multi-stage builds produce a runtime image closer to 200 MB, which is faster to push, pull, and deploy.
- Save Dockerfile.
You won't build the image directly yet. The docker-compose file you create next handles the build for you.
Not sure if the file is named correctly?
The file must be named exactly Dockerfile with a capital D and no file extension. If your IDE added .txt or saved it as dockerfile (lowercase), Docker will not find it.
help me verify my Dockerfile is named correctly
Next, you need a .dockerignore file to prevent unnecessary files from being sent to Docker during the build.
- Create a file called .dockerignore in the same directory as your Dockerfile.
- Paste the following exclusion patterns into .dockerignore:
target/
.git/
.idea/
*.iml
.mvn/repository
What does .dockerignore do?
When Docker builds an image, it sends all files in the current directory to the Docker daemon. The .dockerignore file tells Docker to skip files that the build doesn't need.
Excluding target/ prevents sending your locally compiled classes (the Dockerfile rebuilds from source). Excluding .git/ and IDE files keeps your build context small and fast.
- Save .dockerignore.
Create docker-compose.yml for local development
Your Dockerfile packages the Spring Boot app, but the app also needs a PostgreSQL database. Docker Compose lets you define both services in a single file and start them together with one command.
- Create a file called docker-compose.yml in the root of your url-shortener project folder (same level as Dockerfile).
- Paste the following into docker-compose.yml:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
app:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: jdbc:postgresql://db:5432/urlshortener
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: postgres
DB_DRIVER: org.postgresql.Driver
depends_on:
- db
volumes:
pgdata:
What does this compose file do?
- db service: runs a lightweight PostgreSQL container using the postgres:16-alpine image. The named volume pgdata persists data between container restarts.
- app service: builds your Spring Boot app from the Dockerfile and connects it to the database. The depends_on field ensures the database container starts before the app.
- The environment variables (DATABASE_URL, DATABASE_USERNAME, DATABASE_PASSWORD, DB_DRIVER) override the H2 defaults in your application.properties, pointing Spring Boot to the PostgreSQL container instead.
Why does this work without changing application.properties?
In the previous step, you configured application.properties to read DATABASE_URL, DATABASE_USERNAME, and DATABASE_PASSWORD from environment variables with H2 fallback defaults. Docker Compose sets those environment variables on the app container, so Spring Boot picks up PostgreSQL automatically.
- Save docker-compose.yml.
YAML syntax errors?
YAML is sensitive to indentation. Make sure you use spaces (not tabs) and that each level is indented by exactly 2 spaces. Misaligned keys will cause Docker Compose to fail with a parsing error.
help me fix a YAML parsing error in my docker-compose.yml
Build and run locally with Docker Compose
With all three files in place, you can now spin up your entire stack in containers.
- Make sure your earlier mvn spring-boot:run process is stopped (press Ctrl+C in that terminal if it's still running). Port 8080 must be free for Docker to use.
- Make sure Docker Desktop is running.
- Build and start your containers by running this command from your url-shortener project folder:
docker compose up --build
What does this command do?
docker compose up starts all services defined in docker-compose.yml. The --build flag forces Docker to rebuild the app image from your Dockerfile before starting. The first build takes a few minutes because Maven downloads all dependencies inside the container.
The first build may take 3-5 minutes as Maven downloads dependencies. Subsequent builds are faster thanks to Docker's layer caching.
- Wait for the logs to show that the Spring Boot app has started. Look for a line like Started UrlShortenerApplication in the terminal output.
- Open your browser and navigate to http://localhost:8080/swagger-ui.html.
You should see the Swagger UI page with all three endpoints listed, just like before. The difference is that your app is now running inside a Docker container, connected to a real PostgreSQL database.
- Test the API by creating a short URL. In Swagger UI, expand the POST /api/shorten endpoint, click Try it out, and submit this request body:
{
"url": "https://docs.docker.com/get-started/"
}
- Copy the shortCode from the response (a 7-character string like aBcDeFg).
- Open a new browser tab and navigate to http://localhost:8080/ followed by your short code.
Your browser should redirect to the Docker documentation page. This confirms your app is creating short URLs in PostgreSQL and resolving them correctly.
App fails to connect to the database?
- Check that Docker Desktop is running. If Docker isn't active, the containers can't start.
- If you see a port conflict error on 8080 or 5432, make sure no other process is using those ports. Stop any running mvn spring-boot:run or local PostgreSQL instances.
- If the app starts but shows database connection errors, the database container may still be initializing. Stop with Ctrl+C and run docker compose up --build again.
help me troubleshoot my Docker Compose setup for the URL shortener
✔️ Awesome, I've got everything!
Great. Double check you have saved all three new files: Dockerfile, .dockerignore, and docker-compose.yml.
ⓧ I'd like to double check the full code
Here are the three new files you should have:
# Stage 1: Build
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
COPY .mvn ./.mvn
COPY mvnw .
RUN chmod +x mvnw && ./mvnw clean package -DskipTests
# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
target/
.git/
.idea/
*.iml
.mvn/repository
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
app:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: jdbc:postgresql://db:5432/urlshortener
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: postgres
DB_DRIVER: org.postgresql.Driver
depends_on:
- db
volumes:
pgdata:
Your URL shortener is now fully containerized and running with a real PostgreSQL database. Next up, you'll connect to a cloud-hosted database and prepare your app for production deployment.
Connect to Neon PostgreSQL and Prepare for Deployment
Your URL shortener runs beautifully inside Docker containers on your local machine. But when you deploy to the cloud, the local PostgreSQL container stays behind.
You need a cloud-hosted database that your app can reach from anywhere. In this step, you will set up a free Neon PostgreSQL database and create a production Spring profile that reads connection details from environment variables.
In this step, get ready to:
- Create a free Neon PostgreSQL database in the cloud.
- Create a production Spring profile that reads database config from environment variables.
- Test the production profile locally to confirm the app connects to Neon.
Create a Neon PostgreSQL database
Neon is a serverless PostgreSQL provider with a permanent free tier. No credit card is required, and the database scales to zero after 5 minutes of inactivity.
- Open https://neon.tech in your browser.
- Click Sign Up and create a free account (you can use GitHub, Google, or email).
- Once signed in, you will land in the Neon Console.
- Click New Project.
- Enter a project name like url-shortener.
- Leave the Postgres version, cloud provider, and region at their defaults.
- Click Create Project.
After creation, Neon directs you to the Project Dashboard.
- On the Project Dashboard, click the Connect button to open the connection details.
- Copy the full connection string. It follows this format:
postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-123456.us-east-2.aws.neon.tech/neondb?sslmode=require
- Note the individual components you will need: the host (everything between @ and /neondb), the database name (neondb), the username (neondb_owner), and the password.
- Save these details somewhere safe. You will need them in the next substep.
What is Neon?
Neon is a serverless PostgreSQL service that separates storage and compute. Your database scales to zero when idle, so the free tier stays free without any manual shutdown.
The free tier gives you 0.5 GB of storage and 100 compute-hours per month. That is more than enough for this project.
Keep your password safe
Neon only shows the full password once during project creation. If you lose it, you can reset it from the Roles section in the Neon Console sidebar.
Configure Spring Boot for production
Spring Boot supports multiple configuration profiles. A prod profile lets you swap in production database settings without touching your local development config.
- In your IDE, create a new file at src/main/resources/application-prod.properties and paste the following code into it:
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
spring.datasource.hikari.maximum-pool-size=3
spring.h2.console.enabled=false
What does this configuration do?
- DATABASE_URL, DATABASE_USERNAME, and DATABASE_PASSWORD are read from environment variables at runtime. This keeps credentials out of your source code.
- spring.datasource.driver-class-name=org.postgresql.Driver tells Spring to use the PostgreSQL JDBC driver instead of H2.
- spring.datasource.hikari.maximum-pool-size=3 limits the connection pool size to stay within Neon's free tier connection limits.
- spring.h2.console.enabled=false disables the H2 console since production uses PostgreSQL.
- Save application-prod.properties.
Why a separate profile instead of editing the existing file?
Your application.properties already defaults to H2 for quick local testing without Docker. The application-prod.properties file activates only when you set SPRING_PROFILES_ACTIVE=prod.
This means you can switch between local H2, Docker PostgreSQL, and cloud Neon without changing any files.
File not being picked up?
Make sure the file is named exactly application-prod.properties (not application-production.properties). Spring Boot uses the suffix after the hyphen as the profile name.
help me debug my Spring Boot profile configuration
Test the production configuration locally
Before deploying, you should confirm that your app can connect to Neon from your local machine. This catches misconfigured connection strings early.
- First, stop your Docker containers if they are running by pressing Ctrl+C in the terminal where docker compose up is active.
- Set the Neon connection details as environment variables and run the app with the prod profile by running this command:
DATABASE_URL=jdbc:postgresql://[[NEON_HOST="your-neon-host.us-east-2.aws.neon.tech"]]/neondb?sslmode=require DATABASE_USERNAME=[[NEON_USER="your-neon-username"]] DATABASE_PASSWORD=your-neon-password-here mvn spring-boot:run -Dspring-boot.run.profiles=prod
What does this command do?
- DATABASE_URL, DATABASE_USERNAME, and DATABASE_PASSWORD are set as inline environment variables for this single command.
- The -Dspring-boot.run.profiles=prod flag tells Spring Boot to load application-prod.properties on top of the base application.properties.
- The JDBC URL uses the format jdbc:postgresql://host/database?sslmode=require which is the standard JDBC form of the Neon connection string.
JDBC vs standard connection string
Neon gives you a connection string starting with postgresql://. Spring Boot's JDBC driver needs it in the form jdbc:postgresql://host/database?sslmode=require. Drop the username and password from the URL (they go in the separate environment variables) and add the jdbc: prefix.
- Wait for the app to start. You should see a log line indicating Hibernate has connected and created the shortened_urls table.
- Open http://localhost:8080/swagger-ui.html in your browser to confirm Swagger UI loads.
- Use the POST /api/shorten endpoint in Swagger UI to shorten a test URL (for example, https://nextwork.org).
Now verify the data made it to Neon.
- Switch back to the Neon Console in your browser.
- Click SQL Editor in the left sidebar.
- Run this query to see your shortened URL:
SELECT * FROM shortened_urls;
- You should see a row with the short code and original URL you just created.
Seeing a connection refused error?
- Double-check that your DATABASE_URL starts with jdbc:postgresql:// and not just postgresql://. Spring's JDBC driver requires the jdbc: prefix.
- Verify that ?sslmode=require is at the end of your JDBC URL. Neon requires SSL connections.
- Make sure Docker Compose is stopped. If port 5432 is in use locally, Spring might try to connect to the wrong database.
help me fix my Neon connection
✔️ Awesome, I've got everything!
Great. Double check you have saved application-prod.properties and confirmed data appears in the Neon SQL Editor.
ⓧ I'd like to double check the full code
Here is the complete application-prod.properties file:
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
spring.datasource.hikari.maximum-pool-size=3
spring.h2.console.enabled=false
And here is application.properties (unchanged from the previous step):
spring.application.name=url-shortener
server.port=${PORT:8080}
# Default (local H2 for quick testing without Docker)
spring.datasource.url=${DATABASE_URL:jdbc:h2:mem:urlshortener}
spring.datasource.username=${DATABASE_USERNAME:sa}
spring.datasource.password=${DATABASE_PASSWORD:}
spring.datasource.driver-class-name=${DB_DRIVER:org.h2.Driver}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
# H2 console for local dev
spring.h2.console.enabled=true
Your app now connects to a real cloud database. Next up, you will push your code to GitHub and deploy the entire application to Render for a live public URL.
Deploy to Render
Your URL shortener is fully built, Dockerized, and connected to a cloud database. The final piece is making it available to the world at a public URL.
In this step, you will push your code to GitHub and deploy it to Render. Render builds your app directly from the Dockerfile, so you do not need to set up a separate build pipeline.
In this step, get ready to:
- Push your project to a new GitHub repository.
- Create a Render Web Service connected to your repository.
- Verify your live URL shortener works at a public .onrender.com address.
Push to GitHub
Render pulls your source code from a Git repository and builds from your Dockerfile automatically. You need a GitHub repository to connect to Render.
- Open your browser and go to https://github.com/new to create a new repository.
- Name the repository url-shortener.
- Leave it set to Public (Render free tier can access public repos without extra setup).
- Click Create repository. Do not initialize with a README or .gitignore since your project already has files.
GitHub will show a page with instructions for pushing an existing repository. You will use those commands next.
- Return to your terminal and make sure you are in the url-shortener project root folder.
- Initialize a Git repository and push all files by running these commands:
git init
git add .
git commit -m "Initial commit: URL shortener with Docker"
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/url-shortener.git
git push -u origin main
What do these commands do?
- git init creates a new Git repository in your project folder.
- git add . stages all files for the first commit.
- git branch -M main renames the default branch to main (which Render expects).
- git push -u origin main uploads your code to GitHub.
- Refresh your GitHub repository page in the browser. You should see all your project files listed.
Push rejected or authentication error?
- If Git asks for credentials, you may need to set up a personal access token or SSH key. GitHub no longer accepts passwords for command-line operations.
- If you see "remote already exists", run git remote set-url origin followed by your repository URL to fix the remote address.
Help me push my Spring Boot project to GitHub
Create a Render Web Service
Render is a cloud platform that can build and host Docker-based applications directly from your GitHub repository. The free tier gives you 512 MB RAM and 0.1 CPU, which is enough to run your Spring Boot app with some memory tuning.
- Open https://render.com and sign up for a free account. No credit card is required.
- In the Render Dashboard, click + New in the top-right corner.
- Select Web Service from the dropdown.
- Select Git Provider and connect your GitHub account if you have not already.
- Find and select your url-shortener repository, then click Connect.
The service creation form appears. Fill it out with these settings:
- Set Language to Docker. This tells Render to build your app using your Dockerfile instead of a native runtime.
- Choose the Free instance type (512 MB RAM, 0.1 CPU).
Why Docker as the Language?
Render does not natively support Java as a runtime. By selecting Docker, Render uses your multi-stage Dockerfile to build the JAR and create the runtime image. This is the standard approach for deploying JVM applications on Render.
Now add the environment variables your app needs in production:
- Expand the Advanced section of the creation form.
- Under Environment Variables, add the following five variables:
- Key: SPRING_PROFILES_ACTIVE with Value: prod.
- Key: DATABASE_URL with Value: your Neon JDBC connection string (format: jdbc:postgresql://host/database?sslmode=require).
- Key: DATABASE_USERNAME with Value: your Neon database username.
- Key: DATABASE_PASSWORD with Value: your Neon database password.
- Key: JAVA_TOOL_OPTIONS with Value: -Xmx350m.
Why set JAVA_TOOL_OPTIONS?
The Free instance type only provides 512 MB of RAM. The JVM defaults to using up to 25% of system memory, but in a container this can cause out-of-memory crashes. Setting -Xmx350m caps the JVM heap at 350 MB, leaving room for non-heap memory and the OS.
- Click Create Web Service at the bottom of the form.
Render kicks off the first build. You can watch the progress in the log explorer on your service's page.
Build takes 5-10 minutes
The first build downloads the JDK base image, runs Maven, and packages the JAR. This is slower than subsequent builds because Docker layer caching is not yet warmed. Subsequent deploys (triggered by git push) will be faster.
Build failing with out-of-memory error?
- Double-check that JAVA_TOOL_OPTIONS is set to -Xmx350m in your environment variables. Without this, the JVM may exceed the 512 MB container limit.
- If the Maven build itself runs out of memory during compilation, try adding MAVEN_OPTS as another environment variable with value -Xmx256m.
Help me fix my Render build failure for a Spring Boot Docker app
Verify the live deployment
Once the build completes and the deploy status shows Live, your API is accessible to anyone on the internet.
- Find your service URL on your Render service page. It follows the format https://your-service-name.onrender.com.
- Open your Swagger UI by visiting your URL with /swagger-ui.html appended (for example, https://your-service-name.onrender.com/swagger-ui.html).
You should see the familiar Swagger UI page, now served from your live Render URL.
- Test the live API by expanding the POST /api/shorten endpoint in Swagger UI.
- Click Try it out, enter a URL like https://nextwork.org in the request body, and click Execute.
- Copy the shortUrl value from the response and paste it into a new browser tab.
The browser should redirect you to the original URL. Your URL shortener is live and working.
First request takes about 60 seconds?
Render free tier services spin down after 15 minutes of inactivity. The first request after a period of inactivity triggers a cold start where Render boots the container. Subsequent requests respond immediately.
Swagger UI not loading?
- Wait for the deploy status on your Render service page to show Live. If it still shows In progress, the build is not done yet.
- Check that SPRING_PROFILES_ACTIVE is set to prod in your environment variables. Without this, the app tries to use H2 which may not connect correctly in production.
- Open the Logs section in the Render Dashboard sidebar to check for startup errors.
Help me debug why my Spring Boot app is not responding on Render
Secret mission
Add Click Analytics
Your URL shortener works, but you have no idea who is clicking your links. In this secret mission, you will add click analytics that record the timestamp, referrer, and user-agent for every redirect visit. Then you will expose a stats endpoint so you can see exactly how each short URL performs.
Clean Up Your Resources
Clean Up Your Resources
This project uses Render (free tier) and Neon PostgreSQL (free tier), both of which scale to zero when idle. There are no ongoing costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Render Web Service (free tier, spins down after 15 min of inactivity).
- Neon PostgreSQL database (free tier, scales to zero after 5 min of inactivity).
- Docker images and volumes (local, stored on your machine).
- Local project folder on your Desktop.
- GitHub repository.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your URL shortener.
- Both Render and Neon are on free tiers and will not incur charges.
- Render free services spin down after 15 minutes of no traffic and take about 60 seconds to wake up on the next request.
- Neon auto-suspends compute after 5 minutes of inactivity.
- Docker Desktop remains installed locally for future container projects.
Pause - I'll come back to this later
Shut down running containers to free up local memory. Both cloud services pause automatically when idle.
- In your terminal, navigate to your project folder and stop the local containers by running:
- Your Render service automatically spins down after 15 minutes with no traffic. You can also suspend it manually from the Render dashboard by selecting your service, opening its Settings page, and toggling the suspend option.
- Neon automatically suspends compute after 5 minutes of inactivity. No manual action required.
docker compose down
Your data and configuration remain intact for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- Delete the Render Web Service: Open the Render dashboard, select your URL shortener service, go to its Settings page, scroll to the bottom, and click Delete. Type the service name to confirm.
- Delete the Neon PostgreSQL project: Navigate to the Neon Console at console.neon.tech. Select your project. Click Settings in the sidebar. Click Delete. This permanently removes all databases, branches, and roles in the project.
- Remove local Docker containers and volumes by running:
- Remove Docker images built during this project by running:
- Delete the local project folder:
- Delete the GitHub repository: Go to your repository on GitHub, click Settings, scroll to the bottom under Danger Zone, and click Delete this repository.
docker compose down -v
docker image prune
macOS
rm -rf ~/Desktop/url-shortener
Windows
rmdir /s /q %USERPROFILE%\Desktop\url-shortener
That's a Wrap!
That's a Wrap!
Nice work! You just built, containerized, and deployed a production-grade URL shortener REST API from scratch.
You've learned how to:
- Build a REST API with Spring Boot that shortens URLs using Base62 encoding and serves Swagger documentation.
- Write a multi-stage Dockerfile and use docker-compose to orchestrate a multi-container local development environment with PostgreSQL.
- Deploy a Dockerized Java application to Render with a cloud-hosted Neon PostgreSQL database and a live public URL.
- Secret Mission: Added click analytics that track visitor metadata on each redirect.
Ready to quiz yourself?