Back to Journal
DevOps

How to Build Zero-Downtime Deployments Using Spring Boot

Step-by-step tutorial for building Zero-Downtime Deployments with Spring Boot, from project setup through deployment.

Muneer Puthiya Purayil 21 min read

Spring Boot provides the most mature zero-downtime deployment support of any web framework. Actuator health probes, graceful shutdown, Flyway migrations, and Spring Cloud's deployment tooling give enterprise Java teams everything they need to deploy without user impact. This tutorial builds a Spring Boot 3 application with production-grade zero-downtime deployment from project setup through Kubernetes rolling updates.

Project Setup

xml
1<!-- pom.xml key dependencies -->
2<dependencies>
3 <dependency>
4 <groupId>org.springframework.boot</groupId>
5 <artifactId>spring-boot-starter-web</artifactId>
6 </dependency>
7 <dependency>
8 <groupId>org.springframework.boot</groupId>
9 <artifactId>spring-boot-starter-actuator</artifactId>
10 </dependency>
11 <dependency>
12 <groupId>org.springframework.boot</groupId>
13 <artifactId>spring-boot-starter-data-jpa</artifactId>
14 </dependency>
15 <dependency>
16 <groupId>org.flywaydb</groupId>
17 <artifactId>flyway-core</artifactId>
18 </dependency>
19 <dependency>
20 <groupId>org.springframework.boot</groupId>
21 <artifactId>spring-boot-starter-data-redis</artifactId>
22 </dependency>
23 <dependency>
24 <groupId>io.micrometer</groupId>
25 <artifactId>micrometer-registry-prometheus</artifactId>
26 </dependency>
27</dependencies>
28 

Application Configuration

yaml
1# application.yml
2server:
3 port: 8080
4 shutdown: graceful
5 
6spring:
7 lifecycle:
8 timeout-per-shutdown-phase: 30s
9 datasource:
10 url: ${DATABASE_URL}
11 hikari:
12 maximum-pool-size: 20
13 minimum-idle: 5
14 connection-timeout: 5000
15 idle-timeout: 300000
16 max-lifetime: 1800000
17 flyway:
18 enabled: true
19 locations: classpath:db/migration
20 baseline-on-migrate: true
21 
22management:
23 endpoints:
24 web:
25 exposure:
26 include: health,info,prometheus,metrics
27 endpoint:
28 health:
29 probes:
30 enabled: true
31 show-details: always
32 group:
33 readiness:
34 include: readinessState,db,redis
35 liveness:
36 include: livenessState
37 health:
38 readinessstate:
39 enabled: true
40 livenessstate:
41 enabled: true
42 
43app:
44 version: ${APP_VERSION:unknown}
45 

Graceful Shutdown Implementation

java
1@Configuration
2public class GracefulShutdownConfig {
3 
4 private static final Logger log = LoggerFactory.getLogger(GracefulShutdownConfig.class);
5 private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
6 private final AtomicLong activeRequests = new AtomicLong(0);
7 
8 @EventListener(ContextClosedEvent.class)
9 public void onShutdown() {
10 log.info("Shutdown signal received");
11 shuttingDown.set(true);
12 
13 // Wait for load balancer to deregister
14 try {
15 log.info("Waiting 15s for LB deregistration");
16 Thread.sleep(15_000);
17 } catch (InterruptedException e) {
18 Thread.currentThread().interrupt();
19 }
20 
21 // Wait for active requests to drain
22 long deadline = System.currentTimeMillis() + 30_000;
23 while (activeRequests.get() > 0 && System.currentTimeMillis() < deadline) {
24 try {
25 Thread.sleep(100);
26 } catch (InterruptedException e) {
27 Thread.currentThread().interrupt();
28 break;
29 }
30 }
31 
32 log.info("Shutdown complete. Remaining active requests: {}", activeRequests.get());
33 }
34 
35 @Bean
36 public FilterRegistrationBean<ShutdownFilter> shutdownFilter() {
37 FilterRegistrationBean<ShutdownFilter> bean = new FilterRegistrationBean<>();
38 bean.setFilter(new ShutdownFilter(shuttingDown, activeRequests));
39 bean.addUrlPatterns("/*");
40 bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
41 return bean;
42 }
43}
44 
45public class ShutdownFilter extends OncePerRequestFilter {
46 
47 private final AtomicBoolean shuttingDown;
48 private final AtomicLong activeRequests;
49 
50 public ShutdownFilter(AtomicBoolean shuttingDown, AtomicLong activeRequests) {
51 this.shuttingDown = shuttingDown;
52 this.activeRequests = activeRequests;
53 }
54 
55 @Override
56 protected void doFilterInternal(
57 HttpServletRequest request,
58 HttpServletResponse response,
59 FilterChain chain) throws ServletException, IOException {
60 
61 // Allow health checks during shutdown
62 if (request.getRequestURI().startsWith("/actuator")) {
63 chain.doFilter(request, response);
64 return;
65 }
66 
67 if (shuttingDown.get()) {
68 response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
69 response.setHeader("Connection", "close");
70 response.setHeader("Retry-After", "5");
71 response.getWriter().write("{\"error\":\"Service shutting down\"}");
72 return;
73 }
74 
75 activeRequests.incrementAndGet();
76 try {
77 chain.doFilter(request, response);
78 } finally {
79 activeRequests.decrementAndGet();
80 }
81 }
82}
83 

