La lección anterior terminó con un problema al descubierto: toda la configuración de Actuator —qué se expone, cuántos detalles se muestran, si shutdown está habilitado— debe ser distinta en el portátil de un desarrollador y en el servidor del ayuntamiento de Ribalta. Y no solo Actuator: la base de datos (H2 frente a PostgreSQL), el nivel de log, Swagger, ddl-auto, CORS, la caducidad del JWT y hasta qué datos de prueba se cargan al arrancar. Hasta ahora los hemos ido cambiando a mano en un único application.yml, que es exactamente cómo una configuración de desarrollo acaba desplegada en producción.

Esta lección resuelve eso con los perfiles, el mecanismo que permite que el mismo JAR —byte a byte, el que aprobó ./mvnw verify— se comporte de forma distinta según dónde se ejecute. Veremos cómo se activan, cómo se organizan los ficheros por entorno y qué gana cuando dos definen la misma propiedad, cómo condicionar beans con @Profile, cuándo un perfil es la herramienta equivocada, cómo se comportan en las pruebas y cómo se inyecta la configuración desde fuera del artefacto en un contenedor.

Contenido

  1. Construir una vez, desplegar en muchos sitios
  2. Qué es un perfil y cómo se activa
  3. Ficheros por perfil: qué gana cuando hay conflicto
  4. El mapa de configuración de CicloUrbana
  5. Documentos multi-perfil en un solo YAML
  6. Grupos de perfiles
  7. @Profile en beans y clases de configuración
  8. Cuándo @Profile es un olor de diseño
  9. Perfiles y pruebas
  10. Comprobar qué perfil está activo
  11. Configuración externa: ficheros, spring.config.import y variables de entorno
  12. Secretos por entorno
  13. Errores clásicos y cómo detectarlos pronto
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. Construir una vez, desplegar en muchos sitios

El principio se enuncia en una frase: el artefacto que se prueba es el artefacto que se despliega. Si para pasar de preproducción a producción hay que recompilar cambiando un valor, lo que llega a Ribalta no es lo que se validó, sino algo parecido. Es un principio explícito de la metodología 12-Factor App (factor III, «configuración») y la razón de ser de los perfiles.

Enfoque Cómo cambia de entorno Problema
Recompilar por entorno mvn -Pprod package con filtrado de recursos El binario de producción nunca se ha probado; el de pruebas nunca se despliega
Editar el YAML antes de desplegar Alguien cambia la URL a mano Error humano garantizado; sin trazabilidad de quién cambió qué
Un artefacto + configuración externa El mismo JAR, distinta configuración al arrancar Ninguno de los anteriores; es lo que hacemos aquí

Un corolario incómodo pero importante: los perfiles de Maven y los perfiles de Spring son cosas distintas y no deben confundirse. Un perfil de Maven decide qué se compila y se empaqueta —decisión de construcción—; un perfil de Spring decide cómo se comporta lo ya empaquetado —decisión de ejecución—. Usar perfiles de Maven para separar entornos rompe el principio de esta lección.

  1. Qué es un perfil y cómo se activa

Un perfil es simplemente una etiqueta con nombre que puede estar activa o no. Spring lee esa lista al arrancar y la usa para dos cosas: elegir qué ficheros de configuración carga y decidir qué beans registra.

Mecanismo Ejemplo Uso típico
Propiedad en application.yml spring.profiles.active: dev Valor por defecto para desarrollo
Variable de entorno SPRING_PROFILES_ACTIVE=prod La forma estándar en contenedores (07-04)
Argumento de línea de comandos java -jar app.jar --spring.profiles.active=pre Arranques manuales y scripts
Propiedad de sistema -Dspring.profiles.active=prod Servidores de aplicaciones y arranques heredados
Anotación en pruebas @ActiveProfiles("test") Suites de JUnit (06-04)
Programático new SpringApplicationBuilder().profiles("dev") Arranques embebidos poco frecuentes

Se pueden activar varios a la vez, separados por comas: SPRING_PROFILES_ACTIVE=prod,metricas,ue. Y la precedencia es la del orden de propiedades de 02-04: la línea de comandos gana a la variable de entorno, que gana al YAML empaquetado. Esa cadena es justo lo que permite que el valor dev escrito en el application.yml del repositorio sea un valor cómodo por defecto que producción sobrescribe sin tocar el fichero.

Existen dos propiedades adicionales menos conocidas:

  • spring.profiles.default cambia el perfil implícito cuando no se activa ninguno. Sin ella vale default, lo que significa que un @Profile("default") se aplica solo si nadie activó nada.
  • spring.profiles.include añade perfiles a los ya activos, sin sustituirlos. Es útil para complementos transversales (spring.profiles.include: auditoria) y no para construir jerarquías: para eso están los grupos del apartado 6.

  1. Ficheros por perfil: qué gana cuando hay conflicto

La convención es application-<perfil>.yml en src/main/resources. CicloUrbana tendrá cuatro:

src/main/resources/
├── application.yml          # común a todos los entornos
├── application-dev.yml      # portátil del desarrollador
├── application-test.yml     # pruebas automatizadas
├── application-pre.yml      # preproducción del ayuntamiento
└── application-prod.yml     # producción

Con spring.profiles.active=prod, Spring carga application.yml primero y application-prod.yml después, y el segundo gana propiedad a propiedad. Es importante entender que no se sustituye un fichero por otro: se combinan.

# application.yml — lo común
spring:
  application:
    name: ciclourbana
  jpa:
    open-in-view: false
ciclourbana:
  red:
    ciudad: Ribalta
    capacidad-minima: 8
    duracion-maxima-alquiler: 2h
# application-prod.yml — solo lo que cambia
spring:
  jpa:
    hibernate:
      ddl-auto: validate
ciclourbana:
  red:
    duracion-maxima-alquiler: 4h

En producción, ciudad sigue valiendo Ribalta (viene del común), duracion-maxima-alquiler vale 4h (gana el perfil) y capacidad-minima sigue siendo 8. Tres reglas que evitan sorpresas:

  1. La fusión es por clave, no por documento. Definir ciclourbana.red en el fichero de perfil no borra las claves que no menciona.
  2. Las listas y los mapas NO se fusionan, se sustituyen enteros. Si application.yml declara estaciones-destacadas: [Plaza Mayor, Universidad] y application-prod.yml declara estaciones-destacadas: [Estación Norte], en producción la lista tiene un elemento. Es la causa de errores desconcertantes con propiedades de colección.
  3. Con varios perfiles activos, gana el último de la lista. Con active=prod,ue, application-ue.yml sobrescribe a application-prod.yml.

Y una regla de estilo que ahorra mucho mantenimiento: en application.yml va todo lo común y en los ficheros de perfil solo las diferencias. Duplicar el fichero entero por entorno garantiza que, tarde o temprano, un cambio se aplique en tres de los cuatro.

  1. El mapa de configuración de CicloUrbana

Este es el reparto que adoptamos, y sirve de guía para cualquier proyecto:

Aspecto dev test pre prod
Base de datos H2 en memoria H2 o Testcontainers (06-05) PostgreSQL 16 en Docker PostgreSQL 16 gestionado
ddl-auto create-drop create-drop validate validate
Flyway Activo con db/migration/dev Desactivado Activo Activo, sin clean
Nivel de log de com.ciclourbana DEBUG INFO INFO INFO
SQL de Hibernate DEBUG + format_sql WARN WARN WARN
Swagger UI (03-07) Habilitado Deshabilitado Habilitado, con clave Deshabilitado
Consola H2 (04-02) Habilitada Deshabilitada — —
CORS (05-05) http://localhost:* — Dominio de pre Solo https://ciclourbana.ribalta.example
Expiración del JWT 8h, cómodo para depurar 1m, para probar la caducidad 15m 15m
Actuator expuesto * health Lista explícita Lista mínima, puerto 8081
show-details de salud always never when-authorized when-authorized
Datos de demostración CargadorDatosDemo activo Datos por prueba Sin cargador Sin cargador
Correo de confirmación Servicio falso que escribe en el log Simulado con Mockito Real, a buzón de pruebas Real
Pasarela de pagos Simulador local WireMock (07-06) Entorno de pruebas de la pasarela Pasarela real

Merece la pena detenerse en dos filas. La expiración del JWT en test es de un minuto a propósito: es la única forma de probar la caducidad sin esperar. Y ddl-auto: validate en pre y prod es la garantía de 04-08: si una entidad se desalinea de las migraciones, el arranque falla en preproducción y no en Ribalta.

  1. Documentos multi-perfil en un solo YAML

Un fichero YAML puede contener varios documentos separados por ---, cada uno condicionado a un perfil:

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://bd-ribalta:5432/ciclourbana
    password: ${POSTGRES_PASSWORD}

spring.config.activate.on-profile sustituye al antiguo spring.profiles de Boot 1.x, y admite las mismas expresiones que @Profile (!prod, dev | test). Su hermana spring.config.activate.on-cloud-platform: kubernetes condiciona un documento a la plataforma detectada, algo que retomaremos en 08-04.

Sus dos límites son los que deciden cuándo usarla:

  • spring.profiles.active no puede declararse dentro de un documento condicionado. Es lógico: sería activar un perfil desde un bloque que solo se lee si ese perfil ya está activo. Spring lanza InvalidConfigDataPropertyException al arrancar.
  • El fichero crece muy deprisa. Con cuatro entornos y treinta propiedades cada uno, un solo YAML de trescientas líneas es más difícil de revisar que cuatro de setenta, y en una revisión de código nadie ve de un vistazo qué cambia entre pre y prod.

