Spring Boot – Configuration, Profiles and Properties

June 23, 20265 min readUpdated 8/18/2026

Every application has settings that differ between your laptop and production. Spring Boot's answer is a layered configuration system, and the part worth learning properly is which layer wins and how to bind settings to types instead of scattering strings.

Where configuration comes from

Boot reads from many sources and merges them. Later sources override earlier ones, and the practical order, lowest priority first, is:

  1. application.properties (or .yml) in the jar
  2. application-{profile}.properties for each active profile
  3. An application.properties beside the jar
  4. OS environment variables
  5. Java system properties (-D)
  6. Command-line arguments (--server.port=9000)

That ordering is the whole design. Defaults ship in the jar; environments override with env vars; a human overrides everything from the command line for one run.

# All three set the same thing. The last one wins.
export SERVER_PORT=8081
java -Dserver.port=8082 -jar app.jar --server.port=8083

Environment variables are spelled differently. Boot relaxes the binding, so pizza.jwt.expiration-minutes is settable as PIZZA_JWT_EXPIRATIONMINUTES — upper case, dots and dashes to underscores. This is how you configure a container without a properties file.

@Value, and why the pizza API stopped using it

@Value injects a single property:

@Value("${pizza.pricing.tax-rate}")
private BigDecimal taxRate;

@Value("${pizza.pricing.delivery-fee}")
private BigDecimal deliveryFee;

// With a default, after the colon:
@Value("${pizza.oauth2.success-redirect:http://localhost:5173/oauth2/callback}")
private String successRedirect;

It is fine for one or two values. It scales badly, and the pizza API demonstrated exactly how: the two fields above were declared in PricingService and again in CartServiceImpl. The same keys, written out four times, with nothing checking that they still agreed. That is the failure mode @Value invites — the property name is a string repeated wherever it is needed.

It has a second problem. A missing property becomes a null field, and the failure surfaces later and elsewhere. A missing pizza.jwt.secret produced a NullPointerException from inside the JWT library on the first login attempt.

@ConfigurationProperties

Bind a whole namespace once, into types:

@Validated
@ConfigurationProperties(prefix = "pizza")
public record PizzaProperties(
        @Valid Pricing pricing,
        @Valid Jwt jwt,
        @Valid Stripe stripe,
        @Valid Cors cors,
        @Valid Storage storage,
        @Valid Mail mail) {

    public record Pricing(
            @DecimalMin("0.0") @DecimalMax("1.0") BigDecimal taxRate,
            @DecimalMin("0.0") BigDecimal deliveryFee) {}

    public record Jwt(
            @NotBlank @Size(min = 32) String secret,
            @Positive long expirationMinutes) {}

    public record Stripe(String secretKey, String publishableKey, String webhookSecret) {}

    public record Cors(@NotEmpty List<String> allowedOrigins) {}

    public record Storage(@NotBlank String uploadDir, @Positive long maxImageBytes) {}

    public record Mail(@NotBlank String from) {}
}

Enable scanning for it once, on the application class:

@SpringBootApplication
@ConfigurationPropertiesScan
public class PizzaSpringbootBackendApplication { /* … */ }

Then inject it like any other bean:

BigDecimal fee = dto.orderType() == OrderType.DELIVERY
        ? scale(properties.pricing().deliveryFee())
        : BigDecimal.ZERO;
BigDecimal tax = scale(subtotal.multiply(properties.pricing().taxRate()));

Why records. A record component can only be set by the constructor, so these values are immutable after startup. Boot binds constructor arguments by name, which is why there are no setters and no @ConstructorBinding.

Relaxed binding does the naming translation. pizza.pricing.tax-rate binds to taxRate; kebab-case in the file, camelCase in Java, no annotation needed.

Lists bind properly. Cors takes a List<String>, and Boot splits the comma-separated property itself — which deletes the split(",") that every @Value version of this ends up writing by hand.

@Validated is the part that earns its keep

Those constraints are checked while the context is starting, so a bad value fails the application at boot with a message naming the property:

Property: pizza.jwt.secret
Value: "short"
Reason: size must be between 32 and 2147483647

Compare that with the @Value version's NullPointerException at first login. The 32-character minimum is not arbitrary — HS256 requires a 256-bit key and jjwt throws WeakKeyException for anything shorter. Checking it here converts a runtime failure into a startup failure that says what to fix.

Note what is deliberately not validated. Stripe has no @NotBlank, because the keys default to empty so the app still starts without them. Payment endpoints then fail loudly when used, which is the right trade for a demo someone just cloned.

Profiles

A profile is a named set of overrides. Put them in application-{profile}.properties:

# application.properties — the defaults, and the profile to use if nobody says otherwise
spring.profiles.active=local
spring.data.elasticsearch.repositories.enabled=false
# application-search.properties — activated with --spring.profiles.active=local,search
spring.data.elasticsearch.repositories.enabled=true
spring.elasticsearch.uris=http://localhost:9200
spring.elasticsearch.connection-timeout=2s

Profiles are additive, so local,search applies both files. This is the cleanest way to flip a default: nothing in the default configuration has to know the profile exists.

Beans can be profile-scoped too, and in the pizza API this is load-bearing rather than decorative:

@Configuration
@EnableJms
@Profile("messaging")
public class MessagingConfig { /* … */ }

A @JmsListener container starts polling as soon as the context is up. Without the profile, merely having the Artemis starter on the classpath would make the app try to reach a broker at every startup and fill the log with connection retries. Gated, the default run needs nothing but MySQL.

Keeping secrets out of the repository

The pattern the pizza API uses — an env var with an empty default, overridable by a gitignored file:

# application.properties — committed. No secrets, and the app still starts without them.
pizza.stripe.secret-key=${STRIPE_SECRET_KEY:}
pizza.stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET:}

# HS256 needs >= 256 bits of key. Overridden in application-local.properties.
pizza.jwt.secret=change-me-this-is-a-development-only-placeholder-secret-key

${VAR:default} reads an environment variable and falls back after the colon. Real values go in application-local.properties, which is gitignored, or in env vars in production. Three rules worth keeping:

  • Never commit a real secret, and treat one that was committed as burned — rotate it rather than deleting the line.
  • A placeholder must look like a placeholder. The JWT default above says change-me and development-only in the value itself.
  • The app should still start without secrets, so a first-time clone works and the failure happens at the feature, not at boot.

What to take from this

  • Later sources win — jar, then profile, then env, then system properties, then the command line.
  • @Value for one or two values; @ConfigurationProperties for a namespace. Duplicated keys across classes is the smell that means it is time.
  • @Validated turns a runtime NPE into a startup error that names the property.
  • Profiles for environment differences and for gating optional integrations off by default.

Next: aspect-oriented programming — and the proxy rule that explains why @Transactional, @Cacheable and @Async all fail in the same silent way.