Custom Health Indicators

java
1@Component
2public class DatabaseHealthIndicator implements HealthIndicator {
3 
4 private final DataSource dataSource;
5 
6 public DatabaseHealthIndicator(DataSource dataSource) {
7 this.dataSource = dataSource;
8 }
9 
10 @Override
11 public Health health() {
12 try (Connection conn = dataSource.getConnection()) {
13 PreparedStatement stmt = conn.prepareStatement("SELECT 1");
14 stmt.setQueryTimeout(2);
15 stmt.execute();
16 
17 HikariPoolMXBean pool =
18 ((HikariDataSource) dataSource).getHikariPoolMXBean();
19 
20 return Health.up()
21 .withDetail("active_connections", pool.getActiveConnections())
22 .withDetail("idle_connections", pool.getIdleConnections())
23 .withDetail("total_connections", pool.getTotalConnections())
24 .build();
25 } catch (SQLException e) {
26 return Health.down()
27 .withDetail("error", e.getMessage())
28 .build();
29 }
30 }
31}
32 
33@Component
34public class RedisHealthIndicator implements HealthIndicator {
35 
36 private final StringRedisTemplate redis;
37 
38 public RedisHealthIndicator(StringRedisTemplate redis) {
39 this.redis = redis;
40 }
41 
42 @Override
43 public Health health() {
44 try {
45 redis.getConnectionFactory().getConnection().ping();
46 return Health.up().build();
47 } catch (Exception e) {
48 return Health.down()
49 .withDetail("error", e.getMessage())
50 .build();
51 }
52 }
53}
54 
55@Component
56public class ReadinessStateManager implements ApplicationListener<ApplicationReadyEvent> {
57 
58 private final ApplicationAvailability availability;
59 
60 public ReadinessStateManager(ApplicationAvailability availability) {
61 this.availability = availability;
62 }
63 
64 @Override
65 public void onApplicationEvent(ApplicationReadyEvent event) {
66 // Mark as ready only after caches are warmed
67 }
68 
69 public void markReady() {
70 AvailabilityChangeEvent.publish(
71 availability, this, ReadinessState.ACCEPTING_TRAFFIC
72 );
73 }
74 
75 public void markNotReady() {
76 AvailabilityChangeEvent.publish(
77 availability, this, ReadinessState.REFUSING_TRAFFIC
78 );
79 }
80}
81 

Cache Warming on Startup