La recomendación para CicloUrbana: ficheros separados por entorno, con application.yml para lo común. El formato multi-documento se reserva para dos o tres diferencias pequeñas o para configuraciones que deben viajar juntas por fuerza, como un application.yml externo montado en un contenedor.

  1. Grupos de perfiles

Cuando los perfiles empiezan a combinarse —prod + postgres + correo-real + metricas— activar cuatro nombres a mano es frágil. Un grupo los agrupa bajo un alias:

spring:
  profiles:
    group:
      prod: postgres, correo-real, metricas, cors-estricto
      dev:  h2, correo-falso, datos-demo

Con SPRING_PROFILES_ACTIVE=prod, los cinco quedan activos. La ventaja es que los beans se condicionan a la capacidad, no al entorno: @Profile("correo-real") describe qué hace el bean, mientras que @Profile("prod") solo describe dónde vive. Si mañana preproducción también debe enviar correo real, basta añadir correo-real al grupo pre, sin tocar una línea de Java.

  1. @Profile en beans y clases de configuración

@Profile decide si un bean —o toda una clase de configuración— llega a registrarse en el contexto.

package com.ciclourbana.estaciones;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Component
@Profile("datos-demo")
public class CargadorDatosDemo implements CommandLineRunner {

    private final EstacionService estacionService;
    private final BicicletaService bicicletaService;      // constructor omitido

    @Override
    public void run(String... args) {
        if (estacionService.contarTodas() > 0) {
            return;                    // idempotente: no duplica si ya hay datos
        }
        estacionService.crear("Plaza Mayor", 24);
        estacionService.crear("Estación Norte", 30);
        estacionService.crear("Parque del Río", 18);
        estacionService.crear("Universidad", 36);
        bicicletaService.altaLote("RB-0142", 40);
        log.info("Datos de demostración cargados para la red de Ribalta");
    }
}

Dos detalles del ejemplo. El perfil es datos-demo, no dev, siguiendo la idea del apartado anterior: describe la capacidad. Y el run es idempotente, porque en dev con H2 en memoria se ejecuta en cada arranque, pero si alguien activa el perfil contra una base de datos persistente no debe duplicar las cuatro estaciones.

La anotación admite expresiones lógicas:

Expresión Se registra cuando...
@Profile("dev") dev está activo
@Profile("!prod") prod no está activo (útil para herramientas de desarrollo)
@Profile({"dev", "test"}) dev o test (el array es un OR)
@Profile("dev | test") Lo mismo, con la sintaxis de expresión
@Profile("prod & !mantenimiento") prod activo y mantenimiento no

Dos aplicaciones más en CicloUrbana. Un servicio de correo con dos implementaciones tras la misma interfaz:

public interface ServicioCorreo {
    void enviarConfirmacion(Long alquilerId, String destinatario);
}

@Service
@Profile("correo-falso")
public class ServicioCorreoRegistrado implements ServicioCorreo {
    @Override
    public void enviarConfirmacion(Long alquilerId, String destinatario) {
        log.info("[CORREO SIMULADO] Confirmación del alquiler {} a {}", alquilerId, destinatario);
    }
}

@Service
@Profile("correo-real")
public class ServicioCorreoSmtp implements ServicioCorreo {
    // usa JavaMailSender con las credenciales del entorno
}

Y una clase de configuración completa condicionada, que es la forma preferible cuando el perfil afecta a varios beans a la vez:

@Configuration
@Profile("cors-estricto")
public class ConfiguracionCorsProduccion {

    @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 fuente = new UrlBasedCorsConfigurationSource();
        fuente.registerCorsConfiguration("/api/**", cors);
        return fuente;
    }
}

La trampa clásica: que exactamente uno de los beans candidatos quede registrado. Si nadie activa correo-real ni correo-falso, no hay ningún ServicioCorreo y el arranque falla con NoSuchBeanDefinitionException; si se activan los dos, falla con NoUniqueBeanDefinitionException. La red de seguridad es marcar uno como @ConditionalOnMissingBean (02-06) o, mejor, incluirlos siempre en los grupos del apartado 6 para que ningún entorno pueda quedarse sin implementación.

  1. Cuándo @Profile es un olor de diseño

@Profile es cómodo y por eso se abusa de él. El síntoma es una clase con @Profile("prod") cuyo nombre no dice nada del entorno, o una condición que en realidad no es «dónde estoy» sino «qué está activado». Ese segundo caso pide una propiedad y @ConditionalOnProperty (02-06):

@Bean
@ConditionalOnProperty(prefix = "ciclourbana.alquileres", name = "caducador.activo",
                       havingValue = "true", matchIfMissing = true)
