Skip to content
Commitwise
Menu
Java Backend

Configuration and profiles

Configure a Spring Boot service the same way in every environment with application.yml, typed and validated @ConfigurationProperties, profiles and environment variables, and know which value wins when the same setting is defined in five places.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The same jar runs on your laptop, in CI, in staging and in production. What changes is configuration: the database URL, the payment provider’s sandbox or live keys, timeouts, feature flags. Get it wrong and the staging app charges real cards, or production quietly falls back to a default that only made sense on your laptop.

This tutorial covers where Spring Boot reads settings from, how to turn them into typed objects that fail at startup instead of at 3 a.m., and how profiles and environment variables let one image run everywhere.

New to some of these? Open what you need, or skip ahead.

Basics:What is Spring Boot?

Spring Boot is the most popular framework for Java and Kotlin backends. You write classes and mark them with annotations; Spring creates the objects (called beans), connects them to each other (dependency injection: a controller gets the service it needs through its constructor) and runs a web server that sends HTTP requests to your methods.

Starters (spring-boot-starter-web, spring-boot-starter-data-jpa…) add features by adding a dependency, with sensible defaults you can change in application.yml.

Basics:What is an environment variable?

An environment variable is a named value the operating system gives to a program when it starts, like DATABASE_URL=postgres://…. Docker, Kubernetes and every cloud platform let you set them per deployment, which makes them the standard way to pass settings and secrets to a server without changing its code.

Part Not started

Typed settings

A recipe card says “bake at 180 °C for 25 minutes”. The oven doesn’t care where the numbers came from; it only needs them before it starts. Configuration is the recipe card for your service: values that are read once at startup and used everywhere.

In Spring Boot they go in src/main/resources/application.yml. You can read a single value with @Value, but the better way is to group related settings into one class with a field per setting. Spring fills it in at startup, checks the values, and any service that needs them gets the whole object.

@ConfigurationProperties binds a prefix to an immutable, typed object (a record or Kotlin data class bound through its constructor). You get type conversion (Duration, DataSize, enums, lists, maps), defaults in one place, Bean Validation with @Validated so a bad value fails startup, IDE completion through the configuration processor, and a single class to inject and to fake in tests. @Value is fine for one-off values but scatters string keys across the codebase, can’t be validated as a group, and makes typos silent when combined with defaults.

@ConfigurationProperties("payment")
@Validated
data class PaymentProperties(
    @field:NotNull val baseUrl: URI,
    val timeout: Duration = Duration.ofSeconds(5),
    @field:Valid val retry: Retry = Retry(),
    @field:NotEmpty val allowedCurrencies: List<String>,
) {
    data class Retry(
        @field:Min(1) @field:Max(10) val maxAttempts: Int = 3,
        val backoff: Duration = Duration.ofMillis(200),
    )
}

@SpringBootApplication
@ConfigurationPropertiesScan
class ShopApplication

@Service
class PaymentClient(private val props: PaymentProperties)
@ConfigurationProperties("payment")
@Validated
public record PaymentProperties(
        @NotNull URI baseUrl,
        @NotNull Duration timeout,
        @Valid Retry retry,
        @NotEmpty List<String> allowedCurrencies) {

    public record Retry(@Min(1) @Max(10) int maxAttempts, Duration backoff) {
        public Retry {
            if (backoff == null) backoff = Duration.ofMillis(200);   // a default in one place
        }
    }
}

@SpringBootApplication
@ConfigurationPropertiesScan          // finds every @ConfigurationProperties class
public class ShopApplication { /* main */ }

@Service
public class PaymentClient {
    private final PaymentProperties props;
    public PaymentClient(PaymentProperties props) { this.props = props; }
}
# src/main/resources/application.yml
payment:
  base-url: https://api.payments.example.com
  timeout: 5s
  retry:
    max-attempts: 3
    backoff: 200ms
  allowed-currencies: [EUR, USD, RSD]
  • Names: base-url in YAML binds to baseUrl in code (Spring’s relaxed binding accepts base-url, baseUrl and BASE_URL). Use kebab-case in files.
  • Types: 5s becomes a Duration, [EUR, USD] a List. A value that can’t be converted (timeout: five) stops the app at startup with a clear message.
  • Validation: with @Validated, a missing base-url or max-attempts: 0 also stops startup, naming the property. Failing at startup is good: the old version keeps running and the deploy is rolled back, instead of the first payment failing in production.
  • Defaults live in the class, so every reader sees them in one place.