java
1@Component
2public class CacheWarmer implements ApplicationRunner {
3 
4 private static final Logger log = LoggerFactory.getLogger(CacheWarmer.class);
5 
6 private final PlanRepository planRepository;
7 private final StringRedisTemplate redis;
8 private final ReadinessStateManager readiness;
9 private final ObjectMapper mapper;
10 
11 public CacheWarmer(
12 PlanRepository planRepository,
13 StringRedisTemplate redis,
14 ReadinessStateManager readiness,
15 ObjectMapper mapper) {
16 this.planRepository = planRepository;
17 this.redis = redis;
18 this.readiness = readiness;
19 this.mapper = mapper;
20 }
21 
22 @Override
23 public void run(ApplicationArguments args) throws Exception {
24 log.info("Warming caches...");
25 
26 List<Plan> plans = planRepository.findByActiveTrue();
27 for (Plan plan : plans) {
28 redis.opsForValue().set(
29 "plan:" + plan.getId(),
30 mapper.writeValueAsString(plan),
31 Duration.ofHours(1)
32 );
33 }
34 
35 log.info("Warmed {} plan caches", plans.size());
36 
37 // Mark as ready after warmup
38 readiness.markReady();
39 log.info("Application ready to accept traffic");
40 }
41}
42 

Need a second opinion on your DevOps pipelines architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Database Migrations with Flyway

sql
1-- db/migration/V1__initial_schema.sql
2CREATE TABLE IF NOT EXISTS plans (
3 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 name VARCHAR(100) NOT NULL,
5 price INTEGER NOT NULL,
6 active BOOLEAN DEFAULT true,
7 created_at TIMESTAMPTZ DEFAULT NOW()
8);
9 
10-- db/migration/V2__add_billing_cycle.sql
11-- Safe: nullable column, no table lock
12ALTER TABLE plans ADD COLUMN IF NOT EXISTS billing_cycle VARCHAR(20);
13 
14-- db/migration/V3__add_billing_index.sql
15-- Safe: concurrent index creation
16CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_plans_billing_cycle
17ON plans (billing_cycle);
18 

Unsafe migrations that need special handling:

sql
1-- V4__rename_column.sql
2-- NOT SAFE — requires two-deploy approach
3-- Deploy 1: Add new column, dual-write
4-- Deploy 2: Migrate data, remove old column
5 
6-- Deploy 1 migration:
7ALTER TABLE plans ADD COLUMN IF NOT EXISTS price_cents INTEGER;
8UPDATE plans SET price_cents = price WHERE price_cents IS NULL;
9 
10-- Deploy 2 migration (after application uses price_cents):
11-- ALTER TABLE plans DROP COLUMN price;
12 

Feature Flags

java
1@Service
2public class FeatureFlagService {
3 
4 private final StringRedisTemplate redis;
5 private final ObjectMapper mapper;
6 private final ConcurrentHashMap<String, FeatureFlag> cache = new ConcurrentHashMap<>();
7 
8 @Data
9 @JsonIgnoreProperties(ignoreUnknown = true)
10 public static class FeatureFlag {
11 private String key;
12 private boolean enabled;
13 private int rolloutPercent;
14 private List<String> allowedTenants;
15 }
16 
17 public boolean isEnabled(String key, String tenantId) {
18 FeatureFlag flag = cache.get(key);
19 if (flag == null || !flag.isEnabled()) return false;
20 
21 if (flag.getAllowedTenants() != null &&
22 flag.getAllowedTenants().contains(tenantId)) {
23 return true;
24 }
25 
26 int hash = Math.abs((key + ":" + tenantId).hashCode() % 100);
27 return hash < flag.getRolloutPercent();
28 }
29 
30 @Scheduled(fixedRate = 10000)
31 public void refreshFlags() {
32 try {
33 Set<String> keys = redis.keys("flag:*");
34 if (keys == null) return;
35 
36 for (String key : keys) {
37 String raw = redis.opsForValue().get(key);
38 if (raw != null) {
39 FeatureFlag flag = mapper.readValue(raw, FeatureFlag.class);
40 cache.put(flag.getKey(), flag);
41 }
42 }
43 } catch (Exception e) {
44 // Log but don't fail — use stale cache
45 }
46 }
47}
48 

Kubernetes Deployment

