Configuration on Kubernetes
Run one image everywhere and feed it configuration from ConfigMaps, Secrets mounted as files, and health probes Spring Boot already understands.
env: SPRING_DATASOURCE_URL -> spring.datasource.url
spring.config.import=optional:configtree:/etc/secrets/
/actuator/health/liveness /actuator/health/readinessOn 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
@ConfigurationPropertiesrecord so a missing value fails start-up instead of the first request. - A
terminationGracePeriodSecondsshorter thanspring.lifecycle.timeout-per-shutdown-phase, so Kubernetes kills the pod during graceful shutdown.
Example
apiVersionapps/v1
kindDeployment
metadata
namebookings
spec
replicas3
selector
matchLabels
appbookings
template
metadata
labels
appbookings
spec
terminationGracePeriodSeconds40
containers
nameapp
imageregistry.example.com/shop/bookings1.4.0
ports
containerPort8080
env
nameSPRING_PROFILES_ACTIVE
valueprod
nameAPP_PAYMENTS_BASEURL
valueFrom
configMapKeyRef
namebookings-config
keypayments-base-url
volumeMounts
namesecrets
mountPath/etc/secrets
readOnlytrue
resources
requests
cpu500m
memory768Mi
limits
memory768Mi
readinessProbe
httpGet
path/actuator/health/readiness
port8080
periodSeconds5
livenessProbe
httpGet
path/actuator/health/liveness
port8080
periodSeconds10
startupProbe
httpGet
path/actuator/health/liveness
port8080
failureThreshold30
periodSeconds2
volumes
namesecrets
secret
secretNamebookings-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.
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.
# 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.
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