A Spring Boot URL shortener API with JWT authentication, PostgreSQL persistence, Flyway migrations, and public redirect links.
- Java 25
- Spring Boot 4.0.6
- Spring Web MVC
- Spring Security
- Spring Data JPA
- Flyway
- PostgreSQL
- Gradle Kotlin DSL
- JDK 25, only for running the app directly on the host
- Docker and Docker Compose, for the containerized app and PostgreSQL
- OpenSSL, only if you need to regenerate JWT keys
The default configuration is in src/main/resources/application.yaml.
| Variable | Default | Description |
|---|---|---|
DB_URL |
jdbc:postgresql://database:5432/jshortlinkdb |
PostgreSQL JDBC URL |
DB_USERNAME |
admin |
Database username |
DB_PASSWORD |
admin |
Database password |
jwt.private-key |
classpath:jwt/private.pem |
RSA private key used to sign access tokens |
jwt.public-key |
classpath:jwt/public.pem |
RSA public key used to verify access tokens |
jwt.token.expiration |
3600000 |
Access token lifetime in milliseconds |
JWT key files are expected at:
src/main/resources/jwt/private.pem
src/main/resources/jwt/public.pem
If they are missing, generate a local key pair:
mkdir -p src/main/resources/jwt
openssl genrsa -out src/main/resources/jwt/private.pem 2048
openssl rsa -in src/main/resources/jwt/private.pem -pubout -out src/main/resources/jwt/public.pemStart the API and PostgreSQL:
docker compose up --buildRun in the background:
docker compose up -d --buildThe Docker Compose setup starts:
server, built fromdocker/Dockerfiletargetdevdatabase, usingpostgres:18- a named PostgreSQL data volume,
pg_volume - a named Gradle cache volume,
gradle_cache
The development container mounts the project into /app, runs ./gradlew bootRun, and keeps ./gradlew -t classes running so Spring Boot DevTools can restart the app after source changes.
The API is available at:
http://localhost:8080
Useful Docker commands:
docker compose logs -f server
docker compose down
docker compose down -vdocker compose down -v removes the PostgreSQL and Gradle cache volumes.
Build the production image:
docker build -f docker/Dockerfile --target prod -t jshortlink:latest .Run the production image against a PostgreSQL instance on the host:
docker run --rm -p 8080:8080 \
--add-host=host.docker.internal:host-gateway \
-e DB_URL=jdbc:postgresql://host.docker.internal:5432/jshortlinkdb \
-e DB_USERNAME=admin \
-e DB_PASSWORD=admin \
jshortlink:latestStart PostgreSQL:
docker compose up -d databaseRun the application:
DB_URL=jdbc:postgresql://localhost:5432/jshortlinkdb ./gradlew bootRunThe API starts on the default Spring Boot port:
http://localhost:8080
Run tests:
./gradlew testFlyway migrations are stored in src/main/resources/db/migrations.
Current migrations create:
userroleuser_rolelink
Registration assigns the USER role, but the current migrations do not seed roles. Before registering users, insert the default roles:
docker compose exec database psql -U admin -d jshortlinkdb -c "INSERT INTO \"role\" (name, description) SELECT 'USER', 'Default user' WHERE NOT EXISTS (SELECT 1 FROM \"role\" WHERE name = 'USER');"
docker compose exec database psql -U admin -d jshortlinkdb -c "INSERT INTO \"role\" (name, description) SELECT 'ADMIN', 'Admin user' WHERE NOT EXISTS (SELECT 1 FROM \"role\" WHERE name = 'ADMIN');"Public endpoints:
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
POST |
/api/auth/register |
Register a user |
POST |
/api/auth/login |
Login and receive an access token |
GET |
/pub/link/{shortCode} |
Redirect to the original URL |
Protected endpoints require:
Authorization: Bearer <accessToken>| Method | Path | Description |
|---|---|---|
GET |
/api/me |
Get the current authenticated user |
GET |
/api/link |
Search current user's links |
POST |
/api/link |
Create a shortened link |
Register:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"fullName": "Demo User",
"username": "demo",
"password": "password"
}'Login:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "demo",
"password": "password"
}'Create a link:
curl -X POST http://localhost:8080/api/link \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <accessToken>" \
-d '{
"originalUrl": "https://example.com/docs"
}'Search links:
curl "http://localhost:8080/api/link?page=0&pageSize=10&sort=CREATED_AT&order=DESC" \
-H "Authorization: Bearer <accessToken>"Open a public short link:
curl -i http://localhost:8080/pub/link/<shortCode>POST /api/link validates originalUrl with the application link regex:
^(https?://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(/[^\s]*)?$
Examples that pass:
https://example.comhttp://example.com/pathexample.com/docs
Examples that fail:
asdsadlocalhostftp://example.com