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

  1. Build once, deploy many
  2. What a profile is and how it is activated
  3. Per-profile files: which one wins on conflict
  4. CicloUrbana's configuration map
  5. Multi-profile documents in a single YAML
  6. Profile groups
  7. @Profile on beans and configuration classes
  8. When @Profile is a design smell
  9. Profiles and tests
  10. Checking which profile is active
  11. External configuration: files, spring.config.import and environment variables
  12. Secrets per environment
  13. Classic mistakes and how to spot them early
  14. Common Mistakes and Tips
  15. Exercises

  1. 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.

  1. 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.default changes the implicit profile when none is activated. Without it the value is default, which means a @Profile("default") applies only if nobody activated anything.
  • spring.profiles.include adds 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.

  1. 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: 4h

In 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:

  1. Merging happens per key, not per document. Defining ciclourbana.network in the profile file does not erase the keys it does not mention.
  2. Lists and maps are NOT merged, they are replaced wholesale. If application.yml declares featured-stations: [Main Square, University] and application-prod.yml declares featured-stations: [North Station], in production the list has one element. This is the cause of baffling bugs with collection properties.
  3. With several profiles active, the last one in the list wins. With active=prod,eu, application-eu.yml overrides application-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.

  1. 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.

  1. 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.active cannot 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 throws InvalidConfigDataPropertyException at 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 pre and prod.

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.

  1. 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-data

With 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.

  1. @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.

  1. When @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.

  1. 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.

  1. Checking which profile is active

The first check is free and sits in the startup log:

The following 4 profiles are active: "prod", "postgres", "real-mail", "strict-cors"

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:

No active profile set, falling back to 1 default profile: "default"

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");
        }
    }
}

  1. External configuration: files, spring.config.import and environment variables

Profiles 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 files

The 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.

  1. 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.

  1. 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 blank

A 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: INFO

application-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: 8h

application-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: 15m

What 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.jar

Solution 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-cors

The 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:

SPRING_PROFILES_ACTIVE=prod java -jar ciclourbana.jar

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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved