Skip to content

nathsagar96/lynk

Repository files navigation

Lynk

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.

Quick start · API docs · Architecture · Contributing

Java 25 Spring Boot 4.0.6 Postgres 18 Redis 8 Keycloak 26.5 License MIT Docker ready PRs welcome


Highlights

  • 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 stable code property 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-store on 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.

Table of Contents

Quick Start

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-out

That'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 lynk realm is imported from ./keycloak/lynk-realm.json automatically. Admin UI: http://localhost:8180 (default admin / admin).

Core Features

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 sub claim.
  • 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 a timestamp property.

Performance & limits

  • Redis cache for shortCode → longUrl lookups (1-day TTL by default).
  • Bucket4j + Redis rate limiting, two tiers: API (100 / 10 per minute) and redirect (500 / 50 per minute).
  • X-RateLimit-Limit and X-RateLimit-Remaining headers, 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 (tagged cache_hit and outcome: ok / not_found / expired / unavailable), lynk.redirect.duration (timer), and lynk.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 validate mode.
  • Multi-stage Docker build producing a layered, non-root image on Temurin JRE 25.

Technology Stack

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.

Architecture

                                   ┌────────────────────────────┐
                                   │  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:

  1. RedirectService.accessAndRedirect() publishes a ClickRecordedEvent.
  2. ClickTrackingService listens with @Async + @TransactionalEventListener(phase = AFTER_COMMIT) and bumps clicks:{shortCode} in Redis.
  3. ClickSyncService.syncPendingClicks() runs every 30 s under a ShedLock lease and flushes the Redis counters into Postgres via a bulk incrementClickCountBy(). Key iteration uses SCAN (not KEYS) 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 via INCRBY so 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.

Project Structure

.
├── 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

API Endpoints

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.

Examples

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.

Configuration

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.

Short env-var aliases

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

prod profile

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 logsly.lynk at INFO instead of DEBUG.
  • /actuator/health returns 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.

Compose overrides

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).

Deployment

Build a Docker image

There are two supported paths. Pick the one that matches your needs.

Option A — Spring Boot Buildpacks (zero config, recommended for most cases)

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-image

The 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-SNAPSHOT

Override 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.

Option B — Custom Dockerfile (full control)

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.

Optional: CDS training for faster cold start

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.

Run the container

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:dev

The short env-var aliases (REDIS_HOST, APP_BASE_URL, CORS_ALLOWED_ORIGINS) can also be set as standard Spring properties — both forms are equivalent.

Build & test (Maven)

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

Production checklist

  • Set a unique app.snowflake-node-id per 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-url to the public origin.
  • Tune app.rate-limit.* to your expected traffic.
  • Ship logs to your aggregator; ly.lynk is at DEBUG, root at INFO by default.

Troubleshooting

  • 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 — the spring-boot-docker-compose integration requires Docker to be running before the app starts. If a container is unhealthy, docker compose logs <service> to inspect it.

  • Keycloak realm lynk is missing after a restart. The realm is imported from ./keycloak/lynk-realm.json only on the first boot of a fresh data volume. Re-import it via the Keycloak admin console, or run docker compose down -v and start again.

  • 401 on /api/v1/urls. The JWT must include a non-blank sub claim and must be issued by the configured realm (default http://localhost:8180/realms/lynk, overridable via KEYCLOAK_ISSUER_URI). Verify the token with curl -H "Authorization: Bearer $TOKEN" http://localhost:8180/realms/lynk/protocol/openid-connect/userinfo.

  • 429 Too Many Requests. Inspect the X-RateLimit-Limit and X-RateLimit-Remaining response headers to see which tier is exhausted. Tune app.rate-limit.api.* or app.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. ClickSyncService flushes counters to Postgres every 30 s, atomically GETDEL-ing each key in Redis; if the Postgres write fails the counter is restored via INCRBY and retried on the next cycle, so a transient DB blip will not lose clicks. If Redis itself is down, the @Async listener cannot record clicks in the first place, and they will be missed for the duration of the outage.

  • docker compose warns about an unknown compose.yaml field. You are likely running an older Docker Compose V1. The repo uses the modern Compose Specification; upgrade to docker compose (V2) or docker-compose 2.20+.

Contributing

Contributions are welcome.

  1. Read AGENTS.md — it is the source of truth for layout, conventions, the Flyway contract, the Redis key prefix reference, and the test taxonomy (*Test vs *IT).
  2. Fork the repo, create a feature branch, push, open a PR.
  3. Make sure ./mvnw verify is green before requesting review — it runs Surefire, Failsafe, and flyway:validate against Testcontainers Postgres.
  4. 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.

Roadmap

  • 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 AOT already supported).
  • Helm chart and a reference docker-compose.prod.yaml.
  • OpenTelemetry traces + Prometheus metrics via the Micrometer bridge.

License

Released under the MIT License. The license declaration is mirrored in Dockerfile via the org.opencontainers.image.licenses="MIT" label.

Acknowledgments

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors