“How do you configure a Spring Boot application differently for dev, test and production?” Interviewers ask this to see whether you know profiles, and also whether you know their limits. A profile that switches on a fake payment gateway is convenient until someone deploys with the wrong SPRING_PROFILES_ACTIVE and production starts approving every payment. Good answers explain the mechanism, the precedence rules and how to keep profiles from becoming hidden branches of business logic.
Before you start
You should know how application.yml is loaded, what environment variables are, and how beans are defined with @Configuration and @Bean. It helps to have read typed configuration with @ConfigurationProperties. Examples use Spring Boot 3.x.
The short answer
A profile is a named switch. Activating one (with spring.profiles.active, SPRING_PROFILES_ACTIVE, or --spring.profiles.active) loads application-{profile}.yml on top of application.yml, and registers beans annotated @Profile("name"). Profiles should capture environment differences, such as URLs, pool sizes and log levels, while the code path stays the same. Secrets belong in environment variables or a secret store, not in profile files. Precedence puts environment variables and command-line arguments above any file, so the deployment platform has the final word.
How it works
At startup, Boot builds the Environment from ordered property sources. A simplified order from lowest to highest priority:
1. application.yml (packaged in the jar)
2. application-{profile}.yml (packaged, for each active profile)
3. application.yml outside the jar (./config or current directory)
4. application-{profile}.yml outside the jar
5. OS environment variables
6. Java system properties (-D)
7. Command-line arguments (--key=value)A later source overrides an earlier one for the same key. If several profiles are active, the last one listed wins for conflicting keys.
# application.yml
app:
payment:
base-url: http://localhost:9090
logging:
level:
root: info
---
# application-prod.yml
app:
payment:
base-url: https://payments.example.comBeans can also be profile specific:
@Configuration
class PaymentGatewayConfig {
@Bean
@Profile("local")
PaymentGateway fakeGateway() {
return new AlwaysApprovesGateway();
}
@Bean
@Profile("!local")
PaymentGateway realGateway(PaymentProperties props, RestClient.Builder builder) {
return new HttpPaymentGateway(builder.baseUrl(props.baseUrl().toString()).build());
}
}@Profile accepts expressions such as !local, prod & eu and dev | test.
Step-by-step walkthrough
Step 1: Put shared defaults in application.yml
The base file holds everything that is the same everywhere plus safe defaults. A developer cloning the repository should be able to run the app with no profile, or with one local profile.
Step 2: Keep profile files small
Each profile file should contain only differences. If application-prod.yml repeats 80 lines of the base file, nobody can tell what actually differs.
# application-prod.yml
spring:
datasource:
hikari:
maximum-pool-size: 30
logging:
level:
root: warnStep 3: Inject secrets from the environment
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}The platform (Kubernetes Secret, Vault, AWS Secrets Manager) provides the values. The jar contains no credentials, and the same artifact runs in every environment. Spring Boot can also import config trees with spring.config.import=configtree:/etc/secrets/, which suits mounted Kubernetes secrets.
Step 4: Group profiles instead of stacking flags
Profile groups give one name to a combination:
spring:
profiles:
group:
prod: "prod-db,prod-observability"
local: "local-db,fake-payments"Activating prod now activates the group members too. Note that spring.profiles.active and spring.profiles.group cannot be set from inside a profile-specific file; Boot rejects that to avoid circular activation.
Step 5: Fail when no profile is set in production
Make the dangerous default impossible. One option is to set spring.profiles.default=local only in a developer-run configuration, and to guard fake beans with a positive profile (@Profile("local")) rather than a negative one. Another is a startup check:
@Component
@Profile("fake-payments")
class FakePaymentsGuard implements ApplicationRunner {
private final Environment env;
FakePaymentsGuard(Environment env) { this.env = env; }
@Override
public void run(ApplicationArguments args) {
if (env.matchesProfiles("prod")) {
throw new IllegalStateException("fake-payments must never run with prod");
}
}
}Worked scenario
A team has application-dev.yml and application-prod.yml. The fake email sender is defined with @Profile("!prod"), meaning “anything that is not prod”. A new staging environment is created and someone sets SPRING_PROFILES_ACTIVE=staging. Staging was meant to send real emails to a test inbox, but with no prod profile active, the fake sender is registered. Integration testing of email flows in staging reports success while nothing is sent.
Worse, a second team copies the deployment template and forgets the variable entirely. With no active profile, Boot uses the default profile, and the negative condition again picks the fake.
The fix flips the logic so the real implementation is the default and the fake requires an explicit opt-in:
@Bean
@Profile("fake-email")
EmailSender fakeEmailSender() { return new LoggingEmailSender(); }
@Bean
@ConditionalOnMissingBean(EmailSender.class)
EmailSender smtpEmailSender(JavaMailSender mailSender) { return new SmtpEmailSender(mailSender); }Because @ConditionalOnMissingBean in user configuration depends on registration order, a clearer alternative is a single bean method that chooses based on a typed property such as app.email.mode=smtp|log, with smtp as the default and validation rejecting unknown values. Either way, forgetting configuration now means “real behaviour”, and the fake is visible in the profile list.
Common mistake
- Business logic per profile (
if (env.acceptsProfiles("eu"))in a service). Use configuration values or separate strategies selected by properties. - Negative profiles for fakes such as
@Profile("!prod"). Any new or missing profile activates the fake. - Credentials in
application-prod.ymlcommitted to Git. - Building a different artifact per environment with Maven profiles. The artifact you tested should be the one you ship.
- Assuming
spring.profiles.activein a file can’t be overridden. The environment variable or command line overrides it. - Activating
testprofile beans with@ActiveProfilesand forgetting they replace real beans that other tests expect.
Verify the behavior
Test each profile combination selects the intended beans:
class PaymentGatewayProfileTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(PaymentGatewayConfig.class, StubDependencies.class);
@Test
void localUsesFake() {
runner.withPropertyValues("spring.profiles.active=local")
.run(ctx -> assertThat(ctx).getBean(PaymentGateway.class).isInstanceOf(AlwaysApprovesGateway.class));
}
@Test
void noProfileUsesRealGateway() {
runner.run(ctx -> assertThat(ctx).getBean(PaymentGateway.class).isInstanceOf(HttpPaymentGateway.class));
}
}At runtime, the startup log line The following 1 profile is active: "prod" and the /actuator/env endpoint (secured) show which profiles and property sources are in effect.
Follow-up questions
What happens if no profile is active? The default profile is active, so application-default.yml and @Profile("default") beans apply.
How do you activate a profile in a test? @ActiveProfiles("test") on the test class.
What is spring.config.activate.on-profile? In a multi-document YAML file, it marks a document as applying only when that profile is active, replacing the old spring.profiles key in documents.
Can a profile be used to select a whole configuration class? Yes, put @Profile on the @Configuration class; all its beans follow.
Interview exercise
Your service needs a local setup with an in-memory message broker, a staging setup with a real broker and verbose logging, and a production setup with a real broker, strict timeouts and no debug endpoints. Describe the profiles, files and safety measures.
Answer and reasoning
Use application.yml for real-broker defaults, strict timeouts and minimal Actuator exposure, so the safe configuration is the default. Add application-local.yml plus a @Profile("local") configuration that defines the embedded broker, opted into explicitly. Add application-staging.yml that only raises log levels. Production needs no profile file at all, or a small one for pool sizes. Inject broker credentials and URLs through environment variables in staging and production. Add a test asserting the no-profile context uses the real broker client, and a startup guard rejecting local together with any production marker. The reasoning: defaults should be the production-safe behaviour, profiles should add differences rather than remove protections, and the same jar runs everywhere.