Configuration on Kubernetes

Run one image everywhere and feed it configuration from ConfigMaps, Secrets mounted as files, and health probes Spring Boot already understands.

Syntaxenv: SPRING_DATASOURCE_URL -> spring.datasource.url spring.config.import=optional:configtree:/etc/secrets/ /actuator/health/liveness /actuator/health/readiness

On Kubernetes the image is built once and promoted through environments unchanged. Everything that differs between staging and production comes from outside the image: environment variables, ConfigMaps and Secrets. Spring Boot already reads all three, so you rarely need an extra library.

Environment variables

Relaxed binding maps SPRING_DATASOURCE_URL to spring.datasource.url and APP_PAYMENTS_BASEURL to app.payments.base-url: upper case, dots become underscores and dashes are dropped. A ConfigMap value can be injected with configMapKeyRef, or a whole ConfigMap with envFrom. Environment variables override application.yaml in the jar, so the defaults stay in the code and only the differences are in the manifest.

Secrets as files

Secrets in environment variables can be read by anything that can see the process environment, and they show up in crash reports and debug output. Mount the Secret as a volume instead and import it with a config tree:

spring.config.import=optional:configtree:/etc/secrets/

Each file name becomes a property name and the file content its value, so a key called spring.datasource.password in the Secret sets that property. optional: lets the same configuration start on a laptop where the directory does not exist.

Probes

The liveness and readiness health groups are on by default (management.endpoint.health.probes.enabled defaults to true), so /actuator/health/liveness and /actuator/health/readiness exist wherever the application runs, on a laptop as well as in the cluster. The health endpoint is exposed over HTTP by default, so the probes need no extra exposure settings. Keep liveness about the process only: if liveness checks the database, a database outage restarts every pod and makes the outage worse. Readiness may include the database, so a pod that cannot reach it stops receiving traffic.

Changes need a restart

Spring Boot reads configuration at start-up. Editing a ConfigMap does not change a running application, and environment variables never change in a running container. Roll the Deployment after a change; many teams add a checksum of the ConfigMap as a pod annotation so that any change triggers a rollout automatically.

Mistakes people make

  • Profiles per cluster baked into the image (application-prod-eu.yaml). Prefer a small number of profiles and per-environment values from the manifest.
  • No validation. Bind settings to a validated @ConfigurationProperties record so a missing value fails start-up instead of the first request.
  • A terminationGracePeriodSeconds shorter than spring.lifecycle.timeout-per-shutdown-phase, so Kubernetes kills the pod during graceful shutdown.

Example

Example · yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookings
spec:
  replicas: 3
  selector:
    matchLabels:
      app: bookings
  template:
    metadata:
      labels:
        app: bookings
    spec:
      terminationGracePeriodSeconds: 40
      containers:
        - name: app
          image: registry.example.com/shop/bookings:1.4.0
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: prod
            - name: APP_PAYMENTS_BASEURL
              valueFrom:
                configMapKeyRef:
                  name: bookings-config
                  key: payments-base-url
          volumeMounts:
            - name: secrets
              mountPath: /etc/secrets
              readOnly: true
          resources:
            requests:
              cpu: 500m
              memory: 768Mi
            limits:
              memory: 768Mi
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            periodSeconds: 10
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            failureThreshold: 30
            periodSeconds: 2
      volumes:
        - name: secrets
          secret:
            secretName: bookings-secrets

When to use it

  • Promote the same image from staging to production with only the Deployment manifest changing.
  • Give the application its database password without putting it in an environment variable.
  • Stop a misconfigured release from replacing healthy pods by failing its start-up.

More examples

application.yaml that works on and off the cluster

The config tree import is optional, so a developer without <code>/etc/secrets</code> still starts the application. The readiness group adds the database check to the readiness state, while liveness keeps only the application's own state. The shutdown phase timeout must fit inside the pod's <code>terminationGracePeriodSeconds</code>, which is 40 seconds in the Deployment above.

Example · yaml
spring:
  application:
    name: bookings
  config:
    # Every file in /etc/secrets becomes a property named after the file
    import: "optional:configtree:/etc/secrets/"
  lifecycle:
    timeout-per-shutdown-phase: 30s

server:
  shutdown: graceful

management:
  endpoint:
    health:
      # Liveness and readiness groups are on by default; add the database to readiness
      group:
        readiness:
          include: readinessState,db

A Secret keyed by property name, and a ConfigMap

Mounted at <code>/etc/secrets</code>, the Secret becomes one file per key, and the config tree turns each file into the property of the same name. The values here are placeholders; real ones are created by your secret manager or sealed-secrets tooling, never committed. The ConfigMap holds non-secret values and is read as an environment variable.

Example · yaml
# The Secret's keys are property names; mounted as files they feed configtree
apiVersion: v1
kind: Secret
metadata:
  name: bookings-secrets
type: Opaque
stringData:
  spring.datasource.url: jdbc:postgresql://bookings-db:5432/bookings
  spring.datasource.username: bookings
  spring.datasource.password: change-me-in-the-real-secret
  app.payments.api-key: test-key-not-real
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: bookings-config
data:
  payments-base-url: https://payments.internal.example.com

Validated settings that fail fast

The record collects values from two sources, the environment variable and the mounted file, behind one typed object. With <code>@Validated</code>, a typo in the manifest stops start-up with a message naming the property. On Kubernetes that means the new pod never becomes ready, and the rolling update halts while the old pods keep serving.

Example · java
import java.net.URI;
import java.time.Duration;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties("app.payments")
record PaymentsProperties(
        @NotNull URI baseUrl,               // APP_PAYMENTS_BASEURL from the ConfigMap
        @NotBlank String apiKey,            // /etc/secrets/app.payments.api-key
        @DefaultValue("2s") Duration timeout) {
}

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(PaymentsProperties.class)
public class PaymentsConfig {
    // A missing or blank value fails start-up, so the pod never turns ready
    // and the rolling update stops before it replaces healthy pods.
}

Discussion

  • Be the first to comment on this lesson.