03-spring-boot-essentials

Configuration and Profiles in Spring Boot

A staff-engineer guide to externalized configuration, @ConfigurationProperties, environment profiles, and secrets handling in Spring Boot.

August 14, 2026
backend-engineerprofilesyamlenvironmentconfiguration-properties

Configuration and Profiles

Hardcoded configuration is the fastest way to make a service unshippable — every environment (local, test, staging, production) needs different database URLs, thread pool sizes, and feature flags without a single line of code changing between them. This guide covers Spring Boot's externalized configuration model end to end: where values come from, how they're bound to type-safe objects, and how profiles switch behavior per environment.


1. The Externalized Configuration Principle

The twelve-factor app methodology states configuration should live in the environment, not in code. Spring Boot's Environment abstraction is built around exactly this idea — a unified view over configuration sourced from many places, resolved by strict precedence.

Property source precedence (highest wins)

PrioritySourceExample
1 (highest)Command-line argumentsjava -jar app.jar --server.port=8081
2SPRING_APPLICATION_JSON env varSPRING_APPLICATION_JSON='{"server":{"port":8081}}'
3JVM system properties-Dserver.port=8081
4OS environment variablesSERVER_PORT=8081
5application-{profile}.yml (outside jar)Config directory next to the jar
6application-{profile}.yml (inside jar)Bundled in src/main/resources
7application.yml (outside jar)Overrides bundled defaults
8application.yml (inside jar)The bundled base config
9 (lowest)@PropertySource annotations, defaults in codeFallback values
💡

Environment variables and command-line args always win. This is deliberate — it's what makes container orchestrators (Kubernetes env: blocks, ECS task definitions) able to override any application.yml setting without rebuilding the image. Spring relaxes binding rules so SERVER_PORT (env var convention) automatically maps to server.port (property convention).


2. YAML and Properties Files

yaml
# application.yml — the base configuration, applies to all profiles
spring:
  application:
    name: order-service
 
server:
  port: 8080
  shutdown: graceful
 
logging:
  level:
    root: INFO
    com.acme.orders: DEBUG
 
management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  endpoint:
    health:
      show-details: when-authorized
 
order-service:
  payment:
    timeout: 5s
    retry-attempts: 3
  inventory:
    reservation-hold-minutes: 15
properties
# Equivalent in .properties format (less common in modern Spring Boot, but still supported)
spring.application.name=order-service
server.port=8080
order-service.payment.timeout=5s
order-service.payment.retry-attempts=3

YAML is preferred over .properties in modern Spring Boot codebases — it expresses hierarchical structure naturally, supports multiple profile documents in a single file via --- separators, and avoids the repetitive dotted-key noise of .properties. Reserve .properties for legacy modules or tools that specifically require it.


3. Type-Safe Configuration with @ConfigurationProperties

Reading individual values with @Value("${...}") scattered across classes doesn't scale. @ConfigurationProperties binds a whole configuration tree to a strongly-typed, validated object.

java
@ConfigurationProperties(prefix = "order-service.payment")
public record PaymentProperties(
        @DurationUnit(ChronoUnit.SECONDS) Duration timeout,
        int retryAttempts,
        String defaultCurrency
) {
    // Compact constructor for validation/defaults
    public PaymentProperties {
        if (retryAttempts < 0) {
            throw new IllegalArgumentException("retryAttempts must not be negative");
        }
        if (defaultCurrency == null) {
            defaultCurrency = "USD";
        }
    }
}
java
@Configuration
@EnableConfigurationProperties(PaymentProperties.class)
public class PaymentConfig {
 
    @Bean
    public PaymentGateway paymentGateway(PaymentProperties properties, RestClient.Builder builder) {
        RestClient client = builder
            .requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
                .withConnectTimeout(properties.timeout())
                .withReadTimeout(properties.timeout()))
            .build();
        return new HttpPaymentGateway(client, properties.retryAttempts(), properties.defaultCurrency());
    }
}
yaml
order-service:
  payment:
    timeout: 5s
    retry-attempts: 3
    default-currency: USD

Use a Java record for @ConfigurationProperties (Spring Boot 3.x / Spring 6+). It's immutable, requires no boilerplate getters/setters, and the compact constructor gives you a natural place for validation. This has replaced the older mutable-class-with-setters pattern in modern Spring Boot codebases.

@Value vs @ConfigurationProperties

Aspect@Value("${...}")@ConfigurationProperties
Type safetyPer-field, manualWhole tree, compile-time checked
Relaxed binding (kebab-case, camelCase, env vars)NoYes
ValidationManualNative @Validated support
SpEL expressionsYesNo
TestabilityRequires @TestPropertySource per fieldBind directly with @EnableConfigurationProperties in a test slice
Best forOne-off, simple valuesStructured, related configuration
java
// @Value works but doesn't scale — no grouping, no validation, easy to typo the key
@Service
public class LegacyPaymentService {
    @Value("${order-service.payment.retry-attempts:3}")
    private int retryAttempts;
}
⚠️