Kotlin note

In Kotlin, validation annotations need the @field: target, otherwise they land on the constructor parameter and Bean Validation never sees them. Default parameter values work as property defaults.

Spot the bugThe timeout nobody set

Tap the line with the bug.

@Service
class ShippingClient(
    @Value("${shipping.timeout:10}")
    private val timeoutSeconds: Int,

    @Value("${shiping.base-url:http://localhost:8089}")
    private val baseUrl: String,
)
@Service
public class ShippingClient {
    @Value("${shipping.timeout:10}")
    private int timeoutSeconds;

    @Value("${shiping.base-url:http://localhost:8089}")
    private String baseUrl;
}

Exerciseeasy

Replace @Value with a properties class

A notification service reads these settings with @Value in three different classes: push.enabled (boolean, default true), push.batch-size (between 1 and 500, default 100), push.apns.key-id (required) and push.apns.team-id (required). Write one validated properties class, and the YAML for it.

›

Nest the apns settings as their own record or data class, and mark the nested object @Valid so its fields are validated too.

SolutionTry it yourself first
@ConfigurationProperties("push")
@Validated
data class PushProperties(
    val enabled: Boolean = true,
    @field:Min(1) @field:Max(500) val batchSize: Int = 100,
    @field:Valid val apns: Apns,
) {
    data class Apns(
        @field:NotBlank val keyId: String,
        @field:NotBlank val teamId: String,
    )
}
@ConfigurationProperties("push")
@Validated
public record PushProperties(
        Boolean enabled,
        @Min(1) @Max(500) Integer batchSize,
        @NotNull @Valid Apns apns) {

    public PushProperties {
        if (enabled == null) enabled = true;
        if (batchSize == null) batchSize = 100;
    }

    public record Apns(@NotBlank String keyId, @NotBlank String teamId) {}
}
push:
  batch-size: 200
  apns:
    key-id: ${APNS_KEY_ID}
    team-id: ${APNS_TEAM_ID}

${APNS_KEY_ID} is a placeholder resolved from the environment. Every class that needs push settings now injects PushProperties; a test can create one with new PushProperties(true, 10, new Apns("k", "t")) without starting Spring.

Binding details worth knowing

  • Constructor binding is automatic for records and for classes with a single constructor (Boot 3); the object is immutable, so nothing can change it after startup.
  • Unknown keys are ignored by default: a misspelled nested key under a valid prefix is silently dropped. Required fields with validation catch most of it; ignoreUnknownFields = false catches the rest, at the cost of breaking on keys meant for other code.
  • Metadata: spring-boot-configuration-processor generates META-INF/spring-configuration-metadata.json, so IDEs complete and document your keys, and Javadoc on fields becomes the description.
  • /actuator/configprops shows the bound values (sanitized) in a running app; /actuator/env shows where each value came from.
  • @ConfigurationProperties + records/data classes + @ConfigurationPropertiesScan: typed, immutable, with Duration/DataSize/enum conversion and defaults in one place.
  • @Validated + Bean Validation (@field: in Kotlin, @Valid on nested objects) fails startup on missing or invalid values.
  • @Value with defaults turns typos into silent misconfiguration; unknown keys are ignored by default.
  • Configuration processor for IDE metadata; /actuator/configprops and /actuator/env to inspect.

Check this part

  1. Why is a validated @ConfigurationProperties class better than many @Value fields?

  2. In application.yml you write timeout: 5s. What type can the field be?

  3. What happens to a misspelled key under a valid prefix by default?

Settings live in application.yml and are bound to typed @ConfigurationProperties classes (records or data classes) with defaults and validation, so a missing or wrong value stops the app at startup. Prefer them over scattered @Value strings.

Part Not started

Profiles and where values come from

Think of layers of transparent sheets. The bottom one has the defaults; each sheet on top can cover some values. Whatever is visible from above is what the app uses.

Spring Boot stacks configuration the same way. The main application.yml is the bottom sheet. A profile adds another: run with the prod profile, and application-prod.yml is laid on top. Environment variables go above files, and command-line arguments above everything. So production can change one value without editing any file in the jar.

