The previous lesson ended with a problem laid bare: all of the Actuator configuration — what is exposed, how many details are shown, whether shutdown is enabled — must be different on a developer's laptop and on the Ribalta council's server. And not only Actuator: the database (H2 versus PostgreSQL), the log level, Swagger, ddl-auto, CORS, JWT expiry and even which test data is loaded at startup. Until now we have been changing them by hand in a single application.yml, which is exactly how a development configuration ends up deployed in production.
This lesson solves that with profiles, the mechanism that lets the same JAR — byte for byte, the one that passed ./mvnw verify — behave differently depending on where it runs. We will look at how they are activated, how the files are organised per environment and which one wins when two of them define the same property, how to make beans conditional with @Profile, when a profile is the wrong tool, how they behave in tests and how configuration is injected from outside the artefact in a container.
Contents
- Build once, deploy many
- What a profile is and how it is activated
- Per-profile files: which one wins on conflict
- CicloUrbana's configuration map
- Multi-profile documents in a single YAML
- Profile groups
@Profileon beans and configuration classes- When
@Profileis a design smell - Profiles and tests
- Checking which profile is active
- External configuration: files,
spring.config.importand environment variables - Secrets per environment
- Classic mistakes and how to spot them early
- Common Mistakes and Tips
- Exercises
- Build once, deploy many
The principle can be stated in one sentence: the artefact that is tested is the artefact that is deployed. If moving from pre-production to production requires a rebuild with one value changed, what reaches Ribalta is not what was validated but something similar to it. It is an explicit principle of the 12-Factor App methodology (factor III, "config") and the whole reason profiles exist.
| Approach | How it changes environment | Problem |
|---|---|---|
| Rebuild per environment | mvn -Pprod package with resource filtering |
The production binary has never been tested; the tested one is never deployed |
| Edit the YAML before deploying | Somebody changes the URL by hand | Human error guaranteed; no record of who changed what |
| One artefact + external configuration | The same JAR, different configuration at startup | None of the above; it is what we do here |
An awkward but important corollary: Maven profiles and Spring profiles are different things and must not be confused. A Maven profile decides what gets compiled and packaged — a build-time decision; a Spring profile decides how the already-packaged thing behaves — a runtime decision. Using Maven profiles to separate environments breaks this lesson's principle.
- What a profile is and how it is activated
A profile is simply a named label that may or may not be active. Spring reads that list at startup and uses it for two things: choosing which configuration files to load and deciding which beans to register.
| Mechanism | Example | Typical use |
|---|---|---|
Property in application.yml |
spring.profiles.active: dev |
Default value for development |
| Environment variable | SPRING_PROFILES_ACTIVE=prod |
The standard way in containers (07-04) |
| Command-line argument | java -jar app.jar --spring.profiles.active=pre |
Manual launches and scripts |
| System property | -Dspring.profiles.active=prod |
Application servers and legacy launches |
| Annotation in tests | @ActiveProfiles("test") |
JUnit suites (06-04) |
| Programmatic | new SpringApplicationBuilder().profiles("dev") |
Infrequent embedded launches |
Several can be active at once, comma-separated: SPRING_PROFILES_ACTIVE=prod,metrics,eu. And the precedence is the property order from 02-04: the command line beats the environment variable, which beats the packaged YAML. That chain is exactly what makes the dev value written in the repository's application.yml a convenient default that production overrides without touching the file.
There are two additional, less well-known properties:
spring.profiles.defaultchanges the implicit profile when none is activated. Without it the value isdefault, which means a@Profile("default")applies only if nobody activated anything.spring.profiles.includeadds profiles to those already active, without replacing them. It is useful for cross-cutting add-ons (spring.profiles.include: audit) and not for building hierarchies: the groups in section 6 are for that.
- Per-profile files: which one wins on conflict
The convention is application-<profile>.yml in src/main/resources. CicloUrbana will have four:
src/main/resources/ ├── application.yml # common to every environment ├── application-dev.yml # developer's laptop ├── application-test.yml # automated tests ├── application-pre.yml # the council's pre-production └── application-prod.yml # production
With spring.profiles.active=prod, Spring loads application.yml first and application-prod.yml second, and the second wins property by property. It is important to understand that one file does not replace the other: they are merged.
# application.yml — the common part
spring:
application:
name: ciclourbana
jpa:
open-in-view: false
ciclourbana:
network:
city: Ribalta
minimum-capacity: 8
maximum-rental-duration: 2h# application-prod.yml — only what changes
spring:
jpa:
hibernate:
ddl-auto: validate
ciclourbana:
network:
maximum-rental-duration: 4hIn production, city is still Ribalta (it comes from the common file), maximum-rental-duration is 4h (the profile wins) and minimum-capacity is still 8. Three rules that avoid surprises:
- Merging happens per key, not per document. Defining
ciclourbana.networkin the profile file does not erase the keys it does not mention. - Lists and maps are NOT merged, they are replaced wholesale. If
application.ymldeclaresfeatured-stations: [Main Square, University]andapplication-prod.ymldeclaresfeatured-stations: [North Station], in production the list has one element. This is the cause of baffling bugs with collection properties. - With several profiles active, the last one in the list wins. With
active=prod,eu,application-eu.ymloverridesapplication-prod.yml.
And a style rule that saves a great deal of maintenance: everything common goes in application.yml and only the differences go in the profile files. Duplicating the whole file per environment guarantees that, sooner or later, a change will be applied in three of the four.
- CicloUrbana's configuration map
This is the split we adopt, and it works as a guide for any project:
| Aspect | dev |
test |
pre |
prod |
|---|---|---|---|---|
| Database | In-memory H2 | H2 or Testcontainers (06-05) | PostgreSQL 16 in Docker | Managed PostgreSQL 16 |
ddl-auto |
create-drop |
create-drop |
validate |
validate |
| Flyway | On, with db/migration/dev |
Off | On | On, without clean |
Log level for com.ciclourbana |
DEBUG |
INFO |
INFO |
INFO |
| Hibernate SQL | DEBUG + format_sql |
WARN |
WARN |
WARN |
| Swagger UI (03-07) | Enabled | Disabled | Enabled, with a key | Disabled |
| H2 console (04-02) | Enabled | Disabled | — | — |
| CORS (05-05) | http://localhost:* |
— | The pre domain | Only https://ciclourbana.ribalta.example |
| JWT expiry | 8h, convenient for debugging |
1m, to test expiry |
15m |
15m |
| Actuator exposure | * |
health |
Explicit list | Minimal list, port 8081 |
Health show-details |
always |
never |
when-authorized |
when-authorized |
| Demo data | DemoDataLoader active |
Per-test data | No loader | No loader |
| Confirmation e-mail | Fake service that writes to the log | Mocked with Mockito | Real, to a test mailbox | Real |
| Payment gateway | Local simulator | WireMock (07-06) | The gateway's test environment | The real gateway |
Two rows are worth pausing on. JWT expiry in test is one minute on purpose: it is the only way to test expiry without waiting. And ddl-auto: validate in pre and prod is the guarantee from 04-08: if an entity drifts away from the migrations, startup fails in pre-production and not in Ribalta.
- Multi-profile documents in a single YAML
A YAML file can contain several documents separated by ---, each one conditional on a profile:
spring:
application:
name: ciclourbana
jpa:
open-in-view: false
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:ciclourbana;MODE=PostgreSQL
h2:
console:
enabled: true
logging:
level:
com.ciclourbana: DEBUG
---
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:postgresql://db-ribalta:5432/ciclourbana
password: ${POSTGRES_PASSWORD}spring.config.activate.on-profile replaces the old spring.profiles from Boot 1.x, and it accepts the same expressions as @Profile (!prod, dev | test). Its sibling spring.config.activate.on-cloud-platform: kubernetes makes a document conditional on the detected platform, something we will pick up again in 08-04.
Its two limits are what decide when to use it:
spring.profiles.activecannot be declared inside a conditional document. That makes sense: it would mean activating a profile from a block that is only read if that profile is already active. Spring throwsInvalidConfigDataPropertyExceptionat startup.- The file grows very quickly. With four environments and thirty properties each, a single three-hundred-line YAML is harder to review than four of seventy lines, and in a code review nobody can see at a glance what differs between
preandprod.
The recommendation for CicloUrbana: separate files per environment, with application.yml for the common part. The multi-document format is reserved for two or three small differences, or for configurations that must travel together by necessity, such as an external application.yml mounted into a container.
- Profile groups
When profiles start combining — prod + postgres + real-mail + metrics — activating four names by hand is fragile. A group bundles them under one alias:
spring:
profiles:
group:
prod: postgres, real-mail, metrics, strict-cors
dev: h2, fake-mail, demo-dataWith SPRING_PROFILES_ACTIVE=prod, all five end up active. The advantage is that beans are made conditional on the capability, not on the environment: @Profile("real-mail") describes what the bean does, whereas @Profile("prod") only describes where it lives. If tomorrow pre-production must also send real e-mail, it is enough to add real-mail to the pre group, without touching a line of Java.
@Profile on beans and configuration classes
@Profile on beans and configuration classes@Profile decides whether a bean — or a whole configuration class — ever gets registered in the context.
package com.ciclourbana.stations;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("demo-data")
public class DemoDataLoader implements CommandLineRunner {
private final StationService stationService;
private final BikeService bikeService; // constructor omitted
@Override
public void run(String... args) {
if (stationService.countAll() > 0) {
return; // idempotent: does not duplicate if data already exists
}
stationService.create("Main Square", 24);
stationService.create("North Station", 30);
stationService.create("River Park", 18);
stationService.create("University", 36);
bikeService.registerBatch("RB-0142", 40);
log.info("Demo data loaded for the Ribalta network");
}
}Two details from the example. The profile is demo-data, not dev, following the idea from the previous section: it describes the capability. And run is idempotent, because in dev with in-memory H2 it runs on every startup, but if somebody activates the profile against a persistent database it must not duplicate the four stations.
The annotation accepts logical expressions:
| Expression | Registered when... |
|---|---|
@Profile("dev") |
dev is active |
@Profile("!prod") |
prod is not active (handy for development tooling) |
@Profile({"dev", "test"}) |
dev or test (the array is an OR) |
@Profile("dev | test") |
The same, using the expression syntax |
@Profile("prod & !maintenance") |
prod active and maintenance not |
Two more applications in CicloUrbana. A mail service with two implementations behind the same interface:
public interface MailService {
void sendConfirmation(Long rentalId, String recipient);
}
@Service
@Profile("fake-mail")
public class LoggingMailService implements MailService {
@Override
public void sendConfirmation(Long rentalId, String recipient) {
log.info("[FAKE MAIL] Confirmation for rental {} to {}", rentalId, recipient);
}
}
@Service
@Profile("real-mail")
public class SmtpMailService implements MailService {
// uses JavaMailSender with the environment's credentials
}And a whole configuration class made conditional, which is the preferable form when the profile affects several beans at once:
@Configuration
@Profile("strict-cors")
public class ProductionCorsConfig {
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cors = new CorsConfiguration();
cors.setAllowedOrigins(List.of("https://ciclourbana.ribalta.example"));
cors.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
cors.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cors.setAllowCredentials(true);
cors.setMaxAge(Duration.ofHours(1));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cors);
return source;
}
}The classic trap: making sure exactly one of the candidate beans ends up registered. If nobody activates real-mail or fake-mail, there is no MailService at all and startup fails with NoSuchBeanDefinitionException; if both are activated, it fails with NoUniqueBeanDefinitionException. The safety net is to mark one of them @ConditionalOnMissingBean (02-06) or, better, always include them in the groups from section 6 so that no environment can be left without an implementation.
- When
@Profile is a design smell
@Profile is a design smell@Profile is convenient and therefore overused. The symptom is a class with @Profile("prod") whose name says nothing about the environment, or a condition that is not really "where am I" but "what is switched on". That second case calls for a property and @ConditionalOnProperty (02-06):
@Bean
@ConditionalOnProperty(prefix = "ciclourbana.rentals", name = "expirer.enabled",
havingValue = "true", matchIfMissing = true)
RentalExpirer rentalExpirer(RentalService service) {
return new RentalExpirer(service);
}| Criterion | Use @Profile |
Use @ConditionalOnProperty |
|---|---|---|
| The decision depends on the environment | Yes | No |
| The decision is a feature switch that can change within the same environment | No | Yes |
| You need to disable it on one specific instance without changing profile | No | Yes |
| It swaps one implementation for another | Reasonable | Possible, but more verbose |
| It must be changeable without recompiling or redeploying | No (the profile is fixed at startup) | Yes (environment variable, external config) |
| Number of expected combinations | Few and stable | Many and mutually independent |
The case from section 7 illustrates it well: which MailService implementation is used is an environment decision and fits a profile; whether the rental expirer runs on this particular instance (07-03) is not, because when we scale out we will want to switch it off on all but one without changing their profile.
The practical rule: if you catch yourself writing @Profile("prod | pre | client-demo"), the condition stopped being the environment a long time ago. And an if (environment.equals("prod")) inside a business method is always a mistake: business logic must not know which machine it runs on.
- Profiles and tests
In tests, the profile is activated with @ActiveProfiles (06-04):
@SpringBootTest
@ActiveProfiles("test")
class RentalFullFlowIT extends IntegrationTestBase {
// ...
}Three things worth being clear about. @ActiveProfiles replaces, it does not add: whatever was in spring.profiles.active is ignored. To add without replacing there is @ActiveProfiles(profiles = "extra", inheritProfiles = true) in hierarchies of test classes.
Every distinct profile combination creates a distinct context. That is the context cache lesson from 06-04: the cache key includes the list of active profiles, so one class with @ActiveProfiles("test") and another with @ActiveProfiles({"test", "fake-mail"}) start two complete contexts. Standardising the suite's profiles in the IntegrationTestBase base class is not cosmetic: it is the difference between a two-minute suite and a twenty-minute one.
And the test profile must genuinely exist. An application-test.yml with the test database, Flyway disabled, logs at INFO and the one-minute JWT stops the tests from accidentally inheriting the dev configuration — including the H2 console and the demo data loader, which would break the assertions about the four stations.
- Checking which profile is active
The first check is free and sits in the startup log:
When there is none, the message is different and worth recognising, because it is the warning that production is about to start with the default configuration:
The other two routes come from Actuator (07-01): /actuator/env shows activeProfiles along with the loaded property sources — among them applicationConfig: [classpath:/application-prod.yml], which confirms which file was read — and /actuator/configprops shows the effective values of NetworkProperties and friends. And from code, Environment exposes it directly:
@Component
public class StartupProfileLogger {
public StartupProfileLogger(Environment environment) {
log.info("Active profiles: {}", Arrays.toString(environment.getActiveProfiles()));
if (environment.getActiveProfiles().length == 0) {
log.warn("No active profile: the default configuration will be used");
}
}
}
- External configuration: files,
spring.config.import and environment variables
spring.config.import and environment variablesProfiles settle which configuration is used; what is missing is where it comes from. Spring looks for application.yml in several locations, and the external ones beat the packaged ones:
| Order (the last one wins) | Location |
|---|---|
| 1 | classpath:/application.yml (inside the JAR) |
| 2 | classpath:/config/application.yml |
| 3 | ./application.yml (next to the JAR) |
| 4 | ./config/application.yml |
That chain lets you deploy the JAR and leave a config/application-prod.yml alongside it with the council's values, without rebuilding anything. To import other sources explicitly there is spring.config.import:
spring:
config:
import:
- optional:file:./config/ # external directory, if it exists
- optional:file:/etc/ciclourbana/secrets.yml
- optional:configtree:/run/secrets/ # secrets mounted as filesThe optional: prefix is what stops the application from refusing to start when the file does not exist — essential if the same configuration has to work on the laptop and on the server; without it, a missing resource is a startup error, which is sometimes exactly what you want in production. The configtree: prefix reads a directory where each file is a property and its content the value, which is exactly the format in which Docker and Kubernetes mount secrets (07-04, 08-04).
In containers, however, the preferred route is environment variables, for three practical reasons: they require no volume mounts, every deployment platform handles them natively, and they fit factor III of the 12-Factor App, which asks you to store configuration in the environment and not in the code. The name translation is mechanical:
| Spring property | Environment variable |
|---|---|
spring.profiles.active |
SPRING_PROFILES_ACTIVE |
spring.datasource.url |
SPRING_DATASOURCE_URL |
ciclourbana.network.minimum-capacity |
CICLOURBANA_NETWORK_MINIMUMCAPACITY |
ciclourbana.jwt.secret |
CICLOURBANA_JWT_SECRET |
The rule is: upper case, with dots and hyphens turned into underscores. Spring applies the relaxed binding from 02-05 in reverse, so CICLOURBANA_NETWORK_MINIMUMCAPACITY and CICLOURBANA_NETWORK_MINIMUM_CAPACITY both work.
- Secrets per environment
Of everything that changes between environments, secrets are the only thing that cannot live in the repository. The PostgreSQL password, the JWT signing secret from 05-04 and the payment gateway key must not appear in any versioned application-prod.yml, not even "temporarily": once a secret enters the Git history it stays there even if it is deleted in the next commit, and it has to be rotated.
The pattern CicloUrbana applies:
# application-prod.yml — versioned, WITHOUT secret values
spring:
datasource:
url: jdbc:postgresql://db-ribalta:5432/ciclourbana
username: ciclourbana
password: ${POSTGRES_PASSWORD} # no default value: mandatory
ciclourbana:
jwt:
secret: ${JWT_SECRET}
expiration: 15m
gateway:
url: https://payments.ribalta.example/api/v1
api-key: ${GATEWAY_API_KEY}Three deliberate decisions. ${POSTGRES_PASSWORD} with no default value makes the application fail at startup if the variable is missing: that is exactly the behaviour we want, because a startup that fails loudly is better than one that starts with a development password. The structure is versioned, so that code review shows what the environment needs. And the production file with real values does not exist: the values are supplied by the orchestrator from its secret manager (Vault, AWS Secrets Manager, Kubernetes secrets).
A .gitignore with application-local.yml and .env closes the circle and gives each developer a personal file for their own values, loaded with spring.config.import: optional:file:./application-local.yml.
- Classic mistakes and how to spot them early
The profile not activated in production. Startup says No active profile set and the application comes up with in-memory H2, Swagger wide open and the whole of Actuator exposed. Worse still: it works, and nobody notices until the data disappears at the next restart. The defence is an ApplicationListener that aborts if there is no profile, or simply requiring in application.yml a mandatory property that only the environment files define.
Properties duplicated in the common file and in the profile file. This is not a bug, it is the tool working; the problem shows up when somebody fixes the value in application.yml and cannot understand why production is unchanged. Rule: if a property is in a profile file, remove it from the common one unless you want an explicit default.
The mandatory value missing in one environment. This is the most expensive mistake, because it can take hours to surface: the application starts, serves a thousand requests and fails the first time somebody tries to pay, because ciclourbana.gateway.api-key was empty. We already had the solution back in 02-05: validated @ConfigurationProperties.
@ConfigurationProperties(prefix = "ciclourbana.gateway")
@Validated
public record GatewayProperties(
@NotBlank String url,
@NotBlank String apiKey,
@NotNull @DurationMin(millis = 200) Duration timeout) {
}With this, an environment missing GATEWAY_API_KEY does not start, and the message says exactly which property is missing:
Binding to target com.ciclourbana.gateway.GatewayProperties failed:
Property: ciclourbana.gateway.api-key
Reason: must not be blankA failure at startup, in front of an orchestrator that has not yet withdrawn the previous version, is infinitely preferable to an intermittent failure during service hours. It is the same philosophy as ddl-auto: validate in 04-08: let the error appear as early and as loudly as possible.
Implicit dependencies between profiles. A bean with @Profile("prod") that needs another with @Profile("postgres") works as long as somebody activates both, and blows up the day they do not. The groups from section 6 make it explicit and checkable.
Common Mistakes and Tips
Rebuilding per environment. It breaks the lesson's principle: the production binary is not the one that was tested. One artefact, external configuration.
Confusing Maven profiles with Spring profiles. The former decide what gets packaged; the latter, how the packaged thing behaves.
Copying the whole application.yml into every profile file. It guarantees that a future change will be applied in three of the four environments. Only the differences.
Expecting lists to merge. Collections are replaced wholesale. If prod redefines featured-stations, the one from the common file disappears.
Versioning an application-prod.yml with credentials. Once it is in the Git history the secret is compromised even if it is deleted afterwards: it has to be rotated.
Overusing @Profile("prod") for feature switches. If the condition is not "where am I" but "what is switched on", the right tool is @ConditionalOnProperty.
Zero or two implementations of the same interface. Badly combined profiles produce NoSuchBeanDefinitionException or NoUniqueBeanDefinitionException at startup. Cover it with groups.
Tip: name profiles after capabilities, not environments (real-mail, demo-data, postgres) and compose the environments with spring.profiles.group. The code stops knowing where it lives.
Tip: read the startup log on every deployment. The The following N profiles are active line is the cheapest check there is and it catches the most expensive mistake.
Tip: validate the configuration with @ConfigurationProperties and @Validated. It turns a configuration failure into a startup failure, which is where it belongs.
Exercises
Exercise 1: separating CicloUrbana's environments
Starting from a single application.yml that today has H2, ddl-auto: create-drop, Swagger enabled, logging.level.com.ciclourbana: DEBUG, management.endpoints.web.exposure.include: "*" and ciclourbana.jwt.expiration: 8h, write application.yml, application-dev.yml and application-prod.yml following the map in section 4. State what stays in each file and why, and how each environment is started.
Exercise 2: capability profiles and groups
CicloUrbana needs two implementations of NotificationService (one that writes to the log, another that sends SMS through a real gateway) and two of PaymentService (a simulator that always approves and the real client). Design the capability profiles, the groups that compose dev, pre and prod, and the annotations required, guaranteeing that there are never zero or two candidates. Then explain what would happen if somebody started with SPRING_PROFILES_ACTIVE=prod,notifications-fake.
Exercise 3: diagnosing a deployment that "works but shouldn't"
CicloUrbana has been deployed on the council's server and it responds correctly, but the team notices that (a) rentals disappear every time the service is restarted, (b) https://ciclourbana.ribalta.example/swagger-ui.html is reachable from the internet, (c) the log grows at an enormous rate and (d) /actuator/env returns 200 with the password visible. The startup command is java -jar ciclourbana.jar and the server has a correct application-prod.yml inside the JAR. Diagnose the root cause, explain how each symptom follows from it and propose a fix that stops it happening again.
Solutions
Solution 1
application.yml — only what is common to every environment:
spring:
application:
name: ciclourbana
jpa:
open-in-view: false
properties:
hibernate.jdbc.batch_size: 25
ciclourbana:
network:
city: Ribalta
minimum-capacity: 8
battery-threshold: 20
maximum-rental-duration: 2h
logging:
level:
root: INFOapplication-dev.yml — convenience, with no real data to lose:
spring:
datasource:
url: jdbc:h2:mem:ciclourbana;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
logging:
level:
com.ciclourbana: DEBUG
org.hibernate.SQL: DEBUG
springdoc:
swagger-ui:
enabled: true
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
ciclourbana:
jwt:
secret: development-secret-of-at-least-32-characters
expiration: 8happlication-prod.yml — minimal, locked down and free of secrets:
spring:
datasource:
url: jdbc:postgresql://db-ribalta:5432/ciclourbana
username: ciclourbana
password: ${POSTGRES_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
h2:
console:
enabled: false
logging:
level:
com.ciclourbana: INFO
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
management:
server:
port: 8081
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
roles: ADMIN
ciclourbana:
jwt:
secret: ${JWT_SECRET}
expiration: 15mWhat goes where and why. The common file keeps what must not vary: the application name, open-in-view: false — an architectural decision from 04-02, not an environmental one — and the business rules of the Ribalta network, which must be identical everywhere so that what was tested is what is deployed. The profile files keep the four families that really do change: data source, verbosity, exposed surface and security policy.
Note that ddl-auto, swagger-ui.enabled and exposure.include appear in both profile files and in neither the common one. That is deliberate: for dangerous properties, a default in application.yml means that a profile which forgets to declare them inherits the convenient value. By not existing by default, every environment is forced to decide.
Starting each environment:
./mvnw spring-boot:run # dev, if it is the default active profile
java -jar target/ciclourbana.jar --spring.profiles.active=dev
SPRING_PROFILES_ACTIVE=prod POSTGRES_PASSWORD=... JWT_SECRET=... java -jar ciclourbana.jarSolution 2
Capability profiles, four of them in two mutually exclusive pairs: notifications-log / notifications-sms and payments-simulated / payments-real.
@Service
@Profile("notifications-sms")
public class SmsNotificationService implements NotificationService { /* ... */ }
@Service
@Profile("!notifications-sms") // safety net: the one left if there is no other
public class LoggingNotificationService implements NotificationService { /* ... */ }
@Service
@Profile("payments-real")
public class PaymentGatewayClient implements PaymentService { /* ... */ }
@Service
@Profile("!payments-real")
public class PaymentSimulator implements PaymentService { /* ... */ }spring:
profiles:
group:
dev: notifications-log, payments-simulated, demo-data
pre: notifications-sms, payments-simulated, strict-cors
prod: notifications-sms, payments-real, strict-corsThe key to the design is the negation. Using @Profile("notifications-log") on the fallback implementation would let an environment end up with no implementation at all if it forgets to activate it; with @Profile("!notifications-sms") there is always exactly one: the real one when the profile is active, the logging one in every other case. The positive profiles (notifications-log, payments-simulated) still appear in the groups because they document the intent, even though technically they are no longer needed.
An equally valid alternative is to keep both positive profiles and mark one of the beans with @ConditionalOnMissingBean(NotificationService.class) (02-06). The effect is the same; the advantage of the negation is that it does not depend on the registration order of the autoconfigurations.
What happens with SPRING_PROFILES_ACTIVE=prod,notifications-fake. The notifications-fake profile does not exist anywhere, so it is activated and does absolutely nothing — Spring does not validate that a profile corresponds to anything. The prod group still expands, notifications-sms stays active and real SMS keep being sent. This is precisely the danger of profiles: a misspelt name produces no error at all, only behaviour different from the one expected. The defence is twofold: a @PostConstruct that checks which implementation ended up registered and writes it to the log (log.info("Notifications: {}", service.getClass().getSimpleName())), and one integration test per environment that verifies the bean type with @ActiveProfiles("prod").
Solution 3
There is a single root cause: the prod profile was never activated. The command java -jar ciclourbana.jar does not pass --spring.profiles.active, there is no SPRING_PROFILES_ACTIVE in the service's environment and application.yml does not declare spring.profiles.active. The startup log holds the proof: No active profile set, falling back to 1 default profile: "default".
All four symptoms follow from that, all through the same mechanism — application-prod.yml exists inside the JAR but is never read, so only application.yml applies, which is the development one:
| Symptom | Property left in force | Why it manifests this way |
|---|---|---|
| (a) Rentals disappear on restart | The in-memory H2 spring.datasource.url |
The application works because H2 creates the schema with ddl-auto: create-drop; the data lives in RAM and dies with the process |
| (b) Swagger reachable from the internet | springdoc.swagger-ui.enabled: true |
It exposes the complete inventory of the API and its models to anyone |
| (c) The log grows uncontrollably | logging.level.com.ciclourbana: DEBUG and org.hibernate.SQL: DEBUG |
Every query is written out in full; with real traffic, gigabytes a day |
(d) /actuator/env returns 200 with the password |
exposure.include: "*" and show-values: always |
It is the scenario from exercise 3 of the previous lesson, in its accidental version |
And there is a fifth symptom that has not shown up yet and is the most serious of all: the council's PostgreSQL database is untouched and empty of traffic, because nobody has written to it since the deployment. The citizens' rentals from these past days are lost.
The immediate fix is to start with the profile, preferably through an environment variable in the service unit or in the container:
The structural fix, which is what the exercise asks for, has three layers.
First, make the application refuse to start without a profile, because relying on somebody remembering a variable is relying on memory:
@Component
public class ProfileValidator {
public ProfileValidator(Environment environment) {
if (environment.getActiveProfiles().length == 0) {
throw new IllegalStateException(
"No active profile. Start with SPRING_PROFILES_ACTIVE=dev|test|pre|prod");
}
}
}Second, make sure the dangerous values have no convenient default. If spring.datasource.url is not in application.yml, the profile-less application does not start with H2: it does not start at all. The same goes for the JWT secret declared as ${JWT_SECRET} with no default, and for the validated GatewayProperties from section 13. The underlying idea: remove the possibility that a slip produces a system that half works, because that is far worse than one that does not work.
Third, verify the deployment. A post-deployment check that queries /actuator/info and verifies the version, and another that queries /actuator/env expecting 401 or 404, would have caught all four symptoms within the first minute. In 08-05 this becomes an automatic step of continuous delivery.
Conclusion
CicloUrbana now knows where it lives. You have internalised the principle that governs this lesson — build once, deploy many — and you know why rebuilding per environment means deploying a binary nobody tested. You know the six ways of activating a profile and their precedence, with SPRING_PROFILES_ACTIVE as the standard form in containers, and the two lesser-known properties, spring.profiles.default and spring.profiles.include. You know how application.yml and the profile files combine, that merging happens per key, that lists are replaced wholesale and that with several profiles active the last one wins. You have the complete configuration map of CicloUrbana per environment — database, ddl-auto, Flyway, logs, Swagger, CORS, JWT expiry, Actuator exposure, demo data — as a template for any project.
You have mastered multi-profile documents with --- and spring.config.activate.on-profile, and their two limits, which are what make separate files preferable. You know how to compose environments with spring.profiles.group out of profiles that describe capabilities rather than places, and how to make beans and configuration classes conditional with @Profile and its expressions: an idempotent DemoDataLoader on demo-data, the two faces of MailService and ProductionCorsConfig. And, above all, you know when @Profile is the wrong tool: when the condition is not where you are but what is switched on, the answer is a property and @ConditionalOnProperty, with the criteria table to settle it without argument.
In tests, @ActiveProfiles replaces rather than adds and every combination opens a new context, so standardising them in IntegrationTestBase is what keeps the suite down to minutes. You know how to check the active profile in the startup log, in /actuator/env and from Environment, how to inject configuration from outside the artefact using the location chain, spring.config.import and the optional: prefix, and why environment variables rule in containers. And you have a secrets policy: the structure is versioned, the values never; ${VARIABLE} with no default so that absence is a startup failure; and validated @ConfigurationProperties that turn a configuration slip into an immediate, explicit error rather than a failed gateway call three hours later.
With Actuator and profiles, CicloUrbana is observable and knows how to adapt to its environment. But it is still a purely reactive application: it only does something when somebody asks it to. Nobody closes at dawn the rentals a citizen forgot to finish, nobody recalculates the occupancy of Ribalta's four stations every minute for the mobile app, and the confirmation e-mail is still sent on the very thread serving the request, forcing the citizen to wait for the SMTP server to answer. The next lesson, Scheduled Tasks and Asynchronous Execution, gives CicloUrbana initiative of its own and the ability to do things in the background, with every trap that brings: the single-threaded scheduler, the task that runs N times when you scale out, the transaction that does not travel to the other thread and the security context left behind.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
