Spring Boot Configuration Management — Profiles, @ConfigurationProperties, and Secrets

by Eric Hanson, Backend Developer at Clean Systems Consulting

Property source precedence — the complete ordering

Spring Boot loads properties from multiple sources, evaluated in a fixed precedence order. Higher-priority sources override lower-priority ones. The full ordering (highest to lowest):

  1. Command-line arguments (--server.port=9090)
  2. SPRING_APPLICATION_JSON environment variable (JSON blob of properties)
  3. Servlet init parameters
  4. JNDI attributes
  5. Java system properties (-Dserver.port=9090)
  6. OS environment variables (SERVER_PORT=9090)
  7. Profile-specific properties outside the JAR (application-{profile}.properties)
  8. Application properties outside the JAR (application.properties)
  9. Profile-specific properties inside the JAR
  10. Application properties inside the JAR (application.yml)
  11. @PropertySource annotations on @Configuration classes
  12. Default properties (SpringApplication.setDefaultProperties)

The practical implications:

Environment variables override everything in the JAR. DATABASE_URL set in the container environment overrides spring.datasource.url in application.yml. This is the intended behavior for containerized deployments — the JAR carries defaults, the environment carries production values.

@PropertySource is low-priority. Properties loaded via @PropertySource cannot override application.properties or environment variables. Teams that use @PropertySource for environment-specific configuration are surprised when environment variables don't override as expected — they do, @PropertySource just can't override application.properties, not the other way around.

Profile-specific files override the base file. application-production.yml overrides application.yml when the production profile is active. This is the correct mechanism for environment-specific overrides.

@ConfigurationProperties — typed, validated configuration

@Value("${some.property}") is the simple approach. It doesn't validate, doesn't provide IDE completion in many IDEs without additional setup, and scatters property usage throughout the codebase. For any non-trivial configuration, @ConfigurationProperties is better:

@ConfigurationProperties(prefix = "app.payment")
@Validated
public class PaymentConfig {

    @NotBlank
    private String apiKey;

    @NotNull
    @PositiveOrZero
    private Integer maxRetries = 3;

    @NotNull
    @DurationMin(seconds = 1)
    private Duration timeout = Duration.ofSeconds(30);

    @NotBlank
    private String webhookSecret;

    @Valid
    private CircuitBreaker circuitBreaker = new CircuitBreaker();

    // Getters and setters (or use records with Spring Boot 3.x)

    public static class CircuitBreaker {
        @Positive
        private int failureThreshold = 5;

        @NotNull
        private Duration waitDuration = Duration.ofSeconds(60);

        // Getters and setters
    }
}
app:
  payment:
    api-key: ${PAYMENT_API_KEY}
    max-retries: 5
    timeout: 45s
    webhook-secret: ${PAYMENT_WEBHOOK_SECRET}
    circuit-breaker:
      failure-threshold: 10
      wait-duration: 30s

Register the configuration class:

@Configuration
@EnableConfigurationProperties(PaymentConfig.class)
public class PaymentModuleConfig {
    // or use @ConfigurationPropertiesScan on the main class
}

Or add @Component to the @ConfigurationProperties class (simpler but couples the configuration to Spring component scanning).

Validation. @Validated on the class triggers Bean Validation on the properties when the context starts. A missing apiKey or an invalid timeout causes the application to fail at startup with a clear message — not a NullPointerException when the code first tries to use the value.

Type conversion. Spring Boot converts property strings to typed values automatically: "45s" to Duration.ofSeconds(45), "10MB" to DataSize.ofMegabytes(10), "true" to boolean. Use these types in @ConfigurationProperties classes rather than storing strings and parsing manually.

Relaxed binding. app.payment.api-key, app.payment.apiKey, APP_PAYMENT_API_KEY, and APP_PAYMENT_APIKEY all map to the apiKey field. Spring Boot's relaxed binding handles camelCase, kebab-case, and SCREAMING_SNAKE_CASE — use kebab-case in property files (api-key) and SCREAMING_SNAKE_CASE for environment variables (API_KEY).

Profiles — environment-specific configuration

Spring profiles enable loading different configuration for different environments. The recommended structure:

src/main/resources/
  application.yml            # shared across all environments
  application-dev.yml        # development overrides
  application-staging.yml    # staging overrides
  application-prod.yml       # production overrides
# application.yml — base configuration, environment-neutral
spring:
  application:
    name: order-service
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate

server:
  port: 8080
  shutdown: graceful

management:
  server:
    port: 8081
# application-dev.yml — development overrides
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/orderdb_dev
    username: devuser
    password: devpassword
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update  # more permissive in development

logging:
  level:
    com.example: DEBUG
    org.hibernate.SQL: DEBUG
# application-prod.yml — production overrides (no secrets — those come from environment)
spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
  jpa:
    show-sql: false

logging:
  level:
    root: WARN
    com.example: INFO

The production profile never contains secrets. Property values for DATABASE_URL, API keys, and credentials reference environment variables (${VARIABLE_NAME}). The environment (Kubernetes Secret, AWS SSM, Vault) provides the actual values.

Activate profiles:

# Environment variable (recommended for containers)
SPRING_PROFILES_ACTIVE=prod

# Command-line argument
java -jar app.jar --spring.profiles.active=prod

# In application.yml (for always-on profiles)
spring:
  profiles:
    active: prod

Multiple profiles. SPRING_PROFILES_ACTIVE=prod,feature-flags-v2 activates both profiles. Profile-specific files are processed in order — application-feature-flags-v2.yml properties override application-prod.yml properties for overlapping keys.

@Value vs @ConfigurationProperties — when to use each

@Value is appropriate for a single property used in one place:

@Service
public class EmailService {

    @Value("${app.email.from-address}")
    private String fromAddress;

    // ...
}

@ConfigurationProperties is appropriate when:

  • Multiple related properties belong together
  • Properties are used in more than one class (bind once, inject the config class)
  • Validation of the configuration is required
  • IDE support and documentation generation matter

The signal to refactor from @Value to @ConfigurationProperties: a class with more than two @Value fields, or the same property referenced in multiple classes.

Secrets — what not to do and what to do instead

Never commit secrets to version control. application-prod.yml with actual credentials is a security incident. Even if the repository is private, secrets in version control create risk: repository access grants, CI logs, git history after credential rotation.

Never log configuration properties at startup. spring.boot.admin.logging.enabled=true and similar logging of environment details may expose secrets in log aggregators. Spring Boot's Sanitizer redacts common secret patterns (keys containing password, secret, token, key) from actuator /env output. Add custom sanitizing patterns for application-specific secret names:

@Bean
public SanitizingFunction sanitizingFunction() {
    return data -> {
        String key = data.getKey().toLowerCase();
        if (key.contains("apikey") || key.contains("webhook") || key.contains("credential")) {
            return data.withValue("**REDACTED**");
        }
        return data;
    };
}

The options for secret management:

Environment variables (from Kubernetes Secrets, Docker secrets, ECS task definitions): the simplest approach. The secret is injected into the container environment; Spring Boot reads it as a property. No additional library required. The downside: environment variables are visible to all processes in the container and show up in /proc in some configurations.

Spring Cloud Config Server: a dedicated configuration server that serves properties to applications. Secrets are stored in the server (backed by Git, Vault, or a database); the application fetches them at startup. Adds infrastructure complexity; appropriate for large organizations with many services.

HashiCorp Vault with Spring Cloud Vault: fetches secrets from Vault at startup. Secrets are never stored on disk or in environment variables. Supports dynamic credentials (database credentials that expire and rotate automatically):

spring:
  cloud:
    vault:
      uri: https://vault.internal:8200
      authentication: kubernetes
      kubernetes:
        role: order-service
      generic:
        default-context: order-service

Properties from Vault are available as Spring Boot properties with the same relaxed binding.

AWS Parameter Store / Secrets Manager via spring-cloud-aws:

spring:
  config:
    import: aws-parameterstore:
  aws:
    parameterstore:
      prefix: /order-service/prod

All parameters under /order-service/prod/ are loaded as Spring Boot properties. /order-service/prod/database/password maps to database.password.

Configuration validation at startup

Every @ConfigurationProperties class with @Validated validates at context startup. For properties that fail validation, the application refuses to start with a clear message:

APPLICATION FAILED TO START

Description:
Binding to target org.springframework.boot.context.properties.bind.BindException:
  Failed to bind properties under 'app.payment' to com.example.PaymentConfig
  
Reason: app.payment.api-key must not be blank

This fast-fail at startup — rather than at runtime when the property is first used — is the primary advantage of @ConfigurationProperties with @Validated over @Value. A misconfigured production deployment fails immediately and visibly rather than failing silently on the first request that needs the misconfigured value.

For custom validation logic:

@ConfigurationProperties(prefix = "app.database")
@Validated
public class DatabaseConfig implements InitializingBean {

    private String readReplicaUrl;
    private String primaryUrl;

    @Override
    public void afterPropertiesSet() {
        if (readReplicaUrl != null && readReplicaUrl.equals(primaryUrl)) {
            throw new BeanCreationException(
                "read-replica-url must differ from primary-url");
        }
    }
}

InitializingBean.afterPropertiesSet() runs after the properties are bound — use it for cross-field validation that Bean Validation annotations can't express.

The configuration structure that works at scale

application.yml              # base config, environment-neutral, no secrets
application-dev.yml          # dev database, debug logging, relaxed constraints
application-test.yml         # test database (H2 or Testcontainers config)
application-staging.yml      # staging-specific timeouts, staging service URLs
application-prod.yml         # prod pool sizes, prod logging — NO SECRETS

Secrets arrive via environment variables or a secret manager, never in property files. Profile-specific files contain behavior configuration (log levels, pool sizes, feature flags) but never credentials. The JAR is identical across environments; only the environment configuration changes.

This structure means any developer can read the production profile without gaining access to any credentials. Security teams can audit the property files without worrying about credential exposure. Credential rotation doesn't require a code change or redeploy — update the Kubernetes Secret, restart the pods.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Hibernate Bulk Operations — update_all, delete_all, and Bypassing Entity Lifecycle

Loading entities to update or delete them one at a time is the JPA default and the worst approach for bulk operations. Here is when and how to execute bulk operations efficiently — and what you give up when you bypass the entity lifecycle.

Read more

HashiCorp Vault for Spring Boot Developers — Dynamic Secrets, Leases, and Kubernetes Auth

Vault is more than a secrets store. Dynamic database credentials, transit encryption, and Kubernetes-native authentication change how Spring Boot applications handle secrets — from static credentials in environment variables to short-lived credentials that rotate automatically.

Read more

Designing Thread-Safe Classes in Java — Confinement, Immutability, and Synchronization

Thread safety is not a property you add after the fact — it is a design decision made at the class level. Three strategies cover nearly every case: confinement, immutability, and synchronization. Here is how to reason about which applies and how to apply it correctly.

Read more

The Real Cost of Hiring a Backend Developer in Amsterdam (And the Smarter Alternative)

You budgeted for a backend developer. You didn't budget for the three months of interviews, the signing bonus someone else offered first, and the onboarding period where nothing ships. That's the part of the cost nobody puts in the job req.

Read more