Boot resolves properties from an ordered list of sources; higher ones override lower ones. Simplified, from lowest to highest: defaults in code, application.yml in the jar, profile-specific files in the jar, the same files outside the jar (./config/), config imports (spring.config.import: config trees, Vault, Consul), OS environment variables, Java system properties, command-line arguments, and test properties at the very top. Profiles are activated with spring.profiles.active (usually SPRING_PROFILES_ACTIVE), can be grouped (spring.profiles.group.prod: [postgres, otel]), and gate beans with @Profile.

# application.yml (always loaded)
server:
  port: 8080
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/shop
payment:
  base-url: https://sandbox.payments.example.com
---
# a second document in the same file, only for the "prod" profile
spring:
  config:
    activate:
      on-profile: prod
payment:
  base-url: https://api.payments.example.com
# Kubernetes: the environment for the prod deployment
env:
  - name: SPRING_PROFILES_ACTIVE
    value: prod
  - name: SPRING_DATASOURCE_URL                 # spring.datasource.url
    value: jdbc:postgresql://db.internal:5432/shop
  - name: SPRING_DATASOURCE_PASSWORD
    valueFrom: { secretKeyRef: { name: shop-db, key: password } }
  - name: PAYMENT_RETRY_MAXATTEMPTS             # payment.retry.max-attempts
    value: "5"
  • Profile sections: a --- line starts a new document; spring.config.activate.on-profile: prod makes it apply only with that profile. A separate application-prod.yml file works the same way.
  • Environment variable names: uppercase, dots become _, and dashes are removed: payment.retry.max-attempts is PAYMENT_RETRY_MAXATTEMPTS.
  • Secrets like the database password come only from the environment, never from a file in the repository.
  • Local development: keep developer defaults in application.yml (or a dev profile) so the app starts with no setup; production overrides only what differs.

Predict the output

application.yml sets server.port: 8080. application-prod.yml sets server.port: 8081. The app runs with SPRING_PROFILES_ACTIVE=prod and the environment variable SERVER_PORT=9090. Which port does it listen on?

Spot the bugProduction that thinks it's a laptop

Tap the line with the bug.

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/shop
---
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    url: jdbc:postgresql://db.internal:5432/shop
---
# deployment
env:
  - name: SPRING_PROFILES_ACTIVE
    value: production

Profiles for beans

@Profile creates a bean only when a profile is active, which is handy for fakes in local development:

@Configuration
class EmailConfig {
    @Bean @Profile("dev")
    fun consoleEmailSender(): EmailSender = ConsoleEmailSender()            // prints emails to the log

    @Bean @Profile("!dev")
    fun smtpEmailSender(props: MailProperties): EmailSender = SmtpEmailSender(props)
}
@Configuration
public class EmailConfig {
    @Bean @Profile("dev")
    EmailSender consoleEmailSender() { return new ConsoleEmailSender(); }   // prints emails to the log

    @Bean @Profile("!dev")
    EmailSender smtpEmailSender(MailProperties props) { return new SmtpEmailSender(props); }
}

Use it sparingly. Every @Profile is code that runs in one environment and not another, so production paths go untested on your machine. Prefer the same beans everywhere with different values.

Production puzzles

Predict the output

A team adds a setting payment.retry.max-attempts and sets it in Kubernetes as PAYMENT_RETRY_MAX_ATTEMPTS=5. The value in application.yml is 3. What does the app use?

Config as part of the release

  • Twelve-factor: one build artifact, config from the environment. Never build a different jar per environment; that’s how untested artifacts reach production.
  • Config changes are deploys: version them (Git, Helm values, GitOps), review them, and roll them out gradually like code; a bad timeout can take down a service as surely as a bug.
  • Mounted files: Kubernetes ConfigMaps and Secrets can be mounted as a directory and imported with spring.config.import: optional:configtree:/etc/config/; file names become property names.
  • Runtime changes: @RefreshScope (Spring Cloud) and config servers can change values without restarts, but every bean must handle a change mid-flight. For most settings, a rolling restart is simpler and safer; reserve live changes for feature flags, which belong in a dedicated flag service with targeting and audit.
  • Override order (low → high): application.yml in jar → profile files → external files → config imports → env vars → system properties → command line → test properties.
  • Env var names: uppercase, . → _, dashes removed (payment.retry.max-attempts → PAYMENT_RETRY_MAXATTEMPTS). Debug with /actuator/env.
  • Profiles via SPRING_PROFILES_ACTIVE, groups, @Profile (sparingly). A misspelled profile name fails silently.
  • One artifact, config from the environment; config changes are versioned, reviewed, rolled out. Feature flags in a flag service; @RefreshScope only when worth the complexity.