CaducadorAlquileres caducadorAlquileres(AlquilerService servicio) {
    return new CaducadorAlquileres(servicio);
}
Criterio Usa @Profile Usa @ConditionalOnProperty
La decisión depende del entorno Sí No
La decisión es una opción funcional que puede cambiar dentro del mismo entorno No Sí
Hay que poder desactivarlo en una instancia concreta sin cambiar de perfil No Sí
Sustituye una implementación por otra Razonable Posible, pero más verboso
Debe poder cambiarse sin recompilar ni redesplegar No (perfil se fija al arrancar) Sí (variable de entorno, config externa)
Número de combinaciones esperadas Pocas y estables Muchas e independientes entre sí

El caso del apartado 7 lo ilustra bien: qué implementación de ServicioCorreo se usa es una decisión de entorno y encaja en un perfil; si el caducador de alquileres se ejecuta en esta instancia concreta (07-03) no lo es, porque al escalar querremos apagarlo en todas menos una sin cambiarles el perfil.

La regla práctica: si te descubres escribiendo @Profile("prod | pre | demo-cliente"), la condición dejó de ser el entorno hace tiempo. Y un if (entorno.equals("prod")) dentro de un método de negocio es siempre un error: la lógica de negocio no debe saber en qué máquina corre.

  1. Perfiles y pruebas

En las pruebas, el perfil se activa con @ActiveProfiles (06-04):

@SpringBootTest
@ActiveProfiles("test")
class AlquilerFlujoCompletoIT extends PruebaIntegracionBase {
    // ...
}

Tres cosas que conviene tener claras. @ActiveProfiles sustituye, no añade: lo que hubiera en spring.profiles.active se ignora. Para añadir sin sustituir existe @ActiveProfiles(profiles = "extra", inheritProfiles = true) en jerarquías de clases de prueba.

Cada combinación distinta de perfiles crea un contexto distinto. Es la lección de la caché de contextos de 06-04: la clave de caché incluye la lista de perfiles activos, así que una clase con @ActiveProfiles("test") y otra con @ActiveProfiles({"test", "correo-falso"}) arrancan dos contextos completos. Estandarizar los perfiles de la suite en la clase base PruebaIntegracionBase no es cosmética: es la diferencia entre una suite de dos minutos y una de veinte.

Y el perfil test debe existir de verdad. Un application-test.yml con la base de datos de pruebas, Flyway desactivado, logs en INFO y el JWT de un minuto evita que las pruebas hereden por accidente la configuración de dev —incluida la consola H2 y el cargador de datos de demostración, que romperían los asertos sobre las cuatro estaciones—.

  1. Comprobar qué perfil está activo

La primera comprobación es gratis y está en el log de arranque:

The following 4 profiles are active: "prod", "postgres", "correo-real", "cors-estricto"

Cuando no hay ninguno, el mensaje es distinto y conviene reconocerlo, porque es el aviso de que producción va a arrancar con la configuración por defecto:

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

Las otras dos vías son de Actuator (07-01): /actuator/env muestra activeProfiles junto con las fuentes de propiedades cargadas —entre ellas applicationConfig: [classpath:/application-prod.yml], que confirma qué fichero se leyó—, y /actuator/configprops muestra los valores efectivos de RedProperties y compañía. Y desde el código, Environment lo expone directamente:

@Component
public class RegistroDeArranque {

    public RegistroDeArranque(Environment entorno) {
        log.info("Perfiles activos: {}", Arrays.toString(entorno.getActiveProfiles()));
        if (entorno.getActiveProfiles().length == 0) {
            log.warn("Ningún perfil activo: se usará la configuración por defecto");
        }
    }
}

  1. Configuración externa: ficheros, spring.config.import y variables de entorno

Los perfiles resuelven qué configuración se usa; falta de dónde viene. Spring busca application.yml en varias ubicaciones, y las externas ganan a las empaquetadas:

Orden (gana el último) Ubicación
1 classpath:/application.yml (dentro del JAR)
2 classpath:/config/application.yml
3 ./application.yml (junto al JAR)
4 ./config/application.yml

Esa cadena permite desplegar el JAR y dejar a su lado un config/application-prod.yml con los valores del ayuntamiento, sin volver a construir nada. Para importar explícitamente otras fuentes está spring.config.import:

spring:
  config:
    import:
      - optional:file:./config/                    # directorio externo, si existe
      - optional:file:/etc/ciclourbana/secretos.yml
      - optional:configtree:/run/secrets/          # secretos montados como ficheros

El prefijo optional: es lo que evita que la aplicación no arranque cuando el fichero no existe —imprescindible si la misma configuración vale para el portátil y para el servidor—; sin él, un recurso ausente es un error de arranque, que a veces es justo lo que se quiere en producción. El prefijo configtree: lee un directorio donde cada fichero es una propiedad y su contenido el valor, que es exactamente el formato en que Docker y Kubernetes montan los secretos (07-04, 08-04).

