At some point every Spring Boot team hits the same wall: config that works perfectly in dev silently does the wrong thing in production. The database URL is hardcoded in application.properties, secrets are committed to git, and nobody can figure out why the staging environment is pointing at the production database. This guide is the fix.
Spring Boot’s configuration system is genuinely good once you understand the rules. Profiles, externalized config, and the override hierarchy give you all the tools you need to run the same artifact cleanly across environments. Most teams use 20% of it and wonder why they keep having config incidents.
application.yml vs application.properties
Both formats work. The difference is structural: YAML handles nesting and lists naturally, .properties is flat key-value pairs. For non-trivial configs, YAML wins on readability.
application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=app
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
application.yml equivalent:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: app
password: secret
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
YAML’s nested structure also makes it easier to spot when you’ve got a deep key path wrong — misaligned indentation is a compile-time error rather than a silent misconfiguration.
One gotcha: YAML does not allow tabs. If you paste content from an editor that uses tabs for indentation, Spring will fail to parse the file with a confusing error. Stick to spaces.
Pick one format and stay consistent. Mixing both in the same project works but invites confusion. Most teams standardize on .yml.
Profiles: The Right Way to Handle Environments
A profile is a named set of configuration that gets activated for a specific context — dev, staging, prod, test. When a profile is active, its config overlays the base application.yml.
The naming convention is application-{profile}.yml:
src/main/resources/
├── application.yml # base config, shared across all environments
├── application-dev.yml # overrides for local development
├── application-staging.yml # staging overrides
└── application-prod.yml # production overrides
Base application.yml — things that don’t change between environments:
spring:
application:
name: order-service
jpa:
open-in-view: false
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,metrics
application-dev.yml — developer ergonomics:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/orderdb_dev
username: dev
password: dev
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
logging:
level:
com.example: DEBUG
application-prod.yml — production settings:
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
logging:
level:
root: WARN
com.example: INFO
Notice the ${DB_URL} syntax in the prod profile. More on that shortly.
Activating Profiles
There are several ways to activate a profile. They’re listed here in order of how often you’ll actually use them.
Environment variable (most common in containers):
SPRING_PROFILES_ACTIVE=prod java -jar app.jar
JVM system property:
java -Dspring.profiles.active=prod -jar app.jar
In application.yml (useful for setting a default):
spring:
profiles:
default: dev
Programmatically (useful in tests):
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceIntegrationTest {
// runs with application-test.yml active
}
Multiple profiles at once:
SPRING_PROFILES_ACTIVE=prod,metrics java -jar app.jar
Multiple profiles let you compose config: have a prod profile for production settings and a metrics profile for detailed observability when you’re debugging a production issue, then activate both.
@Profile on Beans
Beyond YAML files, @Profile controls which beans get created at all. This is useful for environment-specific infrastructure wiring — in-memory vs real queues, stub vs real external services.
@Configuration
public class MessagingConfig {
@Bean
@Profile("!prod")
public MessageQueue inMemoryQueue() {
return new InMemoryMessageQueue();
}
@Bean
@Profile("prod")
public MessageQueue sqsQueue(AmazonSQS sqs) {
return new SqsMessageQueue(sqs);
}
}
The !prod syntax means “any profile except prod”. This pattern lets you run integration tests with in-memory fakes without needing AWS credentials.
You can also use profile expressions with Spring 5.1+:
@Bean
@Profile("prod | staging")
public FeatureFlags productionFeatureFlags() {
return new FeatureFlags(false); // stricter in prod/staging
}
Don’t over-use @Profile on beans. If you find yourself writing @Profile on dozens of beans, you’re probably missing a config property that should drive the decision instead. Use @Profile for infrastructure wiring, use config properties for behavior switches.
Reading Config Values in Code
Two approaches: @Value for simple cases, @ConfigurationProperties for anything beyond a few values.
@Value — fine for one or two properties:
@Service
public class PaymentService {
@Value("${payment.api.url}")
private String apiUrl;
@Value("${payment.api.timeout:30}") // 30s default if not set
private int timeoutSeconds;
}
@ConfigurationProperties — the right choice for grouped config:
@ConfigurationProperties(prefix = "payment.api")
@Component
public class PaymentApiProperties {
private String url;
private int timeout = 30;
private int maxRetries = 3;
private RetryConfig retry = new RetryConfig();
// getters and setters (or use Lombok @Data)
public static class RetryConfig {
private long backoffMs = 500;
private double multiplier = 2.0;
// getters, setters
}
}
payment:
api:
url: https://payments.example.com/v2
timeout: 45
max-retries: 5
retry:
backoff-ms: 1000
multiplier: 1.5
@ConfigurationProperties gives you type safety, IDE autocompletion (with the annotation processor), and clean validation:
@ConfigurationProperties(prefix = "payment.api")
@Validated
@Component
public class PaymentApiProperties {
@NotBlank
private String url;
@Min(1) @Max(120)
private int timeout = 30;
}
The validation fires at startup. Better to fail fast with a clear error than to discover at runtime that the URL is empty.
To enable autocompletion in your IDE, add this to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
Configuration Precedence: The Override Order
Spring Boot evaluates configuration sources in a defined order. Higher numbers win:
- Default properties (
SpringApplication.setDefaultProperties) @PropertySourceannotations on@Configurationclasses- Config files (
application.yml,application-{profile}.yml) - Random value properties (
random.*) - OS environment variables
- JVM system properties (
-Dflags) - JNDI
- Servlet parameters
- Command-line arguments (
--spring.datasource.url=...)
The practical consequence: environment variables beat config files, and command-line arguments beat everything. This is the 12-factor pattern — your artifact stays immutable, and runtime config comes from the environment.
In a container environment, that means:
application.ymlhas defaults and non-sensitive configapplication-prod.ymlhas production-specific non-sensitive config- Environment variables or secrets manager provides credentials at runtime
Command-line override for quick debugging:
java -jar app.jar --spring.jpa.show-sql=true --logging.level.com.example=TRACE
This is useful for temporarily enabling debug logging in production without redeploying. It doesn’t persist between restarts.
Externalized Config and Secrets Management
Never put secrets in application.yml. Not even in application-prod.yml. Not in git at all.
The pattern is simple: reference environment variables in your YAML files, inject the actual values at deploy time.
# application-prod.yml
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
security:
oauth2:
client:
registration:
github:
client-id: ${OAUTH_CLIENT_ID}
client-secret: ${OAUTH_CLIENT_SECRET}
You can provide defaults:
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/mydb}
This lets developers run without setting DB_URL locally (picks up the default) while requiring it explicitly in production (no default → startup fails if not set).
Kubernetes secrets example:
# kubernetes deployment
env:
- name: DB_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
For more sophisticated secrets management, Spring Boot integrates with HashiCorp Vault via spring-cloud-vault and AWS Secrets Manager via the AWS parameter store integration. These pull secrets at startup rather than requiring them as environment variables.
What belongs in which file:
| Config type | Where it goes |
|---|---|
| Non-sensitive defaults | application.yml |
| Non-sensitive env-specific values | application-{profile}.yml |
| Secrets, credentials, API keys | Environment variables / secrets manager |
| Feature flags | application.yml (with env-var overrides) |
Profile-Specific Properties for Tests
Test configuration often needs its own profile to avoid hitting real databases or external services. Spring Boot’s test slice annotations work well with profile activation.
# application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
database-platform: org.hibernate.dialect.H2Dialect
@SpringBootTest
@ActiveProfiles("test")
class OrderRepositoryTest {
@Autowired
private OrderRepository repository;
@Test
void shouldPersistOrder() {
// uses H2 in-memory database, not your real PostgreSQL
}
}
If you’re using Testcontainers for integration tests, you can skip the application-test.yml approach entirely and configure the datasource programmatically with @DynamicPropertySource. See the Spring Boot Testcontainers guide for that pattern.
Spring Cloud Config for Centralized Configuration
When you have multiple services that need shared configuration — or you need to change config without rebuilding artifacts — environment variables per-service become unwieldy. That’s where Spring Cloud Config comes in.
Spring Cloud Config provides a central config server backed by a git repository. Services fetch their config at startup from the server rather than from local files. The benefits:
- Config changes without redeployment (with config refresh)
- Audit trail via git history
- Encrypted secrets in the config repository
- Environment-specific config managed centrally
The tradeoff is operational complexity: now you have a config server to run, secure, and keep available. For small services or simple deployments, environment variables and profile-specific files are usually sufficient. For larger microservice deployments, the centralized approach pays for itself quickly.
The overview of Spring Cloud covers the broader ecosystem including Config in context.
Common Mistakes
Using spring.jpa.hibernate.ddl-auto=create-drop in production. This drops your schema on shutdown. It’s happened. Use validate in production.
Setting spring.profiles.active in application.yml. This gets ignored because the file is loaded before the profile is resolved. Set it via environment variable or JVM argument instead.
Committing application-prod.yml with real secrets. Even if you rotate them later, they’re in git history. Use environment variable references.
Relying on property name case sensitivity. Spring Boot normalizes property names — spring.datasource.url, SPRING_DATASOURCE_URL, and spring.datasource.URL all bind to the same property. This relaxed binding is intentional but can be confusing when debugging which source won.
Not validating config at startup. Using @Validated on @ConfigurationProperties classes catches missing or invalid config immediately rather than at the first request that needs it.
Putting It Together
A production-ready configuration layout looks like this:
src/main/resources/
├── application.yml # shared defaults, structural config
├── application-dev.yml # local dev overrides (commit this)
└── application-prod.yml # prod structure, env-var references only (commit this)
application-prod.yml in source control is fine as long as it contains no actual secrets — only ${ENV_VAR} references. The secrets themselves live in your secrets manager or are injected by your deployment platform.
Activate the right profile with SPRING_PROFILES_ACTIVE=prod in your container environment. Let the precedence hierarchy do the rest: base config from files, secrets from environment variables, emergency overrides from command-line arguments.
That’s a configuration setup you can reason about, audit, and rotate credentials in without touching source code.