Spring Boot Profiles: dev, test, prod Configuration

1. Overview

Your application typically runs in multiple environments. Your machine uses H2 database. The test server uses a separate PostgreSQL. Production uses a real cluster with real SMTP. If you manually edit the configuration file before every deploy, sooner or later you’ll accidentally push dev configuration to prod.

Spring Boot solves this with profiles. After reading this article, you’ll know how to split configuration files by profile, enable/disable beans using @Profile, activate profiles in various ways, write tests with @ActiveProfiles, and avoid common mistakes.

2. What is a Profile?

A profile is a named label, like dev, test, or prod. Spring uses this label to decide two things:

  • Which configuration is loaded: application-dev.yml is only read when the dev profile is active.
  • Which beans are created: a bean with @Profile("prod") is only registered when the prod profile is active.

If no profile is explicitly activated, Spring uses the default profile named default. Beans without @Profile are always created, regardless of which profile is active.

You only need the basic web dependency for the examples in this article:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. Configuration Files by Profile

3.1. Splitting into Multiple Files

The naming convention is application-{profile}.yml (or .properties). The application.yml file contains common configuration and is always loaded:

# application.yml
spring:
  application:
    name: shop-service
  profiles:
    default: dev

app:
  mail:
    from: no-reply@shop.vn
    host: localhost

Profile-specific files only contain the differences:

# application-dev.yml
server:
  port: 8080
logging:
  level:
    root: DEBUG
# application-test.yml
server:
  port: 0
app:
  mail:
    host: fake-smtp
# application-prod.yml
server:
  port: 80
logging:
  level:
    root: WARN
app:
  mail:
    host: ${MAIL_HOST}

Explanation:

  • spring.profiles.default: dev means when you don’t activate any profile, Spring uses dev instead of default. Your dev machine runs immediately without needing parameters.
  • When the prod profile is active, Spring loads application.yml first, then application-prod.yml. Values in the profile-specific file override the common values. So app.mail.host becomes the value of the MAIL_HOST environment variable, while app.mail.from remains no-reply@shop.vn.
  • server.port: 0 in test lets Spring choose a random port, avoiding conflicts when running tests in parallel.

3.2. Merging into a Single YAML File

If you want fewer files, you can use YAML multi-document syntax, separated by ---:

# application.yml
app:
  mail:
    host: localhost
---
spring:
  config:
    activate:
      on-profile: prod
app:
  mail:
    host: smtp.shop.vn

The second document only applies when prod is active. This is concise when each profile has few differences. For longer configurations, separate files are still more readable and easier to review.

Note: In .properties files, use #--- to separate documents instead of ---.

4. Activating Profiles

There are many ways to do this. The order of precedence follows Spring Boot’s general rules: command-line arguments > environment variables > configuration files.

Command-line argument:

java -jar shop-service.jar --spring.profiles.active=prod

JVM system property:

java -Dspring.profiles.active=prod -jar shop-service.jar

Environment variable, the most common approach on Docker and Kubernetes:

export SPRING_PROFILES_ACTIVE=prod
java -jar shop-service.jar

Using the Maven plugin:

./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

Programmatically, useful when you always want to add a certain profile:

@SpringBootApplication
public class ShopApplication {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(ShopApplication.class);
        app.setAdditionalProfiles("metrics");
        app.run(args);
    }
}

setAdditionalProfiles adds a profile to the list, it doesn’t replace profiles activated from the outside. Running with --spring.profiles.active=prod means both prod and metrics will be active.

You can activate multiple profiles at once using a comma: --spring.profiles.active=prod,eu. If two files define the same key, the later profile wins. In this example, application-eu.yml overrides application-prod.yml.

When the application starts, the log tells you which profile is active:

INFO  c.e.shop.ShopApplication : The following 1 profile is active: "prod"

If no profile is activated and spring.profiles.default is not set, you’ll see:

INFO  c.e.shop.ShopApplication : No active profile set, falling back to 1 default profile: "default"

Always glance at this log line after deploying. It’s the quickest way to catch a server running the wrong profile.

5. Enabling/Disabling Beans with @Profile

Different configuration is only half the battle. Sometimes you need a completely different implementation. For example: dev just prints emails to console, test stores them in memory for verification, prod sends them via SMTP.

Define a common interface:

public interface NotificationService {
    void send(String to, String message);
}

Implementation for dev:

@Service
@Profile("dev")
public class ConsoleNotificationService implements NotificationService {

    @Override
    public void send(String to, String message) {
        System.out.println("[DEV] Sending to " + to + ": " + message);
    }
}

Implementation for test, storing messages in memory for assertion:

@Service
@Profile("test")
public class InMemoryNotificationService implements NotificationService {

    private final List<String> sentMessages = new CopyOnWriteArrayList<>();

    @Override
    public void send(String to, String message) {
        sentMessages.add(to + "|" + message);
    }

    public List<String> getSentMessages() {
        return List.copyOf(sentMessages);
    }
}

Implementation for prod:

@Service
@Profile("prod")
public class SmtpNotificationService implements NotificationService {

    private final String host;

    public SmtpNotificationService(@Value("${app.mail.host}") String host) {
        this.host = host;
    }

    @Override
    public void send(String to, String message) {
        // In real code, use JavaMailSender; here we're just demonstrating
        System.out.println("[SMTP " + host + "] Sending to " + to);
    }
}

The class that uses it doesn’t need to know which environment it’s in:

@Service
public class OrderService {

    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void placeOrder(String customerEmail, long orderId) {
        notificationService.send(customerEmail, "Order #" + orderId + " has been created");
    }
}

