A production-grade URL Shortener — Spring Boot 4 · Java 25 · Postgres · Redis · Keycloak
Shorten long URLs into compact Base62 codes, protect the management API behind Keycloak OIDC, throttle traffic with Bucket4j on Redis, and track click counts without blocking the redirect path.
- Stateless IDs — Snowflake + Base62, no DB round-trip to mint a code.
- Hot-path redirects — Redis-cached, lock-free click counter synced every 30 s.
- Standard errors — every failure is
application/problem+json(RFC 7807) with a stablecodeproperty for programmatic handling. - Cluster-safe scheduling — ShedLock over JDBC, so cron jobs are safe behind a load balancer.
- Security baked in — OAuth2 Resource Server, CORS allowlist, two-tier rate limiting with
X-RateLimit-*headers,Cache-Control: private, no-storeon redirects. - API first — Swagger UI and OpenAPI 3 generated from the code, no manual docs to drift.
- First-class observability — Micrometer counters and timers exposed via
/actuator/metrics, ECS-formatted structured logs (prod profile), graceful shutdown for in-flight drains. - Production Docker image — layered, non-root, healthchecked, ~150 MB on Temurin JRE 25 Alpine. Optional CDS training at build time shaves 100–200 ms off cold start.
- Quick Start
- Core Features
- Technology Stack
- Architecture
- Project Structure
- API Endpoints
- Configuration
- Deployment
- Troubleshooting
- Contributing
- Roadmap
- License
- Acknowledgments
Get a running instance in under a minute. Requires JDK 25 and Docker.
# 1. Boot Postgres, Redis, and Keycloak (auto-imports the lynk realm)
docker compose up -d
# 2. Build and run the app (Flyway applies V1–V6 on first start)
./mvnw spring-boot:run
# 3. Verify
curl http://localhost:8080/actuator/health # -> {"status":"UP", ...}
open http://localhost:8080/swagger-ui.html # try-it-outThat's it — Spring Boot's spring-boot-docker-compose integration wires
host/port for Postgres, Redis, and Keycloak from compose.yaml on startup, so
no env vars are required for local development.
First-time Keycloak boot takes ~30 s. The
lynkrealm is imported from./keycloak/lynk-realm.jsonautomatically. Admin UI: http://localhost:8180 (defaultadmin/admin).
URL management
- Create, fetch, update, and delete short URLs.
- Auto-generated codes from Snowflake IDs encoded in Base62.
- Optional custom aliases (3–20 chars,
[a-zA-Z0-9_-]). - Optional per-URL expiration in hours.
- Public
GET /{shortCode}returns a 302 redirect; expired URLs return 410.
Auth & security
- OAuth2 Resource Server with Keycloak JWT; user identity is the
subclaim. - Stateless session policy; CSRF disabled (token-based API).
- CORS allowlist driven by
app.cors.allowed-origins. - All errors rendered as
application/problem+json(RFC 7807) with atimestampproperty.
Performance & limits
- Redis cache for
shortCode → longUrllookups (1-day TTL by default). - Bucket4j + Redis rate limiting, two tiers: API (100 / 10 per minute) and redirect (500 / 50 per minute).
X-RateLimit-LimitandX-RateLimit-Remainingheaders, CORS-exposed.- Click counter is incremented in Redis asynchronously and flushed to Postgres every 30 seconds, keeping the hot redirect path lock-free.
Operations
- Spring Actuator:
/actuator/health,/actuator/info,/actuator/metrics,/actuator/env,/actuator/loggers. - Micrometer metrics —
lynk.urls.created,lynk.redirects(taggedcache_hitandoutcome:ok/not_found/expired/unavailable),lynk.redirect.duration(timer), andlynk.click.sync.batch. - ECS-formatted structured logs to stdout, ready for any log aggregator that speaks Elastic Common Schema.
- Graceful shutdown (
server.shutdown=graceful, 30 s phase timeout) so in-flight redirects and click flushes complete before the JVM exits. - Swagger UI at
/swagger-ui.html, OpenAPI 3 at/v3/api-docs. - Hourly expired-URL cleanup, cluster-safe via ShedLock.
- Schema owned by Flyway; Hibernate runs in
validatemode. - Multi-stage Docker build producing a layered, non-root image on Temurin JRE 25.
| Layer | Tech | Version |
|---|---|---|
| Runtime | Java (Temurin) | 25 |
| Framework | Spring Boot (WebMVC, Data JPA, Validation, Actuator, Flyway, Security OAuth2 RS) | 4.0.6 |
| Database | PostgreSQL | 18 |
| Cache / counters | Redis | 8 |
| Auth | Keycloak | 26.5 |
| Rate limiting | Bucket4j (Lettuce) | 8.18.0 |
| Distributed jobs | ShedLock (JDBC provider) | 7.7.0 |
| Metrics | Micrometer (/actuator/metrics, ECS-formatted logs) |
bundled |
| Migrations | Flyway + flyway-database-postgresql |
bundled |
| API docs | springdoc-openapi-starter-webmvc-ui | 3.0.3 |
| Container base | eclipse-temurin:25-jre-alpine, layered JAR via spring-boot-loader |
— |
| Tooling | Lombok, Maven Wrapper, Testcontainers, Awaitility | — |
See pom.xml for the full dependency list.
┌────────────────────────────┐
│ Keycloak 26.5 (OIDC) │
│ realm: lynk (imported) │
└─────────────┬──────────────┘
│ JWT (sub)
▼
┌──────────┐ GET /{code} ┌──────────────────────────────────────┐
│ Client │─────────────────▶│ Lynk (Spring Boot 4, Java 25) │
└──────────┘ │ │
▲ │ 302 redirect │ ┌─────────────┐ ┌──────────────┐ │
│ │ + click event │ │ Controllers │─▶│ UrlMapping │ │
│ ▼ │ │ + Filters │ │ Service │ │
│ ┌─────────┤ └─────┬───────┘ └──────┬───────┘ │
│ │ async │ │ │ │
│ │ event │ ▼ ▼ │
│ │ │ ┌─────────┐ ┌─────────────┐ │
│ │ │ │ Redis 8 │◀────▶│ Postgres 18│ │
│ │ │ │ cache │ │ Flyway V1-6│ │
│ │ │ │ bucket │ └─────────────┘ │
│ │ │ └────┬────┘ ▲ │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ ClickSync every 30s ───┘ │
│ │ │ (ShedLock-guarded) │
│ │ └──────────────────────────────────────┘
│ │
└───────────────┘ click counted, not on the hot path
Click tracking is event-driven end-to-end:
RedirectService.accessAndRedirect()publishes aClickRecordedEvent.ClickTrackingServicelistens with@Async+@TransactionalEventListener(phase = AFTER_COMMIT)and bumpsclicks:{shortCode}in Redis.ClickSyncService.syncPendingClicks()runs every 30 s under a ShedLock lease and flushes the Redis counters into Postgres via a bulkincrementClickCountBy(). Key iteration usesSCAN(notKEYS) with a 1000-key batch so the hot path stays non-blocking, and each counter is atomically read-and-deleted. If the DB write fails, the counter is restored to Redis viaINCRBYso clicks are not lost on transient Postgres errors — the next 30 s cycle will retry.
Caching is opportunistic: UrlCacheService populates url:{shortCode} on
create and update, evicts on delete and on short-code change, and serves reads
on the redirect path before Postgres is touched.
.
├── src/
│ ├── main/
│ │ ├── java/ly/lynk/
│ │ │ ├── Application.java # Spring Boot entry point
│ │ │ ├── config/ # Security, OpenAPI, RateLimit, ShedLock, Async, JPA
│ │ │ ├── controller/ # URL CRUD + redirect
│ │ │ ├── dto/ # Java records (request/response)
│ │ │ ├── event/ # ClickRecordedEvent
│ │ │ ├── exception/ # Sealed exception hierarchy → RFC 7807
│ │ │ ├── filter/ # RateLimitFilter
│ │ │ ├── mapper/ # Entity ↔ DTO
│ │ │ ├── model/ # JPA entities
│ │ │ ├── repository/ # Spring Data JPA
│ │ │ ├── service/ # Business logic, schedulers, caching
│ │ │ └── util/ # Snowflake, Base62, Redis keys
│ │ └── resources/
│ │ ├── application.yaml # Base runtime config
│ │ ├── application-prod.yaml # prod profile overrides (ECS logs, INFO, Swagger off)
│ │ └── db/migration/ # Flyway V1–V6 (never edit a committed one)
│ └── test/
│ ├── java/ly/lynk/ # *Test, *IT, slice tests
│ └── resources/application.yaml # Test overrides (ShedLock off, etc.)
├── keycloak/
│ └── lynk-realm.json # Auto-imported realm
├── compose.yaml # Postgres 18, Redis 8, Keycloak 26.5
├── Dockerfile # Multi-stage, non-root, layered JAR
├── pom.xml # Spring Boot 4.0.6 + Java 25
├── AGENTS.md # Contributor & agent conventions
└── README.md # You are here
| Method | Path | Auth | Purpose | Rate limit tier |
|---|---|---|---|---|
GET |
/{shortCode} |
none | 302 redirect to the original URL. | redirect (500 / 50/min) |
POST |
/api/v1/urls |
JWT | Create a short URL. | api (100 / 10/min) |
GET |
/api/v1/urls/{shortCode} |
JWT | Fetch metadata for a short code. | api |
PUT |
/api/v1/urls/{shortCode} |
JWT | Partial update (alias, URL, TTL). | api |
DELETE |
/api/v1/urls/{shortCode} |
JWT | Delete a short URL (returns 204). | api |
GET |
/actuator/health, /actuator/info |
none | Liveness and build info. | — |
GET |
/swagger-ui.html, /v3/api-docs/** |
none | OpenAPI 3 / Swagger UI. | — |
Create a short URL:
POST /api/v1/urls
Authorization: Bearer <jwt>
Content-Type: application/json
{
"originalUrl": "https://verylongurl.example.com/some/really/deep/path?with=params",
"customAlias": "my-link",
"ttlHours": 48
}Response (201 Created):
{
"shortCode": "my-link",
"shortUrl": "http://localhost:8080/my-link",
"originalUrl": "https://verylongurl.example.com/some/really/deep/path?with=params",
"clickCount": 0,
"createdAt": "2026-06-01T10:15:30Z",
"updatedAt": "2026-06-01T10:15:30Z",
"expiresAt": "2026-06-03T10:15:30Z"
}PUT with {} is a no-op; only customAlias, originalUrl, and ttlHours are
honored when present.
All error responses are application/problem+json (RFC 7807) with a
timestamp property and a stable code for programmatic handling:
| Status | code |
Meaning |
|---|---|---|
400 |
validation_failed |
Validation failure (bad URL, invalid alias, non-positive TTL). |
401 |
none | Missing or invalid bearer token. |
404 |
short_code_not_found |
Short code not found. |
409 |
custom_alias_conflict |
Custom alias already in use. |
410 |
url_expired |
Short URL has expired. |
429 |
rate_limit_exceeded |
Rate limit exceeded; inspect X-RateLimit-Limit and X-RateLimit-Remaining. |
503 |
service_unavailable |
A downstream dependency (Postgres, Redis, Keycloak) is unavailable. |
Example error body:
{
"type": "about:blank",
"title": "Short Code Not Found",
"status": 404,
"detail": "No URL mapping found for code: abc123",
"code": "short_code_not_found",
"timestamp": "2026-06-01T10:15:30Z"
}Redirect responses (GET /{shortCode}) include Cache-Control: private, no-store
so browsers and intermediaries never cache the 302.
For the full schema and try-it-out, see /swagger-ui.html.
All app.* properties can be overridden via environment variables in the
standard Spring Boot form (e.g. APP_RATE_LIMIT_API_CAPACITY=200). The
properties marked with a short alias below also have an env-var shortcut
baked into application.yaml.
| Property | Default | Description |
|---|---|---|
app.snowflake-node-id |
1 |
Snowflake node id (0–1023). Must be unique per running instance. |
app.base-url |
http://localhost:8080 |
Base URL used to build the shortUrl field in responses. |
app.cleanup-cron |
0 0 * * * * |
Cron expression for UrlCleanupService (removes expired URLs). |
app.scheduling.enabled |
true |
Master switch for @Scheduled jobs (cleanup, click sync). Tests set this to false. |
app.redis.cache-ttl-days |
1 (7 under prod) |
TTL for the url:{shortCode} Redis cache entry. |
app.rate-limit.api.capacity |
100 |
API tier burst capacity per principal. |
app.rate-limit.api.refill |
10 |
API tier tokens refilled per period. |
app.rate-limit.api.refill-period |
PT1M |
API tier refill period (ISO-8601 duration). |
app.rate-limit.redirect.capacity |
500 |
Redirect tier burst capacity per IP. |
app.rate-limit.redirect.refill |
50 |
Redirect tier tokens refilled per period. |
app.rate-limit.redirect.refill-period |
PT1M |
Redirect tier refill period (ISO-8601 duration). |
app.cors.allowed-origins |
http://localhost:5173 |
CORS allowlist (comma-separated) for the management API. |
spring.data.redis.host |
localhost |
Redis hostname. |
spring.data.redis.port |
6379 |
Redis port. |
server.forward-headers-strategy |
framework |
Trust X-Forwarded-* from a proxy in front of the app (set to none to disable). |
SPRING_PROFILES_ACTIVE |
unset | Comma-separated Spring profiles. Set to prod to load application-prod.yaml. |
application.yaml defines a few env-var shortcuts so container manifests stay
readable. Both forms are equivalent — the alias just supplies a default value.
| Env var | Resolves to |
|---|---|
APP_BASE_URL |
app.base-url |
CORS_ALLOWED_ORIGINS |
app.cors.allowed-origins |
REDIS_HOST |
spring.data.redis.host |
REDIS_PORT |
spring.data.redis.port |
src/main/resources/application-prod.yaml is layered on top of the base
config when SPRING_PROFILES_ACTIVE=prod is set. It enables:
- ECS-formatted structured logs (
logging.structured.format.console=ecs). - Quiet application logs —
ly.lynkatINFOinstead ofDEBUG. /actuator/healthreturns minimal details to anonymous callers (show-details: when-authorized).- Swagger UI and OpenAPI docs disabled.
- 7-day Redis cache TTL for the
url:{shortCode}cache.
Defaults in compose.yaml can be overridden from the shell or a .env file:
| Env var | Default | Used by |
|---|---|---|
POSTGRES_DB |
lynk |
Postgres |
POSTGRES_USER |
postgres |
Postgres |
POSTGRES_PASSWORD |
secret |
Postgres |
KEYCLOAK_ADMIN |
admin |
Keycloak |
KEYCLOAK_ADMIN_PASSWORD |
admin |
Keycloak |
Production deployments should externalize these via env vars or Spring Cloud
Config. Tests override them in src/test/resources/application.yaml (ShedLock
disabled, Swagger disabled, generous rate limits, stub JWT decoder).
There are two supported paths. Pick the one that matches your needs.
Spring Boot's spring-boot:build-image goal uses Cloud Native Buildpacks
(Paketo by default) to produce an OCI image directly from Maven — no
Dockerfile required.
./mvnw spring-boot:build-imageThe resulting image is tagged docker.io/library/lynk:0.0.9-SNAPSHOT (derived
from <artifactId> and <version> in pom.xml). Run it with:
docker run --rm -p 8080:8080 docker.io/library/lynk:0.0.9-SNAPSHOTOverride the tag with -Dspring-boot.build-image.imageName=lynk:dev. Other
useful properties:
| Property | Description |
|---|---|
-Dspring-boot.build-image.imageName |
Set the image name and tag. |
-Dspring-boot.build-image.builder |
Pin a specific Paketo builder (e.g. paketobuildpacks/builder-jammy-base). |
-Dspring-boot.build-image.runImage |
Pin a specific run image. |
-Dspring-boot.build-image.createdDate |
Set the Created label (e.g. now, an ISO-8601 timestamp, or disable). |
-Dspring-boot.build-image.pullPolicy |
ALWAYS, IF_NOT_PRESENT (default), or NEVER. |
-Dspring-boot.build-image.network |
Set the network mode for the build (e.g. host). |
-Dspring-boot.build-image.cacheFrom |
Pull additional base layers for caching (repeatable). |
-Dspring-boot.build-image.publish |
true to push the image to a registry once built. |
Requires a local Docker daemon. The goal will fail fast with a clear message if Docker is not reachable.
If you need a hardened, distroless-style image or to bake in extra layers
(/etc/passwd tweaks, healthcheck, OCI labels, etc.), use the committed
multi-stage Dockerfile.
docker build -t lynk:dev .The image is multi-stage: it builds with Temurin JDK 25, extracts the layered
Spring Boot JAR, and ships a non-root JRE 25 Alpine runtime with tini as PID 1
and an HTTP healthcheck against /actuator/health.
The Dockerfile can run the app once against the bundled compose services to
produce a Class Data Sharing archive (app.jsa). The runtime image then
launches with -XX:SharedArchiveFile=app.jsa, typically shaving 100–200 ms
off cold start.
DOCKER_BUILDKIT=1 docker build --build-arg TRAIN_CDS=1 -t lynk:cds .TRAIN_CDS=1 requires docker compose up to be possible during the build
(Postgres + Redis + Keycloak must boot for Flyway migrations and context
refresh). If you skip the flag, the runtime simply ignores the missing
-XX:SharedArchiveFile flag — the image is still functional.
docker run --rm -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://<host>:5432/lynk \
-e SPRING_DATASOURCE_USERNAME=postgres \
-e SPRING_DATASOURCE_PASSWORD=secret \
-e REDIS_HOST=<redis-host> \
-e KEYCLOAK_ISSUER_URI=https://<keycloak>/realms/lynk \
-e APP_SNOWFLAKE_NODE_ID=1 \
-e APP_BASE_URL=https://lynk.example.com \
-e CORS_ALLOWED_ORIGINS=https://app.example.com \
lynk:devThe short env-var aliases (REDIS_HOST, APP_BASE_URL, CORS_ALLOWED_ORIGINS)
can also be set as standard Spring properties — both forms are equivalent.
| Command | What runs |
|---|---|
./mvnw compile |
Compile main sources. |
./mvnw test |
*Test.java / *Tests.java via Surefire. |
./mvnw verify |
Surefire + *IT.java / *ITCase.java via Failsafe + Flyway validate. |
./mvnw spring-boot:run |
Boot the dev server (needs Docker for compose services). |
./mvnw spring-boot:build-image |
Build an OCI image via Cloud Native Buildpacks (no Dockerfile). |
Run a single test class:
./mvnw test -Dtest=UrlMappingServiceTest
./mvnw verify -Dit.test=UrlMappingIntegrationIT- Set a unique
app.snowflake-node-idper replica (0–1023, never collides). - Point
spring.datasource.*at a managed Postgres 18 with backups + PITR. - Use a managed Redis 8 (cluster or sentinel) — Bucket4j + ShedLock both need it.
- Terminate TLS in front of the app and set
app.base-urlto the public origin. - Tune
app.rate-limit.*to your expected traffic. - Ship logs to your aggregator;
ly.lynkis atDEBUG, root atINFOby default.
-
Port 5432 / 6379 / 8180 already in use. Stop the conflicting service, or change the host port mapping in
compose.yaml(for example"15432:5432"). -
App exits with "Unable to connect to Redis" (or Postgres). Run
docker compose ps— thespring-boot-docker-composeintegration requires Docker to be running before the app starts. If a container is unhealthy,docker compose logs <service>to inspect it. -
Keycloak realm
lynkis missing after a restart. The realm is imported from./keycloak/lynk-realm.jsononly on the first boot of a fresh data volume. Re-import it via the Keycloak admin console, or rundocker compose down -vand start again. -
401 on
/api/v1/urls. The JWT must include a non-blanksubclaim and must be issued by the configured realm (defaulthttp://localhost:8180/realms/lynk, overridable viaKEYCLOAK_ISSUER_URI). Verify the token withcurl -H "Authorization: Bearer $TOKEN" http://localhost:8180/realms/lynk/protocol/openid-connect/userinfo. -
429 Too Many Requests. Inspect the
X-RateLimit-LimitandX-RateLimit-Remainingresponse headers to see which tier is exhausted. Tuneapp.rate-limit.api.*orapp.rate-limit.redirect.*if the defaults are too aggressive for your workload. -
Click counter is stuck at zero. Redis must be reachable from the app.
ClickSyncServiceflushes counters to Postgres every 30 s, atomicallyGETDEL-ing each key in Redis; if the Postgres write fails the counter is restored viaINCRBYand retried on the next cycle, so a transient DB blip will not lose clicks. If Redis itself is down, the@Asynclistener cannot record clicks in the first place, and they will be missed for the duration of the outage. -
docker composewarns about an unknowncompose.yamlfield. You are likely running an older Docker Compose V1. The repo uses the modern Compose Specification; upgrade todocker compose(V2) ordocker-compose2.20+.
Contributions are welcome.
- Read
AGENTS.md— it is the source of truth for layout, conventions, the Flyway contract, the Redis key prefix reference, and the test taxonomy (*Testvs*IT). - Fork the repo, create a feature branch, push, open a PR.
- Make sure
./mvnw verifyis green before requesting review — it runs Surefire, Failsafe, andflyway:validateagainst Testcontainers Postgres. - Format with the Palantir Java Format IntelliJ plugin (see
.idea/palantir-java-format.xml); it is not enforced by Maven, so check the diff before pushing.
- Per-user analytics (geo, referer, device) on top of the click event stream.
- Bulk URL import / CSV export.
- Optional Postgres row-level isolation strategy for multi-tenant SaaS mode.
- Native image via GraalVM (Spring Boot 4
AOTalready supported). - Helm chart and a reference
docker-compose.prod.yaml. - OpenTelemetry traces + Prometheus metrics via the Micrometer bridge.
Released under the MIT License. The license declaration is
mirrored in Dockerfile via the
org.opencontainers.image.licenses="MIT" label.
- Spring Boot — the framework.
- Bucket4j — token-bucket rate limiting.
- ShedLock — distributed locks for Spring scheduled tasks.
- springdoc-openapi — Swagger UI and OpenAPI 3 from code.
- Keycloak — OIDC provider used for dev and prod alike.
- Testcontainers — integration tests against real Postgres.