En contenedores, sin embargo, la vía preferida son las variables de entorno, por tres razones prácticas: no requieren montar volúmenes, las gestionan de forma nativa todas las plataformas de despliegue, y encajan con el factor III de 12-Factor App, que pide guardar la configuración en el entorno y no en el código. La traducción de nombres es mecánica:

Propiedad de Spring Variable de entorno
spring.profiles.active SPRING_PROFILES_ACTIVE
spring.datasource.url SPRING_DATASOURCE_URL
ciclourbana.red.capacidad-minima CICLOURBANA_RED_CAPACIDADMINIMA
ciclourbana.jwt.secreto CICLOURBANA_JWT_SECRETO

La regla es: mayúsculas, los puntos y los guiones se convierten en guiones bajos. Spring aplica el relaxed binding de 02-05 en sentido inverso, así que CICLOURBANA_RED_CAPACIDADMINIMA y CICLOURBANA_RED_CAPACIDAD_MINIMA funcionan las dos.

  1. Secretos por entorno

De todo lo que cambia entre entornos, los secretos son lo único que no puede vivir en el repositorio. La contraseña de PostgreSQL, el secreto de firma del JWT de 05-04 y la clave de la pasarela de pagos no deben aparecer en ningún application-prod.yml versionado, ni siquiera «temporalmente»: una vez que un secreto entra en el historial de Git, sigue ahí aunque se borre en el commit siguiente, y hay que rotarlo.

El patrón que aplica CicloUrbana:

# application-prod.yml — versionado, SIN valores secretos
spring:
  datasource:
    url: jdbc:postgresql://bd-ribalta:5432/ciclourbana
    username: ciclourbana
    password: ${POSTGRES_PASSWORD}          # sin valor por defecto: obligatoria
ciclourbana:
  jwt:
    secreto: ${JWT_SECRETO}
    expiracion: 15m
  pasarela:
    url: https://pagos.ribalta.example/api/v1
    api-key: ${PASARELA_API_KEY}

Tres decisiones deliberadas. ${POSTGRES_PASSWORD} sin valor por defecto hace que la aplicación falle al arrancar si la variable no está: es exactamente el comportamiento que se quiere, porque un arranque que falla ruidosamente es mejor que uno que arranca con una contraseña de desarrollo. La estructura sí se versiona, para que se vea en la revisión de código qué necesita el entorno. Y el fichero de producción con valores reales no existe: los valores los proporciona el orquestador desde su gestor de secretos (Vault, AWS Secrets Manager, los secrets de Kubernetes).

Un .gitignore con application-local.yml y .env cierra el círculo y da a cada desarrollador un fichero personal para sus valores, cargado con spring.config.import: optional:file:./application-local.yml.

  1. Errores clásicos y cómo detectarlos pronto

El perfil no activado en producción. El arranque dice No active profile set y la aplicación levanta con H2 en memoria, Swagger abierto y Actuator entero expuesto. Peor aún: funciona, y nadie se entera hasta que los datos desaparecen en el siguiente reinicio. La defensa es un ApplicationListener que aborte si no hay perfil, o simplemente exigir en application.yml una propiedad obligatoria que solo definan los ficheros de entorno.

Propiedades duplicadas en el fichero común y en el de perfil. No es un error, es la herramienta funcionando; el problema aparece cuando alguien corrige el valor en application.yml y no entiende por qué producción sigue igual. Regla: si una propiedad está en un fichero de perfil, quítala del común salvo que quieras un valor por defecto explícito.

El valor obligatorio que falta en un entorno. Es el error más caro, porque puede tardar horas en aparecer: la aplicación arranca, atiende mil peticiones y falla la primera vez que alguien intenta pagar, porque ciclourbana.pasarela.api-key estaba vacía. La solución la teníamos ya en 02-05: @ConfigurationProperties validadas.

@ConfigurationProperties(prefix = "ciclourbana.pasarela")
@Validated
public record PasarelaProperties(
        @NotBlank String url,
        @NotBlank String apiKey,
        @NotNull @DurationMin(millis = 200) Duration tiempoEspera) {
}

Con esto, un entorno al que le falte PASARELA_API_KEY no arranca, y el mensaje dice exactamente qué propiedad falta:

Binding to target com.ciclourbana.pasarela.PasarelaProperties failed:
    Property: ciclourbana.pasarela.api-key
    Reason: no debe estar vacío

Un fallo en el arranque, ante el orquestador que aún no ha retirado la versión anterior, es infinitamente preferible a un fallo intermitente en horas de servicio. Es la misma filosofía que ddl-auto: validate en 04-08: que el error aparezca lo antes posible y lo más ruidosamente posible.