@Value field injection has the same testability problems as @Autowired field injection — it requires reflection or a running Spring context to set in tests. Prefer binding related settings into a single @ConfigurationProperties record injected via the constructor.

Validating configuration at startup

java
@ConfigurationProperties(prefix = "order-service.inventory")
@Validated
public record InventoryProperties(
        @Min(1) @Max(1440) int reservationHoldMinutes,
        @NotBlank String warehouseRegion
) {}
🚨

Validate configuration and fail fast at startup, not on the first request that touches a bad value. A missing or malformed warehouseRegion should crash the container during SpringApplication.run(), not three hours later during a checkout flow in production. This is one of the cheapest reliability wins available — Bean Validation on @ConfigurationProperties gives it to you for free.


4. Environment Profiles

Profiles let you swap configuration (and even entire beans) per environment without changing code.

yaml
# application.yml — shared defaults + profile-specific overrides using YAML documents
spring:
  application:
    name: order-service
 
---
spring:
  config:
    activate:
      on-profile: dev
datasource:
  url: jdbc:h2:mem:orders
logging:
  level:
    com.acme.orders: DEBUG
 
---
spring:
  config:
    activate:
      on-profile: staging
datasource:
  url: jdbc:postgresql://staging-db.internal:5432/orders
 
---
spring:
  config:
    activate:
      on-profile: prod
datasource:
  url: jdbc:postgresql://prod-db.internal:5432/orders
logging:
  level:
    com.acme.orders: WARN

Or as separate files (equally valid, often clearer for larger configs):

text
src/main/resources/
├── application.yml            # shared, profile-agnostic config
├── application-dev.yml        # local development overrides
├── application-staging.yml    # staging environment
└── application-prod.yml       # production environment
bash
# Activating a profile
java -jar order-service.jar --spring.profiles.active=prod
 
# Or via environment variable (typical in containers)
export SPRING_PROFILES_ACTIVE=prod
java -jar order-service.jar
 
# Multiple profiles compose together
java -jar order-service.jar --spring.profiles.active=prod,metrics
💡

Profiles compose, they don't replace. Activating prod,metrics merges both profile documents on top of the base application.yml. If both define the same key, the last-listed active profile wins. This lets you build small, orthogonal profiles (metrics, tracing, feature-x) that combine freely instead of one giant profile per permutation.

Profile-specific beans

java
@Configuration
public class NotificationConfig {
 
    @Bean
    @Profile("prod")
    public NotificationChannel productionEmailChannel(SesClient sesClient) {
        return new SesEmailChannel(sesClient); // real AWS SES integration
    }
 
    @Bean
    @Profile({"dev", "test"})
    public NotificationChannel loggingOnlyChannel() {
        return (to, message) -> log.info("Would send to {}: {}", to, message); // no real email sent
    }
}
⚠️

Never let @Profile("dev") mock beans accidentally activate in production because a profile name was misspelled or the deployment script forgot to set SPRING_PROFILES_ACTIVE. A good safeguard: fail startup if no explicit profile is active, rather than silently falling back to default. You can enforce this with a custom ApplicationListener that checks environment.getActiveProfiles().length > 0.


5. application.yml vs Command-Line vs Environment Variables in Practice

EnvironmentPreferred sourceWhy
Local developmentapplication-dev.yml + IDE run configFast iteration, no external system needed
CI test runsapplication-test.yml + TestcontainersIsolated, reproducible, disposable
KubernetesEnvironment variables from ConfigMap/SecretNative to the platform, no image rebuild to change config
Docker Compose (local integration).env file + environment: blockMatches production's env-var-driven model
yaml
# Kubernetes ConfigMap example — same relaxed binding rules apply
apiVersion: v1
kind: ConfigMap
metadata:
  name: order-service-config
data:
  SPRING_PROFILES_ACTIVE: "prod"
  ORDER_SERVICE_PAYMENT_TIMEOUT: "8s"
  ORDER_SERVICE_PAYMENT_RETRY_ATTEMPTS: "5"

Spring Boot's relaxed binding means ORDER_SERVICE_PAYMENT_RETRY_ATTEMPTS (env var, uppercase with underscores) automatically binds to order-service.payment.retry-attempts (kebab-case YAML key) without any extra mapping code. This is what makes the same @ConfigurationProperties class work identically whether the value comes from YAML locally or a Kubernetes ConfigMap in production.


6. Secrets: What Not to Do, and What to Do Instead

yaml
# NEVER do this — plaintext secret committed to source control
datasource:
  url: jdbc:postgresql://prod-db.internal:5432/orders
  username: admin
  password: SuperSecret123!   # DO NOT COMMIT
🚨

Never commit secrets to application.yml or any file in version control — not even in a "private" repo, not even temporarily. Once a secret is in git history, rotating it is the only real fix; deleting the line in a later commit does not remove it from history. This applies to database passwords, API keys, JWT signing secrets, and third-party credentials alike.

Practical secrets handling for a Spring Boot service

yaml
# application.yml references an environment variable — no secret value in the file
datasource:
  url: jdbc:postgresql://prod-db.internal:5432/orders
  username: ${DB_USERNAME}
  password: ${DB_PASSWORD}