At any given time, only one of the three NotificationService beans is created. Spring injects the correct one into OrderService. Running with the dev profile and calling placeOrder("an@shop.vn", 42), you get:

[DEV] Sending to an@shop.vn: Order #42 has been created

5.1. Profile Expressions

@Profile supports logical expressions:

@Configuration
public class SecurityConfig {

    @Bean
    @Profile("!prod")
    public CommandLineRunner seedDemoData() {
        return args -> System.out.println("Loading demo data...");
    }

    @Bean
    @Profile("dev | test")
    public CommandLineRunner relaxedCors() {
        return args -> System.out.println("CORS open to all origins");
    }
}
  • !prod: create the bean in every profile except prod.
  • dev | test: create when dev or test is active. There’s also & for “and”, for example prod & eu.

@Profile can be placed on @Component/@Configuration classes or on individual @Bean methods. Placing it on @Configuration applies the condition to all beans inside.

5.2. Checking the Profile in Code

Sometimes you need to branch in your logic. Use Environment:

@Component
public class StartupBanner implements CommandLineRunner {

    private final Environment environment;

    public StartupBanner(Environment environment) {
        this.environment = environment;
    }

    @Override
    public void run(String... args) {
        if (environment.acceptsProfiles(Profiles.of("prod"))) {
            System.out.println("Running PRODUCTION");
        }
    }
}

Use this approach sparingly. If your code is full of if (prod) checks, you should be creating separate beans with @Profile instead.

6. Profile Groups

As your system grows, you might want to split configuration further, like proddb for database and prodmq for the message broker. Profile groups let you activate a cluster of profiles with one name:

# application.yml
spring:
  profiles:
    group:
      prod: proddb, prodmq
      dev: devdb

Now running --spring.profiles.active=prod activates prod, proddb, and prodmq. Spring loads application-prod.yml, application-proddb.yml, and application-prodmq.yml in order. Operators only need to remember one name.

7. Writing Tests with @ActiveProfiles

In tests, you select a profile using @ActiveProfiles. The test below verifies two things: the correct bean is injected, and configuration from application-test.yml takes effect.

@SpringBootTest
@ActiveProfiles("test")
class OrderServiceProfileTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private NotificationService notificationService;

    @Value("${app.mail.host}")
    private String mailHost;

    @Test
    void shouldUseInMemoryNotificationInTestProfile() {
        assertThat(notificationService).isInstanceOf(InMemoryNotificationService.class);
    }

    @Test
    void shouldRecordNotificationWhenOrderPlaced() {
        orderService.placeOrder("binh@shop.vn", 7);

        var inMemory = (InMemoryNotificationService) notificationService;
        assertThat(inMemory.getSentMessages())
                .contains("binh@shop.vn|Order #7 has been created");
    }

    @Test
    void shouldOverrideMailHostFromTestProfile() {
        assertThat(mailHost).isEqualTo("fake-smtp");
    }
}

Explanation:

  • @ActiveProfiles("test") activates the test profile for this class’s context, regardless of what spring.profiles.default is.
  • The first test confirms that Spring chose InMemoryNotificationService instead of sending real emails.
  • The second test uses the in-memory bean to verify the message content without needing mocks.
  • The third test proves that app.mail.host from application-test.yml has overridden the localhost value in application.yml.

Note: Each different combination of profiles creates a separate Spring context. If each test class uses a different set of profiles, the context cache can’t be reused and your test suite runs significantly slower. Keep the number of profile combinations in your tests to a minimum.

8. Common Pitfalls

Setting spring.profiles.active in a profile-specific file. Starting with Spring Boot 2.4, declaring spring.profiles.active or spring.profiles.include in application-dev.yml (or in a document with on-profile) will prevent your application from starting:

InvalidConfigDataPropertyException: Property 'spring.profiles.active' imported from
location 'class path resource [application-dev.yml]' is invalid in a profile specific resource

Use profile groups (section 6) instead.

Hard-coding spring.profiles.active=dev in application.yml. This file is packaged inside the jar. If someone forgets to pass the environment variable on the server, prod will run with dev configuration. Use spring.profiles.default for your local environment, and always activate the profile explicitly on the server.

Storing secrets in application-prod.yml. Database passwords and API keys should not go in Git. Use placeholders like ${DB_PASSWORD} and provide the value via environment variables or a secrets manager.

No bean matches the profile. If you run with the staging profile but only have implementations for dev, test, prod, the application will report an error:

Parameter 0 of constructor in OrderService required a bean of type
'NotificationService' that could not be found.

Consider adding a fallback bean with @Profile("!prod") or add an implementation for the new profile.

Abusing profiles for feature flags. Profiles are good for describing environments. If you want to enable/disable a feature independently of the environment, @ConditionalOnProperty is often clearer:

@Bean
@ConditionalOnProperty(name = "app.feature.loyalty-points", havingValue = "true")
public LoyaltyService loyaltyService() {
    return new LoyaltyService();
}

When you end up with profiles like prod-with-loyalty or dev-no-cache, that’s a sign you should switch to properties instead.

9. Conclusion

Profiles let you separate configuration and behavior by environment without changing code. You’ve seen how to use application-{profile}.yml to override configuration, @Profile to select implementations, profile groups to bundle multiple profiles, and @ActiveProfiles for testing.

A few principles to remember: put common configuration in application.yml, only write the differences in profile-specific files. Activate profiles on the server via environment variables or command-line arguments. Don’t put secrets in Git. And always check the log line The following profiles are active after each deployment.

spring-bootprofileconfigurationtesting