Check this part

  1. Which wins: a value in application-prod.yml or the same setting as an environment variable?

  2. What is the environment variable for spring.datasource.url?

  3. Why build one artifact for every environment?

Values come from many sources, and later ones win: application.yml, then profile files like application-prod.yml, then environment variables, then command-line arguments. Profiles group per-environment differences; environment variables carry deployment-specific values and secrets.

Part Not started

Secrets

A house key shouldn’t be taped to the front door, even if the door is in a private street. A password committed to Git is taped to the door forever: it stays in the history even after you delete the line, and every clone, fork and CI log has a copy.

Secrets (database passwords, API keys, signing keys) are injected into the app when it starts, by the platform, from a place built to store them.

Secrets come from a secret store (Kubernetes Secrets, ideally synced from Vault, AWS Secrets Manager or GCP Secret Manager via External Secrets or CSI), reach the app as env vars or mounted files, and are rotated without redeploying code. Prefer short-lived, identity-based credentials (workload identity, IAM database auth, Vault dynamic secrets) over long-lived static ones. Keep them out of logs, actuator endpoints, exception messages and heap dumps where possible, and scan repositories (gitleaks, GitHub secret scanning) so a leak is caught at push time.

# application.yml in the repository: only references
spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USER}
    password: ${DATABASE_PASSWORD}
payment:
  api-key: ${PAYMENT_API_KEY}
management:
  endpoint:
    env:
      show-values: never        # don't expose values through /actuator/env
  • ${DATABASE_PASSWORD} is a placeholder: Spring fills it from the environment at startup. With no default after a colon, a missing value fails startup instead of connecting with an empty password.
  • For local development, put values in a .env file or your IDE run configuration, and add .env to .gitignore.
  • If a secret is ever committed, rotate it (create a new one and revoke the old one). Rewriting Git history isn’t enough; assume it was copied.

Predict the output

A developer commits an API key, notices a minute later, and pushes a new commit that deletes the line. Is the key still exposed?

Keeping secrets out of everything else

  • Logs: never log configuration objects whole; records’ generated toString() prints every field, including an apiKey. Override toString() for properties classes that hold secrets, or wrap them in a type whose toString() is "***".
  • Actuator: since Boot 3, /actuator/env and /configprops hide values by default (show-values: never); keep it that way, and don’t expose actuator publicly.
  • Errors: connection-string exceptions can echo credentials; review what your error handler returns to clients.
  • Rotation without downtime: support two valid keys during rotation (old and new), or use credentials that refresh themselves (Vault leases, IAM tokens), so rotation never needs a coordinated deploy.
  • Secret store → env vars or mounted files at runtime; nothing in the repository, image or Helm values in plain text.
  • Prefer short-lived, identity-based credentials; design for rotation (two valid keys, refreshing leases).
  • Keep secrets out of toString() of properties records, logs, actuator, error responses; scan repositories.
  • A leaked secret is rotated, not just deleted from history.

Check this part

  1. You accidentally pushed a database password. What must you do?

  2. Why can a properties record holding an API key leak it?

Secrets never go into the repository, the image, or the logs. They're injected at runtime from a secret store, rotated without code changes, and kept out of actuator output and error messages.

Key takeaways

  • Group settings into validated @ConfigurationProperties classes, so bad values stop the app at startup.
  • Use types like Duration for timeouts; put defaults in the class.
  • Profiles and environment variables override application.yml; environment variables win over files.
  • Secrets come from the environment at runtime and are rotated if they leak.
  • Typed, immutable, validated properties; @Value defaults hide typos.
  • Know the override order and env var naming, and debug with /actuator/env.
  • One artifact everywhere; config versioned and rolled out like code; few profiles, little @Profile code.
  • Secrets from a store, short-lived where possible, never in logs, toString() or actuator output.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.