Dependencias implícitas entre perfiles. Un bean con @Profile("prod") que necesita otro con @Profile("postgres") funciona mientras alguien active los dos, y estalla el día que no. Los grupos del apartado 6 lo hacen explícito y comprobable.

Errores Comunes y Consejos

Recompilar por entorno. Rompe el principio de la lección: el binario de producción no es el que se probó. Un artefacto, configuración externa.

Confundir perfiles de Maven con perfiles de Spring. Los primeros deciden qué se empaqueta; los segundos, cómo se comporta lo empaquetado.

Copiar el application.yml entero en cada fichero de perfil. Garantiza que un cambio futuro se aplique en tres de los cuatro entornos. Solo las diferencias.

Esperar que las listas se fusionen. Las colecciones se sustituyen enteras. Si prod redefine estaciones-destacadas, la del fichero común desaparece.

Versionar un application-prod.yml con credenciales. Una vez en el historial de Git, el secreto está comprometido aunque se borre después: hay que rotarlo.

Abusar de @Profile("prod") para opciones funcionales. Si la condición no es «dónde estoy» sino «qué está activado», la herramienta correcta es @ConditionalOnProperty.

Cero o dos implementaciones de la misma interfaz. Los perfiles mal combinados producen NoSuchBeanDefinitionException o NoUniqueBeanDefinitionException en el arranque. Cúbrelo con grupos.

Consejo: nombra los perfiles por capacidad, no por entorno (correo-real, datos-demo, postgres) y compón los entornos con spring.profiles.group. El código deja de saber dónde vive.

Consejo: revisa el log de arranque en cada despliegue. La línea The following N profiles are active es la comprobación más barata que existe y detecta el error más caro.

Consejo: valida la configuración con @ConfigurationProperties y @Validated. Convierte un fallo de configuración en un fallo de arranque, que es donde debe estar.

Ejercicios

Ejercicio 1: separar los entornos de CicloUrbana

Partiendo de un único application.yml que hoy tiene H2, ddl-auto: create-drop, Swagger habilitado, logging.level.com.ciclourbana: DEBUG, management.endpoints.web.exposure.include: "*" y ciclourbana.jwt.expiracion: 8h, escribe application.yml, application-dev.yml y application-prod.yml respetando el mapa del apartado 4. Indica qué queda en cada fichero y por qué, y cómo se arranca cada entorno.

Ejercicio 2: perfiles por capacidad y grupos

CicloUrbana necesita dos implementaciones de ServicioNotificaciones (una que escribe en el log, otra que envía SMS por una pasarela real) y dos de ServicioPagos (un simulador que siempre aprueba y el cliente real). Diseña los perfiles por capacidad, los grupos que componen dev, pre y prod, y las anotaciones necesarias, garantizando que nunca haya cero ni dos candidatos. Después explica qué pasaría si alguien arrancase con SPRING_PROFILES_ACTIVE=prod,notificaciones-falsas.

Ejercicio 3: diagnosticar un despliegue que «funciona pero no debería»

CicloUrbana se ha desplegado en el servidor del ayuntamiento y responde correctamente, pero el equipo observa que (a) los alquileres desaparecen cada vez que se reinicia el servicio, (b) https://ciclourbana.ribalta.example/swagger-ui.html es accesible desde Internet, (c) el log crece a un ritmo enorme y (d) /actuator/env devuelve 200 con la contraseña visible. El comando de arranque es java -jar ciclourbana.jar y en el servidor hay un application-prod.yml correcto dentro del JAR. Diagnostica la causa raíz, explica cómo se llega a cada síntoma y propón una corrección que impida que vuelva a ocurrir.

Soluciones

Solución 1

application.yml — solo lo común a todos los entornos:

spring:
  application:
    name: ciclourbana
  jpa:
    open-in-view: false
    properties:
      hibernate.jdbc.batch_size: 25
ciclourbana:
  red:
    ciudad: Ribalta
    capacidad-minima: 8
    umbral-bateria: 20
    duracion-maxima-alquiler: 2h
logging:
  level:
    root: INFO

application-dev.yml — comodidad, sin datos reales que perder:

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:
    secreto: secreto-de-desarrollo-de-32-caracteres-minimo
    expiracion: 8h

application-prod.yml — mínimo, cerrado y sin secretos:

spring:
  datasource:
    url: jdbc:postgresql://bd-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:
    secreto: ${JWT_SECRETO}
    expiracion: 15m

Qué va dónde y por qué. En el común queda lo que no debe variar: el nombre de la aplicación, open-in-view: false —una decisión de arquitectura de 04-02, no de entorno— y las reglas de negocio de la red de Ribalta, que deben ser idénticas en todas partes para que lo probado sea lo desplegado. En los ficheros de perfil quedan las cuatro familias que sí cambian: origen de datos, verbosidad, superficie expuesta y política de seguridad.