yaml
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4 name: spring-boot-api
5spec:
6 replicas: 3
7 strategy:
8 type: RollingUpdate
9 rollingUpdate:
10 maxSurge: 1
11 maxUnavailable: 0
12 template:
13 spec:
14 terminationGracePeriodSeconds: 60
15 containers:
16 - name: api
17 image: spring-boot-api:latest
18 ports:
19 - containerPort: 8080
20 name: http
21 - containerPort: 8081
22 name: management
23 env:
24 - name: JAVA_OPTS
25 value: >-
26 -XX:MaxRAMPercentage=75.0
27 -XX:+UseG1GC
28 -XX:+UseStringDeduplication
29 - name: MANAGEMENT_SERVER_PORT
30 value: "8081"
31 resources:
32 requests:
33 memory: "512Mi"
34 cpu: "500m"
35 limits:
36 memory: "1Gi"
37 cpu: "1000m"
38 readinessProbe:
39 httpGet:
40 path: /actuator/health/readiness
41 port: 8081
42 initialDelaySeconds: 30
43 periodSeconds: 5
44 failureThreshold: 3
45 livenessProbe:
46 httpGet:
47 path: /actuator/health/liveness
48 port: 8081
49 initialDelaySeconds: 60
50 periodSeconds: 10
51 startupProbe:
52 httpGet:
53 path: /actuator/health/liveness
54 port: 8081
55 initialDelaySeconds: 10
56 periodSeconds: 5
57 failureThreshold: 30
58 lifecycle:
59 preStop:
60 exec:
61 command: ["/bin/sh", "-c", "sleep 15"]
62 

The startupProbe is essential for Spring Boot — JVM class loading and Spring context initialization typically take 20-60 seconds. Without it, the readiness probe would fail during startup and Kubernetes would restart the pod.

CI/CD Pipeline

yaml
1# .github/workflows/deploy.yml
2name: Deploy
3on:
4 push:
5 branches: [main]
6 
7jobs:
8 test:
9 runs-on: ubuntu-latest
10 steps:
11 - uses: actions/checkout@v4
12 - uses: actions/setup-java@v4
13 with:
14 java-version: '21'
15 distribution: 'temurin'
16 - run: ./mvnw test
17 
18 migrate:
19 needs: test
20 runs-on: ubuntu-latest
21 steps:
22 - uses: actions/checkout@v4
23 - uses: actions/setup-java@v4
24 with:
25 java-version: '21'
26 distribution: 'temurin'
27 - name: Run Flyway migrations
28 run: ./mvnw flyway:migrate
29 env:
30 SPRING_DATASOURCE_URL: ${{ secrets.DATABASE_URL }}
31 
32 deploy:
33 needs: migrate
34 runs-on: ubuntu-latest
35 steps:
36 - uses: actions/checkout@v4
37 - name: Build and push image
38 run: |
39 ./mvnw spring-boot:build-image \
40 -Dspring-boot.build-image.imageName=$ECR_REPO:${{ github.sha }}
41 docker push $ECR_REPO:${{ github.sha }}
42 - name: Deploy to Kubernetes
43 run: |
44 kubectl set image deployment/spring-boot-api \
45 api=$ECR_REPO:${{ github.sha }}
46 kubectl rollout status deployment/spring-boot-api --timeout=600s
47

JVM Warmup for Consistent Latency

java
1@Component
2public class JvmWarmup implements ApplicationRunner {
3 
4 private static final Logger log = LoggerFactory.getLogger(JvmWarmup.class);
5 
6 private final RestTemplate restTemplate;
7 
8 @Value("${server.port:8080}")
9 private int port;
10 
11 public JvmWarmup(RestTemplate restTemplate) {
12 this.restTemplate = restTemplate;
13 }
14 
15 @Override
16 public void run(ApplicationArguments args) {
17 log.info("Starting JVM warmup...");
18 String baseUrl = "http://localhost:" + port;
19 
20 // Hit critical endpoints to trigger JIT compilation
21 String[] warmupPaths = {
22 "/api/plans",
23 "/api/health",
24 };
25 
26 for (String path : warmupPaths) {
27 for (int i = 0; i < 100; i++) {
28 try {
29 restTemplate.getForEntity(baseUrl + path, String.class);
30 } catch (Exception e) {
31 // Ignore errors during warmup
32 }
33 }
34 }
35 
36 log.info("JVM warmup complete");
37 }
38}
39 

FAQ

Need expert help?

Building with CI/CD pipelines?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026