- Understand how Spring profiles enable single-codebase, multi-environment deployment
- Configure profile-specific properties files and Java configuration classes
- Apply profiles to manage database connections across development, testing, and production environments
- Use profile activation mechanisms through command-line arguments, environment variables, and IDE configurations
- Implement conditional bean registration based on active profiles
Your application needs three different database configurations. Development uses H2 because schema changes happen constantly and you want instant startup. Testing uses PostgreSQL in Docker because your CI pipeline needs realistic SQL behavior. Production uses MySQL with connection pooling and strict schema validation because data loss isn't an option. Writing three separate applications would be insane. Environment checks scattered throughout your code would be worse. Spring profiles solve this cleanly: one codebase, multiple runtime configurations, zero compromises.
Profiles are labels. You activate a profile, and Spring loads only the configuration marked with that label. The mechanism is simple but powerful—you can vary both property-driven settings like database URLs and behavioral changes like which beans exist in your application context. This split between data configuration and structural configuration gives you surprising flexibility without complexity.
A profile activates configuration. Spring checks which profile is active at startup, then loads matching property files and registers matching beans. Everything else gets ignored. Your application.properties file might define baseline settings shared across all environments. Your application-dev.properties overrides specific values for development. Your application-prod.properties does the same for production. Spring merges these files at runtime based on which profile you've activated.
The same principle applies to Java configuration classes. Mark a class with @Profile("dev") and Spring only registers
it when the dev profile is active. Mark another class with @Profile("prod") and you get different beans in production.
Your business logic stays profile-agnostic. It asks Spring for a DataSource and gets one—the correct one for whatever
environment it's running in.
This matters because configuration bugs cause production incidents. Accidentally connecting to the test database from
production? Profile-based configuration prevents that. Enabling SQL logging in production and overwhelming your log
aggregation system? Can't happen if your production profile explicitly disables it. Running schema auto-generation
against production data? Not when your prod profile uses ddl-auto=validate. Profiles enforce environment boundaries at
the configuration level.
Spring accepts profile activation through multiple channels. Command-line arguments work everywhere:
java -jar food-api.jar --spring.profiles.active=devEnvironment variables suit containerized deployments:
export SPRING_PROFILES_ACTIVE=test
java -jar food-api.jarMaven integrates for local development:
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=prodYour IDE's run configuration can set profiles through VM arguments or program arguments. IntelliJ, Eclipse, VS Code—they all support this. During development you'll probably use IDE configurations because they persist across restarts. In CI/CD pipelines you'll use environment variables. In production containers you might use environment variables or command-line arguments depending on your orchestration platform.
Priority matters when multiple mechanisms set profiles simultaneously. Command-line arguments override environment
variables. Environment variables override application.properties. This layering lets you define sensible defaults and
override them where needed. Your production Kubernetes deployment might set SPRING_PROFILES_ACTIVE=prod cluster-wide,
then specific pods override it for canary deployments or A/B testing.
Spring's property file naming convention follows application-{profile}.properties. Create these files
in src/main/resources and Spring handles the rest. Start with a base file that defines settings common across all
environments:
application.properties:
spring.application.name=food-api
server.servlet.context-path=/api
logging.level.org.springframework.web=INFOThese values rarely change between environments. The application name stays constant. The context path is usually static. Default logging levels provide reasonable baselines. Profile-specific files inherit these settings and override selectively.
Development needs rapid iteration and debugging visibility:
application-dev.properties:
server.port=9092
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.hibernate.ddl-auto=create-dropPort 9092 avoids conflicts with other local services. H2 runs entirely in-memory—no installation, no external processes,
instant startup. The H2 console at /h2-console lets you inspect tables and run ad-hoc queries during debugging. SQL
logging shows you exactly what Hibernate generates. Schema recreation on every startup means you're always working with
a clean slate.
Testing needs realistic database behavior:
application-test.properties:
server.port=8081
spring.datasource.url=jdbc:postgresql://localhost:5433/fooddb_test
spring.datasource.username=test_user
spring.datasource.password=test_pass
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=truePostgreSQL catches dialect-specific SQL issues that H2 misses. Running in Docker provides isolation—each test run gets a
fresh database without affecting development environments. The create-drop setting still applies because integration
tests need repeatable state, but now you're testing against a production-class database engine.
Production demands different trade-offs:
application-prod.properties:
server.port=8080
spring.datasource.url=jdbc:mysql://localhost:3306/fooddb?useSSL=true&serverTimezone=UTC
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000Schema validation instead of generation prevents accidental data modifications. SQL logging stays off to avoid log
volume. Database credentials come from environment variables—${DB_USERNAME} and ${DB_PASSWORD} resolve at startup.
If these variables are missing, Spring fails fast during initialization rather than starting with invalid configuration.
HikariCP connection pooling reuses database connections across requests. Maximum pool size controls concurrent database load. Set it too high and you overwhelm the database. Too low and requests queue waiting for available connections. These values need tuning based on your application's traffic patterns and database capacity. The timeout values prevent connection leaks and ensure connections get recycled before they go stale.
Property files handle data—URLs, credentials, feature flags. Configuration classes handle structure—which beans exist,
how they're wired, what initialization logic runs. Combine @Configuration and @Profile to create
environment-specific component registration:
package blog.academy.javapro;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import javax.annotation.PostConstruct;
@Profile("dev")
@Configuration
public class DevConfig {
@PostConstruct
public void logStartup() {
System.out.println("Development profile active - H2 console available");
}
}Spring scans for @Configuration classes at startup. When it finds DevConfig, it checks the @Profile annotation
against active profiles. Match? The class gets registered in the application context. No match? Spring skips it
entirely. The @PostConstruct method runs after dependency injection completes but before the application accepts
requests—perfect for logging which profile loaded.
Create parallel configurations for other environments:
package blog.academy.javapro;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import javax.annotation.PostConstruct;
@Profile("test")
@Configuration
public class TestConfig {
@PostConstruct
public void logStartup() {
System.out.println("Test profile active - PostgreSQL backend");
}
}package blog.academy.javapro;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import javax.annotation.PostConstruct;
@Profile("prod")
@Configuration
public class ProdConfig {
@PostConstruct
public void logStartup() {
System.out.println("Production profile active - MySQL with connection pooling");
}
}Now your console output immediately confirms which profile is running. This seems trivial until you're debugging a production incident at 2 AM and need to verify your container picked up the right environment variables.
Configuration classes enable conditional bean registration. Register different implementations of the same interface based on profile:
package blog.academy.javapro;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Profile("dev")
@Configuration
public class DevConfig {
@Bean
public EmailService emailService() {
return new MockEmailService(); // Logs emails instead of sending
}
@Bean
public DataSeeder dataSeeder() {
return new DataSeeder(); // Populates H2 with test data
}
}package blog.academy.javapro;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Profile("prod")
@Configuration
public class ProdConfig {
@Bean
public EmailService emailService() {
return new SmtpEmailService(); // Actually sends emails
}
}Both configurations define an EmailService bean, but only one exists at runtime. Your business logic
injects EmailService through Spring's dependency injection and gets the correct implementation for the current
environment. During development, emails get logged. In production, they actually send. The business logic never checks
profiles or environment variables—it just uses EmailService.
The dev profile includes a DataSeeder bean that production doesn't have. This bean might implement CommandLineRunner
to populate your H2 database with test records at startup, making development easier. Production never sees this bean
because ProdConfig doesn't define it.
You can inject property values into configuration classes:
package blog.academy.javapro;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import javax.annotation.PostConstruct;
@Profile("dev")
@Configuration
public class DevConfig {
@Value("${server.port}")
private int serverPort;
@Value("${spring.h2.console.enabled}")
private boolean h2ConsoleEnabled;
@PostConstruct
public void logStartup() {
System.out.println("Development environment running on port " + serverPort);
if (h2ConsoleEnabled) {
System.out.println("H2 Console: http://localhost:" + serverPort + "/h2-console");
}
}
}The @Value annotation pulls properties from your application-dev.properties file. Now your startup logs include the
exact URL for accessing the H2 console—small convenience, but it eliminates the friction of remembering port numbers
when you're switching between multiple projects.
Database management proves whether profile-based configuration actually works. Three different database engines, three different operational models, one application codebase. Development uses H2's in-memory database. No installation required. Schema auto-generation. Web-based console for SQL queries. When you stop the application, the data disappears. Perfect for rapid iteration where you're constantly modifying entity classes and don't want migration scripts.
Access the H2 console at http://localhost:9092/h2-console when running the dev profile. Use JDBC
URL jdbc:h2:mem:testdb with no username or password. You can inspect generated schemas, verify foreign key
relationships, and run test queries without leaving your browser.
Testing needs more realism. H2's SQL dialect differs from production databases in subtle ways. PostgreSQL in Docker
provides a production-class database engine without installation overhead. Create docker-compose-test.yml:
version: '3.8'
services:
postgres-test:
image: postgres:15-alpine
container_name: foodapi-test-db
environment:
POSTGRES_DB: fooddb_test
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
ports:
- "5433:5432"
volumes:
- postgres-test-data:/var/lib/postgresql/data
tmpfs:
- /var/run/postgresql
volumes:
postgres-test-data:Start it with docker-compose -f docker-compose-test.yml up -d. The test profile's properties automatically connect to
this instance. Your integration tests run against PostgreSQL, catching SQL dialect issues before production deployment.
The volume provides persistence across container restarts, but ddl-auto=create-drop still gives each test run a clean
slate.
Production uses MySQL with operational safeguards. Create docker-compose-prod.yml:
version: '3.8'
services:
mysql-prod:
image: mysql:8.0
container_name: foodapi-prod-db
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: fooddb
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "3306:3306"
volumes:
- mysql-prod-data:/var/lib/mysql
command: --default-authentication-plugin=mysql_native_password
restart: unless-stopped
volumes:
mysql-prod-data:The production profile connects to this instance using credentials from environment variables. Schema validation ensures your entity mappings match the actual database structure without making modifications. Versioned migration tools like Flyway or Liquibase handle schema changes. HikariCP connection pooling optimizes database connection usage for production traffic patterns.
Run each profile to see the configuration in action:
# Development
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=dev
# Testing (start PostgreSQL first)
docker-compose -f docker-compose-test.yml up -d
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=test
# Production (start MySQL first)
docker-compose -f docker-compose-prod.yml up -d
export DB_USERNAME=food_user
export DB_PASSWORD=secure_password
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=prodSame JAR file. Different profiles. Different databases. Different operational characteristics. No code changes.
Spring supports multiple active profiles simultaneously. Activate them with comma-separated values:
java -jar food-api.jar --spring.profiles.active=dev,debug,metricsSpring loads all three profiles. Their property files merge with later profiles overriding earlier ones when conflicts exist. Configuration classes from all active profiles register their beans. This lets you compose orthogonal concerns—one profile for environment (dev/test/prod), another for infrastructure (docker/kubernetes), another for features (monitoring/debugging).
Profile groups simplify complex scenarios. Define them in application.properties:
spring.profiles.group.local=dev,debug,h2console
spring.profiles.group.ci=test,docker,postgresql
spring.profiles.group.production=prod,monitoring,metricsActivate a group:
java -jar food-api.jar --spring.profiles.active=localThis automatically activates dev, debug, and h2console profiles together. You don't need to remember which profiles belong together—the group encapsulates that knowledge.
Profile negation handles "everything except production" scenarios:
package blog.academy.javapro;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Profile("!prod")
@Configuration
public class DevelopmentToolsConfig {
@Bean
public DevToolsEndpoint devTools() {
return new DevToolsEndpoint(); // Only exists outside production
}
}This configuration loads in dev and test environments but not in production. Useful for debugging endpoints, mock services, or database seeding logic that should never touch production data.
Conditional expressions provide fine-grained control:
@Profile("dev & debug") // Both must be active
@Profile("test | staging") // Either can be active
@Profile("!prod & monitoring") // Not prod, but monitoring is activeThese expressions combine profiles with boolean logic. You can model complex environment requirements without creating explosion of single-purpose profiles.
Profile-based configuration eliminates environment drift. All environments use the same property files from source
control. You can't have development running different SQL logging settings from what's documented because the settings
are in version control. Production configuration drift becomes impossible when production reads
from application-prod.properties in your repository.
Debugging production issues becomes tractable. Reproduce production problems locally by activating the prod profile against a production database snapshot. The application behaves identically because it's using the same configuration. No more "works on my machine" mysteries where development and production differ in subtle ways.
Testing gains confidence. Integration tests run against the same database engine as production. You catch SQL dialect differences, transaction isolation issues, and connection pool problems before deployment. The test profile matches production's operational model while maintaining test-friendly features like schema recreation.
Deployment simplifies. Build once, deploy everywhere. The same JAR runs in every environment. No environment-specific compilation. No conditional logic checking system properties. No separate codebases. Just activate the appropriate profile and the application configures itself correctly.
This pattern scales from solo projects to enterprise platforms. Add profiles for staging environments that mirror production. Create performance-testing profiles with different JVM settings. Define disaster-recovery profiles that connect to backup systems. The architecture stays consistent—properties for data, configuration classes for structure, profiles for activation.
Spring profiles transform configuration from a deployment liability into a deployment asset. The same compiled artifact adapts to development, testing, staging, and production without modification. Profile-specific property files override baseline settings. Profile-specific configuration classes register environment-appropriate beans. Your application code stays profile-agnostic, asking Spring for components and trusting that the right ones exist for the current environment.
Property file layering provides the foundation. Spring loads application.properties for common settings, then overlays
profile-specific files. Development gets H2, SQL logging, and the web console. Testing gets PostgreSQL with schema
recreation. Production gets MySQL with connection pooling and schema validation. Each environment receives exactly what
it needs without affecting the others.
Configuration classes enable structural variation beyond property values. Register mock services in development, real
implementations in production. Include database seeders for testing, exclude them from production. Inject property
values into initialization logic to provide environment-specific startup information. The @Profile annotation ensures
configuration classes only load when appropriate.
Database management demonstrates the pattern's effectiveness. Three database engines with different operational models, managed through the same codebase. No environment checks. No conditional compilation. Just profile activation. Development iterates rapidly with in-memory databases. Testing catches dialect issues against PostgreSQL. Production runs MySQL with operational safeguards. The application code never knows or cares which database it's using.
Advanced patterns extend this flexibility. Multiple active profiles compose orthogonal concerns. Profile groups simplify complex combinations. Profile negation handles "everything except production" scenarios. Boolean expressions enable fine-grained conditional logic. These mechanisms scale from simple three-environment deployments to sophisticated multi-tenant platforms.
The real value emerges during incident response. Production issue? Run the prod profile locally with a production database snapshot. Integration test failure? Run the test profile on your development machine. Configuration discrepancy? Impossible—all environments use version-controlled property files. Profile-based configuration eliminates entire categories of environment-related failures.
Understanding Spring profiles means understanding how to build applications that deploy confidently. You're not guessing whether production configuration is correct. You're not maintaining parallel codebases that drift apart. You're running one application that adapts intelligently to its environment through explicit, version-controlled configuration. That architectural decision pays dividends every time you deploy.
Spring Profiles. Last updated January 12, 2026.
Join our course Building Production-Ready REST APIs with Spring Boot to learn enterprise Spring development patterns, or start with our free Core Java course to build your foundation.