Available Hire Me
← All Writing Spring Boot

Configuration Done Right — Profiles, Env Vars, and Config Server

How to structure Spring Boot configuration across profiles, environment variables, and Spring Cloud Config Server — so your app behaves correctly at every stage without hardcoded values.

Configuration is one of those things that works fine in development and quietly breaks everything in production. Not because Spring Boot’s configuration system is difficult — it’s actually excellent — but because teams don’t think through the layering until something goes wrong at 2am.

This post covers how to structure configuration properly: when to use profiles, when to use environment variables, how @ConfigurationProperties makes the whole thing typesafe, and when a Config Server makes sense.

The property source hierarchy

Spring Boot resolves configuration from multiple sources in a defined priority order. The most important layers, from highest to lowest priority:

  1. Command-line arguments (--server.port=9090)
  2. OS environment variables (SERVER_PORT=9090)
  3. application-{profile}.yml / application-{profile}.properties
  4. application.yml / application.properties
  5. Default values set on @ConfigurationProperties fields

This hierarchy is your friend. It means you can set sensible defaults in application.yml, override per-environment in profile files, and override anything at deploy time with environment variables — without touching code.

Profiles for environment-specific config

The primary use case for profiles is environment-specific infrastructure config: database URLs, message broker endpoints, feature flags that differ between dev and production.

application.yml:

spring:
  datasource:
    driver-class-name: org.postgresql.Driver

app:
  feature-flags:
    new-pricing-algorithm: false

application-dev.yml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/myapp_dev
    username: dev
    password: dev

app:
  feature-flags:
    new-pricing-algorithm: true

application-production.yml:

spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}

Notice the pattern: application-production.yml doesn’t contain actual credentials. It contains environment variable references. The real values live in your deployment platform (ECS task definition, Kubernetes secret, SSM parameter). The profile file is committed to source control safely.

Never hardcode secrets in any property file

A common mistake is using profiles as a secrets store — real passwords in application-staging.yml, committed to the repo. The fix is to always use ${ENV_VAR} placeholders in profile files for anything sensitive. The profile file describes the structure; the environment provides the values.

The environment variable naming convention matters here. Spring Boot automatically converts DATABASE_URL to spring.datasource.url via relaxed binding — underscores become dots, uppercase becomes lowercase. For nested properties: APP_FEATURE_FLAGS_NEW_PRICING_ALGORITHM maps to app.feature-flags.new-pricing-algorithm.

@ConfigurationProperties over @Value

Injecting config with @Value("${some.property}") works but doesn’t scale. For any group of related properties, @ConfigurationProperties is strictly better:

@ConfigurationProperties(prefix = "app.trading")
@Validated
public record TradingConfig(
        @NotNull String exchangeUrl,
        @Positive int maxOpenPositions,
        @NotNull Duration heartbeatInterval,
        RiskConfig risk
) {
    public record RiskConfig(
            @DecimalMin("0.0") @DecimalMax("1.0") double maxExposureRatio,
            @Positive BigDecimal stopLossThreshold
    ) {}
}
app:
  trading:
    exchange-url: ${EXCHANGE_URL}
    max-open-positions: 10
    heartbeat-interval: PT30S
    risk:
      max-exposure-ratio: 0.15
      stop-loss-threshold: 500.00

Register it:

@SpringBootApplication
@EnableConfigurationProperties(TradingConfig.class)
public class TradingApplication { ... }

The benefits over @Value:

  • All config validated at startup — misconfiguration fails fast with a clear error
  • IDE autocompletion with the spring-boot-configuration-processor dependency
  • Nested structure maps naturally to nested records
  • Duration, DataSize, and other types are parsed automatically from YAML strings like PT30S or 10MB
  • Testable by constructing the record directly rather than needing a Spring context

Testing with @TestPropertySource

For tests that need specific config without loading a profile:

@SpringBootTest
@TestPropertySource(properties = {
    "app.trading.max-open-positions=5",
    "app.trading.risk.max-exposure-ratio=0.10"
})
class TradingServiceIntegrationTest { ... }

Or with a dedicated test config file:

@SpringBootTest
@ActiveProfiles("test")
class TradingServiceIntegrationTest { ... }

With a matching application-test.yml in src/test/resources. Test profiles should not be in src/main/resources — there’s no reason for production code to know about test configuration.

When to introduce Spring Cloud Config Server

A Config Server makes sense when:

  • You have many services that share configuration
  • You need to change configuration without redeploying
  • You want a single source of truth for all environment config

The Config Server is a Spring Boot app that serves config from a Git repository (or Vault, or S3):

# config-server application.yml
spring:
  cloud:
    config:
      server:
        git:
          uri: https://github.com/your-org/config-repo
          search-paths: '{application}'

Clients add the Spring Cloud Config dependency and point to the server:

# client bootstrap.yml
spring:
  application:
    name: trading-service
  config:
    import: "configserver:http://config-server:8888"

The server serves trading-service.yml, trading-service-production.yml, and so on from the Git repo. Clients refresh on restart or, if you wire it up, on a /actuator/refresh call.

The tradeoff: Config Server adds a dependency in your startup path. If the server is unavailable, your service won’t start unless you configure a fallback. For most teams, SSM Parameter Store or Secrets Manager is a simpler alternative that AWS manages for you — Spring Cloud AWS has first-class support for both.

@Profile for conditional beans

Profiles aren’t just for config. They gate beans:

@Service
@Profile("!production")
public class StubPaymentGateway implements PaymentGateway { ... }

@Service
@Profile("production")
public class StripePaymentGateway implements PaymentGateway { ... }

Use this sparingly — swapping infrastructure implementations this way makes it hard to detect config errors that only appear in production. For most cases, prefer a single implementation with behaviour controlled by config values.

ProTips

Use spring.config.activate.on-profile in multi-document YAML: Rather than multiple files, you can use document separators in a single application.yml:

---
spring:
  config:
    activate:
      on-profile: production
  datasource:
    url: ${DATABASE_URL}

Fail fast on missing required values: Add @Validated to your @ConfigurationProperties class. A missing required property fails at startup with a clear message rather than an NPE somewhere in business logic.

Log your active profiles at startup: Add logging.level.org.springframework.boot: DEBUG during development — Spring Boot logs which property sources it’s loading and in what order. Invaluable when properties aren’t resolving the way you expect.

If you’re trying to untangle configuration that has grown organically across a multi-environment system, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.