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
- Construir una vez, desplegar en muchos sitios
- Qué es un perfil y cómo se activa
- Ficheros por perfil: qué gana cuando hay conflicto
- El mapa de configuración de CicloUrbana
- Documentos multi-perfil en un solo YAML
- Grupos de perfiles
@Profileen beans y clases de configuración- Cuándo
@Profilees un olor de diseño - Perfiles y pruebas
- Comprobar qué perfil está activo
- Configuración externa: ficheros,
spring.config.importy variables de entorno - Secretos por entorno
- Errores clásicos y cómo detectarlos pronto
- Errores Comunes y Consejos
- Ejercicios
- 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.
- 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.defaultcambia el perfil implícito cuando no se activa ninguno. Sin ella valedefault, lo que significa que un@Profile("default")se aplica solo si nadie activó nada.spring.profiles.includeañ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.
- 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: 4hEn 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:
- La fusión es por clave, no por documento. Definir
ciclourbana.reden el fichero de perfil no borra las claves que no menciona. - Las listas y los mapas NO se fusionan, se sustituyen enteros. Si
application.ymldeclaraestaciones-destacadas: [Plaza Mayor, Universidad]yapplication-prod.ymldeclaraestaciones-destacadas: [Estación Norte], en producción la lista tiene un elemento. Es la causa de errores desconcertantes con propiedades de colección. - Con varios perfiles activos, gana el último de la lista. Con
active=prod,ue,application-ue.ymlsobrescribe aapplication-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.
- 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.
- 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.activeno 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 lanzaInvalidConfigDataPropertyExceptional 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
preyprod.
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.
- 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-demoCon 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.
@Profile en beans y clases de configuración
@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.
- Cuándo
@Profile es un olor de diseño
@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.
- 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—.
- Comprobar qué perfil está activo
La primera comprobación es gratis y está en el log de arranque:
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:
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");
}
}
}
- Configuración externa: ficheros,
spring.config.import y variables de entorno
spring.config.import y variables de entornoLos 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 ficherosEl 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.
- 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.
- 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íoUn 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: INFOapplication-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: 8happlication-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: 15mQué 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.jarSolució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-estrictoLa 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:
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
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