Nótese que ddl-auto, swagger-ui.enabled y exposure.include aparecen en ambos ficheros de perfil y en ninguno el común. Es deliberado: para propiedades peligrosas, un valor por defecto en application.yml significa que un perfil que se olvide de declararlas hereda el valor cómodo. Al no existir por defecto, cada entorno se ve obligado a decidir.

Arranque de cada entorno:

./mvnw spring-boot:run                                   # dev, si es el activo por defecto
java -jar target/ciclourbana.jar --spring.profiles.active=dev
SPRING_PROFILES_ACTIVE=prod POSTGRES_PASSWORD=... JWT_SECRETO=... java -jar ciclourbana.jar

Solución 2

Perfiles por capacidad, cuatro en dos parejas mutuamente excluyentes: notificaciones-log / notificaciones-sms y pagos-simulados / pagos-reales.

@Service
@Profile("notificaciones-sms")
public class ServicioNotificacionesSms implements ServicioNotificaciones { /* ... */ }

@Service
@Profile("!notificaciones-sms")                 // red de seguridad: es el que queda si no hay otro
public class ServicioNotificacionesRegistradas implements ServicioNotificaciones { /* ... */ }

@Service
@Profile("pagos-reales")
public class ClientePasarelaPagos implements ServicioPagos { /* ... */ }

@Service
@Profile("!pagos-reales")
public class SimuladorPagos implements ServicioPagos { /* ... */ }
spring:
  profiles:
    group:
      dev:  notificaciones-log, pagos-simulados, datos-demo
      pre:  notificaciones-sms, pagos-simulados, cors-estricto
      prod: notificaciones-sms, pagos-reales,   cors-estricto

La clave del diseño está en la negación. Usar @Profile("notificaciones-log") en la implementación de reserva permitiría que un entorno se quedara sin ninguna implementación si olvida activarla; con @Profile("!notificaciones-sms") siempre hay exactamente una: la real cuando el perfil está activo, la de registro en cualquier otro caso. Los perfiles positivos (notificaciones-log, pagos-simulados) siguen apareciendo en los grupos porque documentan la intención, aunque técnicamente ya no sean necesarios.

La alternativa igualmente válida es dejar los dos perfiles positivos y marcar uno de los beans con @ConditionalOnMissingBean(ServicioNotificaciones.class) (02-06). El efecto es el mismo; la ventaja de la negación es que no depende del orden de registro de las autoconfiguraciones.

Qué pasa con SPRING_PROFILES_ACTIVE=prod,notificaciones-falsas. El perfil notificaciones-falsas no existe en ningún sitio, así que se activa y no hace absolutamente nada —Spring no valida que un perfil corresponda a algo—. El grupo prod sigue expandiéndose, notificaciones-sms queda activo y se sigue enviando SMS reales. Este es precisamente el peligro de los perfiles: un nombre mal escrito no produce ningún error, solo un comportamiento distinto del esperado. La defensa es doble: un @PostConstruct que compruebe qué implementación quedó registrada y lo escriba en el log (log.info("Notificaciones: {}", servicio.getClass().getSimpleName())), y una prueba de integración por entorno que verifique el tipo del bean con @ActiveProfiles("prod").

Solución 3

La causa raíz es una sola: el perfil prod nunca se activó. El comando java -jar ciclourbana.jar no pasa --spring.profiles.active, no hay SPRING_PROFILES_ACTIVE en el entorno del servicio y application.yml no declara spring.profiles.active. El log de arranque contiene la prueba: No active profile set, falling back to 1 default profile: "default".

De ahí salen los cuatro síntomas, todos por el mismo mecanismo —application-prod.yml existe dentro del JAR pero nunca se lee, así que rige solo application.yml, que es el de desarrollo—:

Síntoma Propiedad que quedó vigente Por qué se manifiesta así
(a) Los alquileres desaparecen al reiniciar spring.datasource.url de H2 en memoria La aplicación funciona porque H2 crea el esquema con ddl-auto: create-drop; los datos viven en RAM y mueren con el proceso
(b) Swagger accesible desde Internet springdoc.swagger-ui.enabled: true Expone el inventario completo de la API y sus modelos a cualquiera
(c) El log crece sin control logging.level.com.ciclourbana: DEBUG y org.hibernate.SQL: DEBUG Cada consulta se escribe entera; con tráfico real, gigabytes al día
(d) /actuator/env devuelve 200 con la contraseña exposure.include: "*" y show-values: always Es el escenario del ejercicio 3 de la lección anterior, en su versión accidental

Y hay un quinto síntoma que todavía no se ha manifestado y es el más grave: la base de datos PostgreSQL del ayuntamiento está intacta y vacía de tráfico, porque nadie ha escrito en ella desde el despliegue. Los alquileres de los ciudadanos de estos días están perdidos.

