CloudFront CDN in Practice (2) — Putting a Spring Boot + Kotlin Origin Behind CloudFront (Terraform)

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.

  1. Make the origin send the right Cache-Control (Kotlin) — long for static, no caching for dynamic.
  2. Make CloudFront behave differently per path (Terraform) — cache /static/*, pass through /api/*.

TL;DR

  • The origin states cache intent via headers. In Kotlin, static resources get Cache-Control: max-age=1 year, immutable; dynamic APIs get no-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_behavior in aws_cloudfront_distribution.
  • Verify with X-Cache. With curl -I, static shows Hit on the second request; dynamic always shows Miss.

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 for max-age=60s, cutting origin load a lot. Only the “per-user” ones get no-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) and no-store (forbid storing at all) serve different purposes. An ETag is meaningless on a no-store response. ETags fit no-cache/short-max-age responses 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.

MethodScopeWhen
ResponseEntity.cacheControl()One endpointPer-response, explicit
WebContentInterceptorPer path patternA rule like “/api/** is no-store” in one place
Filter (OncePerRequestFilter)Per path/conditionLower-level, fine-grained control
Resource handler (WebMvcConfigurer)Static resourcesThe §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-revalidate by 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 separate SecurityFilterChain, 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.

PathCache policyOrigin request policyIntent
/static/*CachingOptimized(not needed)Cache long, ignore cookies, compress
/api/*CachingDisabledAllViewerExceptHostHeaderNo cache, forward cookies/headers/query
default (*)CachingDisabledAllViewerExceptHostHeaderSafe 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. AllViewerExceptHostHeader leaves 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 Hit on the second request with rising Age → caching works. Dynamic stays Miss forever (no-store = always origin) → as intended. If /api/me shows Hit, either the origin didn’t send no-store or 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

StepWhat we did
Origin (Kotlin)max-age=1 year immutable for static, no-store for dynamic
Behavior design/static/*=CachingOptimized; /api/* & default=CachingDisabled + AllViewerExceptHostHeader
TerraformDeclared an ALB origin + ordered_cache_behavior in aws_cloudfront_distribution
VerifyConfirmed static=Hit, dynamic=Miss via X-Cache
UpdateVersioning 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)

PurposeKotlin
1-year immutable staticCacheControl.maxAge(365, DAYS).cachePublic().immutable()
Short public cacheCacheControl.maxAge(60, SECONDS).cachePublic()
Forbid cache (private)CacheControl.noStore()
Revalidate each timeCacheControl.noCache()

B. Troubleshooting

SymptomCommon cause
Dynamic API gets cachedOrigin didn’t send no-store → behavior Default TTL applied
Static keeps missingCookies included in cache key (wrong policy) → use CachingOptimized
Old HTML keeps showingLong max-age + no invalidation → invalidate or use short TTL
ALB 503 / routing brokenViewer Host forwarded → use AllViewerExceptHostHeader

C. References

Shop on Amazon

As an Amazon Associate, I earn from qualifying purchases.