CloudFront CDN in Practice (2) — Putting a Spring Boot + Kotlin Origin Behind CloudFront (Terraform)
Introduction
Part 1 covered how a CDN and CloudFront work (cache key, Cache-Control, TTL, hit/miss, invalidation vs versioning). Part 2 turns those concepts into actual code.
The goal is to put a Spring Boot + Kotlin app as the origin and CloudFront in front of it. Two things matter.
- Make the origin send the right
Cache-Control(Kotlin) — long for static, no caching for dynamic. - Make CloudFront behave differently per path (Terraform) — cache
/static/*, pass through/api/*.
- Part 1 — How a CDN and CloudFront work
- Part 2 — Putting a Spring Boot + Kotlin origin behind CloudFront (this post)
- Part 3 — Private content, edge logic, security, monitoring
- Part 4 — Image resizing and video transcoding
TL;DR
- The origin states cache intent via headers. In Kotlin, static resources get
Cache-Control: max-age=1 year, immutable; dynamic APIs getno-store. CloudFront follows these headers. - Split behaviors by path.
/static/*uses a caching policy (CachingOptimized);/api/*and the default use a no-cache policy (CachingDisabled). - An origin request policy decides what to forward. Dynamic paths must forward cookies/headers/query to the origin, so use AllViewerExceptHostHeader; static forwards the minimum.
- Reproduce it in Terraform. Declare one origin (ALB) and per-path rules with
ordered_cache_behaviorinaws_cloudfront_distribution. - Verify with X-Cache. With
curl -I, static showsHiton the second request; dynamic always showsMiss.
1. Architecture
The setup is simple: User → CloudFront → ALB → Spring Boot app. The app serves both static resources (/static/*) and dynamic APIs (/api/*).
flowchart LR
user["User"] --> cf["CloudFront<br/>(edge cache)"]
cf -->|"/static/* (cached)"| alb["ALB"]
cf -->|"/api/* (pass)"| alb
alb --> app["Spring Boot + Kotlin"]
Note: Keeping static assets separately in S3 (two origins) is also common. Here we start with the simplest form — “one app serves both static and dynamic.” The S3 + OAC setup is covered with security in Part 3.
2. Spring Boot + Kotlin Origin — Sending the Right Cache-Control
CloudFront isn’t clever. It simply follows the Cache-Control header the origin sends. So half of caching is decided in app code.
2.1 Static resources — long, immutable
Set Cache-Control on the static path via WebMvcConfigurer. Assuming hash-versioned assets (app.abc123.js), give them immutable + 1 year (the versioning strategy from Part 1, §4).
import org.springframework.context.annotation.Configuration
import org.springframework.http.CacheControl
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import java.util.concurrent.TimeUnit
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(
CacheControl.maxAge(365, TimeUnit.DAYS)
.cachePublic()
.immutable(),
)
}
}
The response header becomes:
Cache-Control: max-age=31536000, public, immutable
2.2 Dynamic APIs — explicitly forbid caching
Responses that vary per user must send no-store. Without it, CloudFront may cache them according to the behavior’s Default TTL.
import org.springframework.http.CacheControl
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class ApiController(private val userService: UserService) {
// Per-user response → never cache
@GetMapping("/api/me")
fun me(): ResponseEntity<UserDto> =
ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(userService.currentUser())
// Public data, same for everyone, rarely changes → allow short cache
@GetMapping("/api/products")
fun products(): ResponseEntity<List<ProductDto>> =
ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS).cachePublic())
.body(userService.products())
}
Key point: Not everything under
/api/*needs to be uncached. A public list that’s “the same for everyone and fine to be slightly stale” can be cached formax-age=60s, cutting origin load a lot. Only the “per-user” ones getno-store.
2.3 ETag to save bandwidth (optional)
Enabling ShallowEtagHeaderFilter adds an ETag (a hash of the response body). When a client/CDN revalidates with If-None-Match and the content is unchanged, you get a body-less 304 Not Modified, saving bandwidth.
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.filter.ShallowEtagHeaderFilter
@Configuration
class EtagConfig {
@Bean
fun shallowEtagFilter(): FilterRegistrationBean<ShallowEtagHeaderFilter> =
FilterRegistrationBean(ShallowEtagHeaderFilter()).apply {
addUrlPatterns("/api/*")
}
}
Caution:
ETag(revalidation) andno-store(forbid storing at all) serve different purposes. An ETag is meaningless on ano-storeresponse. ETags fitno-cache/short-max-ageresponses where you “cache but want to confirm it changed.”
2.4 Several Ways to Set Cache-Control (+ a Spring Security gotcha)
The ResponseEntity.cacheControl() from §2.2 is the per-response, explicit way. It’s the clearest but repeats per endpoint. Spring has no @CacheControl annotation, so to group policies use the approaches below.
| Method | Scope | When |
|---|---|---|
ResponseEntity.cacheControl() | One endpoint | Per-response, explicit |
WebContentInterceptor | Per path pattern | A rule like “/api/** is no-store” in one place |
Filter (OncePerRequestFilter) | Per path/condition | Lower-level, fine-grained control |
Resource handler (WebMvcConfigurer) | Static resources | The §2.1 approach |
WebContentInterceptor, which applies by path rule in one place, is the cleanest.
@Configuration
class CacheConfig : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
val interceptor = WebContentInterceptor().apply {
addCacheMapping(CacheControl.noStore(), "/api/**") // default: no cache
addCacheMapping(
CacheControl.maxAge(60, TimeUnit.SECONDS).cachePublic(),
"/api/products", // exception: public list, short
)
}
registry.addInterceptor(interceptor)
}
}
A more specific pattern (/api/products) overrides the general one (/api/**). Controllers keep only business logic.
Caution — Spring Security’s default cache headers: With Spring Security, every response gets
Cache-Control: no-cache, no-store, max-age=0, must-revalidateby default (to prevent caching of secured pages). This blocks caching even on static/public responses — a common cause of “why isn’t CloudFront caching?” Split static/public paths into a separateSecurityFilterChain, or relax the cache headers on those paths.
3. Designing CloudFront Behaviors
Now pick policies so CloudFront behaves differently per path. Using AWS’s managed policies means you don’t build your own.
| Path | Cache policy | Origin request policy | Intent |
|---|---|---|---|
/static/* | CachingOptimized | (not needed) | Cache long, ignore cookies, compress |
/api/* | CachingDisabled | AllViewerExceptHostHeader | No cache, forward cookies/headers/query |
default (*) | CachingDisabled | AllViewerExceptHostHeader | Safe default (pass through) |
- CachingOptimized: drops cookies from the cache key and only looks at
Accept-Encoding→ high hit ratio. For static assets. - CachingDisabled: doesn’t cache. For dynamic paths.
- AllViewerExceptHostHeader: forwards all viewer headers/cookies/query to the origin except
Host. A custom origin like an ALB routes by its own host, so you must not forward the viewer Host — hence this policy.
Why drop Host: If CloudFront forwards the viewer’s
Host(e.g.,cdn.example.com) to the ALB, the ALB’s host-based routing or backend virtual hosts can break.AllViewerExceptHostHeaderleaves Host as the origin domain and forwards everything else.
4. Building It in Terraform
Assuming the ALB, app, and network already exist (see the earlier Terraform guide), focus on the CloudFront distribution.
4.1 Look up managed policies
data "aws_cloudfront_cache_policy" "optimized" {
name = "Managed-CachingOptimized"
}
data "aws_cloudfront_cache_policy" "disabled" {
name = "Managed-CachingDisabled"
}
data "aws_cloudfront_origin_request_policy" "all_viewer_except_host" {
name = "Managed-AllViewerExceptHostHeader"
}
4.2 The distribution
resource "aws_cloudfront_distribution" "app" {
enabled = true
comment = "spring-boot-kotlin-origin"
origin {
domain_name = aws_lb.app.dns_name # ALB DNS name
origin_id = "alb-origin"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only" # CloudFront ↔ ALB also over HTTPS
origin_ssl_protocols = ["TLSv1.2"]
}
}
# Default: pass through without caching (safest default)
default_cache_behavior {
target_origin_id = "alb-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = data.aws_cloudfront_cache_policy.disabled.id
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer_except_host.id
}
# /static/* : cache long + compress
ordered_cache_behavior {
path_pattern = "/static/*"
target_origin_id = "alb-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id
compress = true
}
# /api/* : no cache + forward viewer request
ordered_cache_behavior {
path_pattern = "/api/*"
target_origin_id = "alb-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = data.aws_cloudfront_cache_policy.disabled.id
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer_except_host.id
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true # custom domain / ACM in Part 3
}
}
output "cdn_domain" {
value = aws_cloudfront_distribution.app.domain_name
}
After terraform apply, hitting cdn_domain (e.g. d123abc.cloudfront.net) reaches the app through CloudFront. Propagation usually takes a few minutes.
5. Verify — hit/miss via X-Cache
Looking only at response headers with curl -I reveals cache behavior.
Static assets — Hit from the second request
$ curl -sI https://d123abc.cloudfront.net/static/app.abc123.js | grep -iE 'x-cache|cache-control|age'
cache-control: max-age=31536000, public, immutable
x-cache: Miss from cloudfront # first request: no copy at edge → origin
$ curl -sI https://d123abc.cloudfront.net/static/app.abc123.js | grep -iE 'x-cache|age'
x-cache: Hit from cloudfront # second: uses the edge copy
age: 12 # seconds the copy has been cached
Dynamic API — always Miss
$ curl -sI https://d123abc.cloudfront.net/api/me | grep -iE 'x-cache|cache-control'
cache-control: no-store
x-cache: Miss from cloudfront # no-store → origin every time
Reading it: Static flips to
Hiton the second request with risingAge→ caching works. Dynamic staysMissforever (no-store= always origin) → as intended. If/api/meshowsHit, either the origin didn’t sendno-storeor the behavior policy matched wrong.
6. Invalidation
If you updated a file whose URL can’t change, like HTML, invalidate it (Part 1, §4). Hash-versioned static assets need no invalidation.
# Invalidate only index.html (wildcards allowed too: "/*")
aws cloudfront create-invalidation \
--distribution-id E123ABCDEF456 \
--paths "/index.html"
You can trigger it from Terraform too, but invalidation is a one-off at deploy time, so it’s usually called via the CLI in a CI pipeline.
Cost note: Invalidation is free up to 1,000 paths/month, then billed per path. Even
"/*"counts as one path, but broad invalidation hurts hit ratio. That’s why versioning, not invalidation, is the standard for static assets.
Recap
| Step | What we did |
|---|---|
| Origin (Kotlin) | max-age=1 year immutable for static, no-store for dynamic |
| Behavior design | /static/*=CachingOptimized; /api/* & default=CachingDisabled + AllViewerExceptHostHeader |
| Terraform | Declared an ALB origin + ordered_cache_behavior in aws_cloudfront_distribution |
| Verify | Confirmed static=Hit, dynamic=Miss via X-Cache |
| Update | Versioning for hash assets, invalidation for HTML |
Half of caching is the origin header, half is the CloudFront behavior. When they disagree (origin says cache but the behavior is off, or vice versa), caching won’t work as intended.
Part 3 covers operations and advanced topics: protect private content with Signed URLs/cookies, run logic at the edge with CloudFront Functions and Lambda@Edge, harden security with a custom domain (ACM), S3 OAC, and WAF, and monitor operations with cache hit ratio, CloudWatch, and logs.
Appendix
A. CacheControl builder cheat sheet (Spring)
| Purpose | Kotlin |
|---|---|
| 1-year immutable static | CacheControl.maxAge(365, DAYS).cachePublic().immutable() |
| Short public cache | CacheControl.maxAge(60, SECONDS).cachePublic() |
| Forbid cache (private) | CacheControl.noStore() |
| Revalidate each time | CacheControl.noCache() |
B. Troubleshooting
| Symptom | Common cause |
|---|---|
| Dynamic API gets cached | Origin didn’t send no-store → behavior Default TTL applied |
| Static keeps missing | Cookies included in cache key (wrong policy) → use CachingOptimized |
| Old HTML keeps showing | Long max-age + no invalidation → invalidate or use short TTL |
| ALB 503 / routing broken | Viewer Host forwarded → use AllViewerExceptHostHeader |