La corrección inmediata es arrancar con el perfil, preferiblemente por variable de entorno en la unidad de servicio o en el contenedor:

SPRING_PROFILES_ACTIVE=prod java -jar ciclourbana.jar

La corrección estructural, que es lo que pide el enunciado, tiene tres capas.

Primero, que la aplicación se niegue a arrancar sin perfil, porque depender de que alguien recuerde una variable es depender de la memoria:

@Component
public class ValidadorDePerfil {

    public ValidadorDePerfil(Environment entorno) {
        if (entorno.getActiveProfiles().length == 0) {
            throw new IllegalStateException(
                    "Ningún perfil activo. Arranque con SPRING_PROFILES_ACTIVE=dev|test|pre|prod");
        }
    }
}

Segundo, que los valores peligrosos no tengan un valor por defecto cómodo. Si spring.datasource.url no está en application.yml, la aplicación sin perfil no arranca con H2: no arranca en absoluto. Lo mismo con el secreto del JWT declarado como ${JWT_SECRETO} sin valor por defecto, y con PasarelaProperties validada del apartado 13. La idea de fondo: quitar de en medio la posibilidad de que un descuido produzca un sistema que funciona a medias, porque ese es mucho peor que uno que no funciona.

Tercero, que el despliegue se verifique. Una comprobación posterior al despliegue que consulte /actuator/info y compruebe la versión, y otra que consulte /actuator/env esperando 401 o 404, habrían detectado los cuatro síntomas en el primer minuto. En 08-05 esto se convierte en un paso automático de la entrega continua.

Conclusión

CicloUrbana ya distingue dónde vive. Has interiorizado el principio que gobierna esta lección —construir una vez, desplegar en muchos sitios— y sabes por qué recompilar por entorno significa desplegar un binario que nadie probó. Conoces las seis formas de activar un perfil y su precedencia, con SPRING_PROFILES_ACTIVE como la forma estándar en contenedores, y las dos propiedades menos conocidas, spring.profiles.default y spring.profiles.include. Sabes cómo se combinan application.yml y los ficheros de perfil, que la fusión es por clave, que las listas se sustituyen enteras y que con varios perfiles activos gana el último. Tienes el mapa completo de configuración de CicloUrbana por entorno —base de datos, ddl-auto, Flyway, logs, Swagger, CORS, caducidad del JWT, exposición de Actuator, datos de demostración— como plantilla para cualquier proyecto.

Dominas los documentos multi-perfil con --- y spring.config.activate.on-profile, y sus dos límites, que son los que aconsejan preferir ficheros separados. Sabes componer entornos con spring.profiles.group a partir de perfiles que describen capacidades y no lugares, y condicionar beans y clases de configuración con @Profile y sus expresiones: CargadorDatosDemo idempotente en datos-demo, las dos caras de ServicioCorreo y ConfiguracionCorsProduccion. Y, sobre todo, sabes cuándo @Profile es la herramienta equivocada: cuando la condición no es dónde estás sino qué está activado, la respuesta es una propiedad y @ConditionalOnProperty, con la tabla de criterios para decidirlo sin discutir.

En las pruebas, @ActiveProfiles sustituye en lugar de añadir y cada combinación abre un contexto nuevo, así que estandarizarlos en PruebaIntegracionBase es lo que mantiene la suite en minutos. Sabes comprobar el perfil activo en el log de arranque, en /actuator/env y desde Environment, inyectar configuración desde fuera del artefacto con la cadena de ubicaciones, spring.config.import y el prefijo optional:, y por qué en contenedores mandan las variables de entorno. Y tienes una política de secretos: la estructura se versiona, los valores nunca; ${VARIABLE} sin valor por defecto para que la ausencia sea un fallo de arranque; y @ConfigurationProperties validadas que convierten un despiste de configuración en un error inmediato y explícito, en lugar de una llamada fallida a la pasarela tres horas después.

Con Actuator y los perfiles, CicloUrbana es observable y sabe adaptarse a su entorno. Pero sigue siendo una aplicación puramente reactiva: solo hace algo cuando alguien le pide algo. Nadie cierra de madrugada los alquileres que un ciudadano olvidó finalizar, nadie recalcula la ocupación de las cuatro estaciones de Ribalta cada minuto para la aplicación móvil, y el correo de confirmación se sigue enviando en el mismo hilo que atiende la petición, obligando al ciudadano a esperar a que el servidor SMTP conteste. La siguiente lección, Tareas Programadas y Ejecución Asíncrona, le da a CicloUrbana iniciativa propia y la capacidad de hacer cosas en segundo plano, con todas las trampas que eso trae: el planificador de un solo hilo, la tarea que se ejecuta N veces al escalar, la transacción que no viaja al otro hilo y el contexto de seguridad que se queda atrás.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados