← All articles

Spring Boot Profiles and Configuration, Properly

3 min read

  • Spring Boot
  • Configuration
  • DevOps

Most Spring Boot configuration confusion comes from one question: when the same property is set in three places, which one wins?

The order that decides everything

Spring Boot builds configuration from an ordered list of property sources. Later sources override earlier ones. Trimmed to what you actually meet day to day, highest priority first:

  1. Command line arguments, such as --server.port=8081
  2. SPRING_APPLICATION_JSON
  3. OS environment variables, such as SERVER_PORT=8081
  4. Profile-specific files, such as application-prod.properties
  5. application.properties
  6. @PropertySource
  7. Defaults set with SpringApplication.setDefaultProperties

Two consequences worth internalising:

  • An environment variable always beats anything in your .properties files. This is what makes containers work: you ship one image and configure it per environment.
  • A profile-specific file beats the base file, but loses to an environment variable.

Environment variables use relaxed binding, so server.port and SERVER_PORT are the same property. Uppercase, dots to underscores.

Profiles

A profile is a named set of configuration that switches on together.

src/main/resources/
├── application.yml          # shared by everything
├── application-dev.yml      # only when 'dev' is active
├── application-prod.yml     # only when 'prod' is active
└── application-test.yml
# application.yml
spring:
  application:
    name: order-service
  jpa:
    open-in-view: false   # turn this off; it is on by default

---
spring:
  config:
    activate:
      on-profile: dev
  jpa:
    hibernate:
      ddl-auto: update
  h2:
    console:
      enabled: true

---
spring:
  config:
    activate:
      on-profile: prod
  jpa:
    hibernate:
      ddl-auto: validate

The --- separators are multi-document YAML, so one file can hold every profile. Use spring.config.activate.on-profile. The older spring.profiles key was deprecated in Boot 2.4.

Activating one

java -jar app.jar --spring.profiles.active=prod   # command line
SPRING_PROFILES_ACTIVE=prod java -jar app.jar     # environment
@SpringBootTest
@ActiveProfiles("test")   // in tests
class OrderServiceTest { }

Avoid setting spring.profiles.active in application.properties. Configuration that decides which configuration to load, baked into the artefact, is how you end up deploying a jar that thinks it is in dev.

Profile-specific beans

@Configuration
public class PaymentConfig {

    @Bean
    @Profile("!prod")
    public PaymentGateway stubGateway() {
        return new StubPaymentGateway();
    }

    @Bean
    @Profile("prod")
    public PaymentGateway stripeGateway(StripeProperties props) {
        return new StripePaymentGateway(props.apiKey());
    }
}

!prod means "any profile except prod", so a new staging profile gets the stub automatically. Useful, and occasionally a nasty surprise, so be deliberate about which way round you write the condition.

Type-safe configuration

Scattered @Value("${...}") annotations are hard to find and validate. Bind a group of related properties to a record instead:

@ConfigurationProperties(prefix = "app.payment")
@Validated
public record PaymentProperties(
    @NotBlank String apiKey,
    @DefaultValue("5s") Duration timeout,
    @Min(0) @Max(10) int maxRetries
) { }
@SpringBootApplication
@EnableConfigurationProperties(PaymentProperties.class)
public class Application { }
app:
  payment:
    api-key: ${PAYMENT_API_KEY}
    timeout: 10s
    max-retries: 3

You get one place to look, IDE completion, Duration parsing for free, and (because of @Validated) a startup failure with a readable message if PAYMENT_API_KEY is missing. That beats a NullPointerException on the first payment attempt.

Secrets

Nothing secret goes in a properties file. Those files are committed, and a secret in git history stays there.

# wrong
app:
  payment:
    api-key: sk_live_51H8xY2abcdef

# right: resolved from the environment at startup
app:
  payment:
    api-key: ${PAYMENT_API_KEY}

The ${VAR} form fails fast if the variable is unset. For anything beyond a single service, graduate to a real secret store such as Vault, AWS Secrets Manager, or your platform's own, rather than growing the list of environment variables forever.

A pattern worth stealing

Give every non-secret property a working default in application.yml, then override only what changes per environment. A new developer clones the repo, runs ./mvnw spring-boot:run, and the application starts. No setup document, no missing variables.

Production then overrides those defaults with environment variables, which sit at the top of the precedence list and win every time.