bash
# The actual secret is injected at deploy time, never stored in the repo
# Kubernetes: mounted from a Secret resource as env vars
# AWS: pulled from Secrets Manager / Parameter Store into ECS task definition
# Docker Compose (local only): from a gitignored .env file
ApproachWhere secrets liveNotes
Environment variables from orchestrator secretsKubernetes Secret, ECS secrets, Docker secretsStandard baseline; combine with encryption at rest
Spring Cloud Config with encrypted valuesConfig server + symmetric/asymmetric keyCentralized across many services
HashiCorp Vault / AWS Secrets Manager / GCP Secret ManagerDedicated secrets store, fetched at startup or runtimeBest for rotation, auditing, fine-grained access control
.gitignored local .env for development onlyDeveloper's machineNever used in staging/production
💡

For anything beyond a small service, a dedicated secrets manager (Vault, AWS Secrets Manager) is worth the integration cost — it gives you rotation without redeployment, audit logs of who accessed what secret when, and fine-grained per-service access policies that a flat environment variable can't provide.


7. Feature Flags via Configuration

A lightweight pattern for toggling behavior without a full feature-flag platform:

java
@ConfigurationProperties(prefix = "order-service.features")
public record FeatureFlags(
        boolean newPricingEngine,
        boolean asyncNotifications
) {}
java
@Service
public class PricingService {
    private final FeatureFlags featureFlags;
    private final PricingStrategy legacyStrategy;
    private final PricingStrategy newStrategy;
 
    public Money calculatePrice(Order order) {
        PricingStrategy strategy = featureFlags.newPricingEngine() ? newStrategy : legacyStrategy;
        return strategy.compute(order);
    }
}
yaml
order-service:
  features:
    new-pricing-engine: false
    async-notifications: true

Static YAML feature flags are fine for deploy-time toggles (this environment has feature X enabled, that one doesn't). For runtime, per-request toggles (gradual rollout, A/B tests, kill switches without a redeploy), use a dedicated feature-flag service — Spring configuration isn't designed to change without a restart or a config refresh event.


8. Config Refresh Without Restart (Spring Cloud Config Preview)

java
@RestController
@RefreshScope // Spring Cloud Config — bean is recreated on a /actuator/refresh call
public class PricingController {
 
    @Value("${order-service.discount.rate}")
    private double discountRate;
}
💡

@RefreshScope requires spring-cloud-starter-config and is only relevant if you've adopted Spring Cloud Config Server for centralized configuration across multiple services. For a single-service setup, a plain restart on config change is simpler and has no hidden staleness risks — introduce @RefreshScope only when the operational need (zero-downtime config changes across a fleet) justifies the added complexity.


Key takeaways

  • Spring resolves configuration from many sources with strict precedence; environment variables and command-line args always beat application.yml, which is exactly what makes container-based deployment work without image rebuilds.
  • Prefer @ConfigurationProperties records over scattered @Value fields for anything beyond a single, one-off value — you get type safety, relaxed binding, and native validation.
  • Validate configuration with Bean Validation on your @ConfigurationProperties classes so bad config fails the deployment at startup, not a production request hours later.
  • Profiles compose (multiple can be active at once); the last-listed active profile wins on key conflicts.
  • Never commit secrets to any file in version control, even briefly — reference them via environment variables or a dedicated secrets manager instead.
  • Relaxed binding means the same @ConfigurationProperties class works identically whether values come from YAML, environment variables, or a Kubernetes ConfigMap/Secret.
  • Guard against a misconfigured or missing SPRING_PROFILES_ACTIVE silently falling back to dev-like defaults in production.
  • Use @Profile-scoped beans to swap entire implementations (real vs. fake notification channels) per environment, not if (profile.equals("prod")) conditionals scattered through business logic.

Interview Questions

  • What is Spring Boot's property source precedence order, and why do environment variables outrank application.yml?
  • What is the difference between @Value and @ConfigurationProperties? When would you choose each?
  • Why is it good practice to validate @ConfigurationProperties with Bean Validation annotations?
  • How does Spring Boot's relaxed binding let DB_PASSWORD (env var) map to db.password (YAML key) automatically?
  • How do multiple active Spring profiles combine when they define overlapping keys?
  • What's the risk of hardcoding secrets in application.yml, even in a private repository?
  • How would you structure configuration for a service that needs different database URLs across dev, staging, and production?
  • What is @Profile used for beyond simple property overrides, and give an example where you'd swap an entire bean implementation per environment.
  • Why might you prefer a record over a mutable class for a @ConfigurationProperties binding target?
  • What operational risk exists if a deployment forgets to set SPRING_PROFILES_ACTIVE, and how would you defend against it?
  • What is @RefreshScope, and what problem does it solve that a normal @ConfigurationProperties bean doesn't?
  • How would you handle a secret that needs to be rotated without redeploying the service?
  • Why is failing fast at application startup on bad configuration preferable to discovering it on the first affected request?