CicloUrbana está llena de números escritos a fuego: 0.50 de desbloqueo, 0.12 por minuto, 15 minutos gratuitos para estudiantes, 8 anclajes mínimos por estación. Cambiar cualquiera de ellos exige hoy recompilar y volver a desplegar. Eso no es aceptable: el ayuntamiento de Ribalta cambia sus tarifas cada temporada y el puerto del servidor no es el mismo en tu portátil que en el servidor de producción. La solución es externalizar la configuración, y Spring Boot tiene para eso uno de los mecanismos más completos —y más incomprendidos— del ecosistema Java. En esta lección veremos qué es el Environment y de dónde saca sus valores, el orden exacto de precedencia entre las fuentes, las diferencias reales entre .properties y .yaml, cómo Spring relaja los nombres de las propiedades para que CICLOURBANA_TARIFA_BASE y ciclourbana.tarifa-base sean lo mismo, cómo leer valores con @Value, y por qué una credencial no puede estar nunca en el repositorio.

Contenido

  1. El Environment y los PropertySource
  2. El orden de precedencia de las fuentes
  3. Demostrar la precedencia en la práctica
  4. .properties frente a .yaml
  5. Relajación de nombres (relaxed binding)
  6. Leer propiedades con @Value
  7. SpEL y valores por defecto
  8. Las propiedades que usa CicloUrbana
  9. Configuración externa: spring.config.import y spring.config.location
  10. Secretos y credenciales
  11. Errores Comunes y Consejos
  12. Ejercicios

  1. El Environment y los PropertySource

En la lección 01-05 vimos que una de las primeras fases del arranque es "preparar el Environment". Ahora podemos concretar qué significa.

El Environment es un bean de Spring que responde a dos preguntas: ¿qué valor tiene esta propiedad? y ¿qué perfiles están activos? (los perfiles se estudian en la lección 07-02). Internamente no guarda ningún valor: mantiene una lista ordenada de PropertySource y, cuando le preguntas por una clave, los recorre en orden y devuelve el primero que la tenga.

flowchart TD
    E["Environment.getProperty('server.port')"] --> PS["MutablePropertySources<br/>(lista ORDENADA)"]
    PS --> P1["1. commandLineArgs<br/>--server.port=9090"]
    P1 -->|no la tiene| P2["2. systemEnvironment<br/>SERVER_PORT"]
    P2 -->|no la tiene| P3["3. systemProperties<br/>-Dserver.port"]
    P3 -->|no la tiene| P4["4. applicationConfig:<br/>application.properties"]
    P4 -->|la tiene: 8080| R["Devuelve 8080<br/>y deja de buscar"]

La consecuencia es fundamental: el primero que responde gana. Toda la lógica de precedencia de Spring Boot se reduce a en qué orden se colocan los PropertySource en esa lista.

Puedes inspeccionar la lista completa en tu propia aplicación:

package com.ciclourbana.comun;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.stereotype.Component;

/**
 * Herramienta de diagnóstico: vuelca las fuentes de propiedades
 * en el orden real de precedencia y resuelve algunas claves clave.
 */
@Component
public class InspectorConfiguracion implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(InspectorConfiguracion.class);

    private final ConfigurableEnvironment entorno;

    public InspectorConfiguracion(ConfigurableEnvironment entorno) {
        this.entorno = entorno;
    }

    @Override
    public void run(String... args) {
        log.info("=== Fuentes de propiedades, de mayor a menor prioridad ===");
        int posicion = 1;
        for (var fuente : entorno.getPropertySources()) {
            log.info("  {}. {}", posicion++, fuente.getName());
        }

        log.info("server.port resuelto a: {}", entorno.getProperty("server.port"));
        log.info("spring.application.name resuelto a: {}",
                entorno.getProperty("spring.application.name"));
    }
}

Salida típica en CicloUrbana:

=== Fuentes de propiedades, de mayor a menor prioridad ===
  1. configurationProperties
  2. commandLineArgs
  3. servletConfigInitParams
  4. servletContextInitParams
  5. systemProperties
  6. systemEnvironment
  7. random
  8. Config resource 'class path resource [application.properties]'
server.port resuelto a: 8080
spring.application.name resuelto a: ciclourbana

Este InspectorConfiguracion es una herramienta que merece la pena tener a mano: cuando una propiedad "no se lee", lo primero es ver qué fuente la está ganando.

  1. El orden de precedencia de las fuentes

Spring Boot define un orden completo y documentado. De mayor a menor prioridad, y quedándonos con lo que importa en la práctica:

# Fuente Ejemplo Uso habitual
1 Propiedades de DevTools (~/.config/spring-boot) — Desarrollo local
2 @TestPropertySource y properties de @SpringBootTest @SpringBootTest(properties = "server.port=0") Pruebas (módulo 6)
3 Argumentos de línea de comandos --server.port=9090 Arranque puntual, contenedores
4 SPRING_APPLICATION_JSON SPRING_APPLICATION_JSON='{"server":{"port":9090}}' Plataformas cloud
5 Parámetros del ServletContext/ServletConfig — Despliegue en WAR
6 Atributos JNDI — Servidores de aplicaciones clásicos
7 Propiedades de sistema Java -Dserver.port=9090 Scripts de arranque
8 Variables de entorno SERVER_PORT=9090 Docker, Kubernetes, CI
9 application-{perfil}.properties fuera del jar ./config/application-prod.properties Configuración por entorno
10 application-{perfil}.properties dentro del jar application-dev.properties Perfiles (lección 07-02)
11 application.properties fuera del jar ./config/application.properties Ajustes del operador
12 application.properties dentro del jar src/main/resources/application.properties Valores por defecto del proyecto
13 @PropertySource en clases @Configuration @PropertySource("classpath:tarifas.properties") Ficheros adicionales
14 Valores por defecto (SpringApplication.setDefaultProperties) — Último recurso

Tres reglas que resumen la tabla y que conviene memorizar:

  1. Lo más externo gana. Cuanto más cerca del momento de arranque se especifica un valor, más prioridad tiene. Un --server.port=9090 en la línea de comandos vence a cualquier fichero.
  2. Fuera del jar gana a dentro del jar. Es lo que permite empaquetar valores por defecto razonables y que el operador los ajuste sin reconstruir nada.
  3. Con perfil gana a sin perfil. application-prod.properties sobrescribe application.properties.

Las tres filas que usarás el 95 % del tiempo son la 3 (argumentos), la 8 (variables de entorno) y la 12 (application.properties del proyecto). El resto conviene conocerlo para no sorprenderse.

  1. Demostrar la precedencia en la práctica

Nada convence como verlo funcionar. Empecemos con el fichero del proyecto:

# src/main/resources/application.properties
spring.application.name=ciclourbana
server.port=8080
ciclourbana.ciudad=Ribalta

Arranca normalmente:

./mvnw spring-boot:run
Tomcat started on port 8080 (http) with context path '/'

Ahora sobrescribe con una variable de entorno (fila 8):

SERVER_PORT=8081 java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
Tomcat started on port 8081 (http) with context path '/'

Ahora con una propiedad de sistema (fila 7, que gana a la variable de entorno):

SERVER_PORT=8081 java -Dserver.port=8082 -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
Tomcat started on port 8082 (http) with context path '/'

Y finalmente con un argumento de línea de comandos (fila 3, el que gana a todos):

SERVER_PORT=8081 java -Dserver.port=8082 -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --server.port=8083
Tomcat started on port 8083 (http) with context path '/'

Fíjate en la diferencia sintáctica, que confunde a mucha gente:

Sintaxis Qué es Posición
-Dclave=valor Propiedad de sistema de la JVM Antes de -jar
--clave=valor Argumento de la aplicación Después del jar
CLAVE=valor (delante del comando) Variable de entorno Antes de todo

Poner --server.port=9090 antes de -jar no funciona: la JVM lo interpretaría como una opción suya y fallaría. Y -Dserver.port después del jar llega como un argumento más de la aplicación, que Spring no reconoce como propiedad.

Con Maven, para pasar argumentos hay que usar la propiedad del plugin:

./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8083

  1. .properties frente a .yaml

Spring Boot admite ambos formatos, con las mismas capacidades. La elección es de estilo... hasta que la estructura crece.

La misma configuración de CicloUrbana en los dos formatos:

# src/main/resources/application.properties
spring.application.name=ciclourbana
server.port=8080
server.servlet.context-path=/

ciclourbana.ciudad=Ribalta
ciclourbana.tarifa.desbloqueo=0.50
ciclourbana.tarifa.precio-minuto=0.12
ciclourbana.red.capacidad-minima=8
ciclourbana.red.umbral-bateria=20

ciclourbana.estaciones-destacadas[0]=Plaza Mayor
ciclourbana.estaciones-destacadas[1]=Universidad

ciclourbana.tarifas-por-usuario.estandar=0.12
ciclourbana.tarifas-por-usuario.estudiante=0.08
ciclourbana.tarifas-por-usuario.jubilado=0.05

logging.level.com.ciclourbana=DEBUG
logging.level.org.springframework.web=INFO
# src/main/resources/application.yaml
spring:
  application:
    name: ciclourbana

server:
  port: 8080
  servlet:
    context-path: /

ciclourbana:
  ciudad: Ribalta
  tarifa:
    desbloqueo: 0.50
    precio-minuto: 0.12
  red:
    capacidad-minima: 8
    umbral-bateria: 20
  estaciones-destacadas:
    - Plaza Mayor
    - Universidad
  tarifas-por-usuario:
    estandar: 0.12
    estudiante: 0.08
    jubilado: 0.05

logging:
  level:
    com.ciclourbana: DEBUG
    org.springframework.web: INFO

Comparados:

Criterio .properties .yaml
Jerarquía Repetitiva: cada línea completa Anidada, sin repetición
Listas clave[0], clave[1]... - elemento (natural)
Mapas clave.subclave=valor Anidado (natural)
Sensible a la indentación No Sí, y es su gran problema
Tabuladores Irrelevantes Prohibidos: rompen el fichero
Comentarios # #
Búsqueda de una clave con grep Trivial: la clave completa está en la línea Difícil: la clave está repartida
Varios documentos en un fichero No (se usa #---) Sí, con ---
Valores multilínea Con \ al final Sí, con | y >
Precedencia si existen ambos Gana .properties —

Recomendación práctica: elige uno y sé coherente. Si tu configuración es plana y corta, .properties es más difícil de romper. En cuanto aparecen listas, mapas y tres niveles de anidamiento —el caso de CicloUrbana en cuanto lleguemos a la lección 02-05—, YAML es claramente más legible. En este curso usaremos YAML a partir de aquí.

Nunca tengas los dos ficheros a la vez: application.properties gana, y pasarás una tarde entera preguntándote por qué tu YAML no se lee.

Los errores de indentación de YAML

Son el peaje del formato, y todos son silenciosos: el fichero se lee, pero las propiedades quedan en otro sitio.

# MAL: 'port' cuelga de la raíz, no de 'server'
server:
port: 8080

# MAL: tabulador en lugar de espacios (aquí no se ve, pero rompe el arranque)
server:
	port: 8080

# MAL: 'context-path' con menos indentación de la que necesita
server:
  servlet:
   context-path: /api      # 3 espacios donde el bloque usa 2 o 4: inconsistente

# BIEN
server:
  port: 8080
  servlet:
    context-path: /

El primer caso produce un error de arranque claro (server no admite un valor escalar), pero variantes más sutiles simplemente dejan la propiedad huérfana y la aplicación arranca con el valor por defecto. Regla: dos espacios por nivel, nunca tabuladores, y activa en tu IDE la visualización de caracteres invisibles.

  1. Relajación de nombres (relaxed binding)

Spring Boot no exige que el nombre de la propiedad se escriba exactamente igual en todas partes. Aplica un algoritmo de relajación que considera equivalentes varias formas:

Forma Ejemplo Dónde se usa
kebab-case ciclourbana.tarifa-base Recomendada en ficheros
camelCase ciclourbana.tarifaBase Admitida en ficheros
snake_case ciclourbana.tarifa_base Admitida
MAYÚSCULAS con guion bajo CICLOURBANA_TARIFABASE Variables de entorno

Las cuatro se resuelven a la misma propiedad. Esto es lo que permite que en un docker-compose.yml o en un Deployment de Kubernetes escribas:

environment:
  CICLOURBANA_TARIFA_DESBLOQUEO: "0.60"
  CICLOURBANA_RED_CAPACIDADMINIMA: "10"
  SERVER_PORT: "8080"

y esos valores lleguen a ciclourbana.tarifa.desbloqueo, ciclourbana.red.capacidadMinima y server.port.

Las reglas para traducir una propiedad a variable de entorno son tres:

  1. Los puntos (.) se convierten en guiones bajos (_).
  2. Los guiones (-) se eliminan.
  3. Todo en mayúsculas.

Así, ciclourbana.red.capacidad-minima → CICLOURBANA_RED_CAPACIDADMINIMA. Ese segundo punto es el que más quebraderos de cabeza da: mucha gente escribe CICLOURBANA_RED_CAPACIDAD_MINIMA y no funciona, porque ese nombre correspondería a ciclourbana.red.capacidad.minima, con un punto de más.

Dos limitaciones importantes de la relajación:

  • Solo se aplica al enlazado de @ConfigurationProperties (lección 02-05) y a las propiedades del propio Spring Boot. Con @Value la coincidencia es exacta: @Value("${ciclourbana.tarifa-base}") no encuentra una propiedad escrita ciclourbana.tarifaBase.
  • Las claves de un Map no se relajan: si defines ciclourbana.tarifas-por-usuario.estudiante-becado, la clave del mapa será literalmente estudiante-becado.

Convención recomendada: escribe siempre en kebab-case en los ficheros. Es la forma canónica, la que aparece en la documentación de Spring Boot y la que evita ambigüedades.

  1. Leer propiedades con @Value

@Value inyecta el valor de una propiedad en un campo o parámetro. Vamos a sacar por fin de las constantes los precios de TarifaEstandar:

package com.ciclourbana.alquileres;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

@Component
@Primary
public class TarifaEstandar implements CalculadoraTarifa {

    private final BigDecimal desbloqueo;
    private final BigDecimal porMinuto;

    // @Value en parámetros del constructor: mantiene los campos final
    public TarifaEstandar(
            @Value("${ciclourbana.tarifa.desbloqueo}") BigDecimal desbloqueo,
            @Value("${ciclourbana.tarifa.precio-minuto}") BigDecimal porMinuto) {
        this.desbloqueo = desbloqueo;
        this.porMinuto = porMinuto;
    }

    @Override
    public BigDecimal calcular(Duration duracion) {
        BigDecimal minutos = BigDecimal.valueOf(Math.max(1, duracion.toMinutes()));
        return desbloqueo
                .add(porMinuto.multiply(minutos))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String nombre() {
        return "estandar";
    }
}

Observa que @Value va en los parámetros del constructor, no en los campos. Así los campos siguen siendo final y la clase sigue siendo construible en un test con new TarifaEstandar(new BigDecimal("0.50"), new BigDecimal("0.12")), sin Spring de por medio. Es la misma lógica de la lección 02-02 aplicada a la configuración.

Spring convierte automáticamente el texto al tipo del parámetro:

@Value("${server.port}")                    int puerto;              // 8080
@Value("${ciclourbana.ciudad}")             String ciudad;           // "Ribalta"
@Value("${ciclourbana.tarifa.desbloqueo}")  BigDecimal desbloqueo;   // 0.50
@Value("${ciclourbana.red.mantenimiento}")  boolean enMantenimiento; // true/false
@Value("${ciclourbana.estaciones-destacadas}") List<String> destacadas;  // separadas por comas
@Value("${ciclourbana.sesion.duracion}")    Duration duracion;       // "30m" -> PT30M

Para que List<String> funcione con @Value, el valor debe ser una cadena separada por comas, no una lista YAML:

ciclourbana:
  estaciones-destacadas: Plaza Mayor,Universidad     # sirve para @Value
ciclourbana:
  estaciones-destacadas:                             # NO sirve para @Value
    - Plaza Mayor                                    # sí sirve para @ConfigurationProperties
    - Universidad

Esta es ya la primera grieta de @Value, y no será la última.

  1. SpEL y valores por defecto

Valores por defecto

Si una propiedad no existe, @Value falla en el arranque:

Could not resolve placeholder 'ciclourbana.tarifa.desbloqueo' in value
"${ciclourbana.tarifa.desbloqueo}"

Se evita con la sintaxis ${clave:valorPorDefecto}:

@Value("${ciclourbana.tarifa.desbloqueo:0.50}")   BigDecimal desbloqueo;   // 0.50 si falta
@Value("${ciclourbana.red.umbral-bateria:20}")    int umbralBateria;
@Value("${ciclourbana.mensaje-mantenimiento:}")   String mensaje;          // cadena vacía
@Value("${ciclourbana.contacto:#{null}}")         String contacto;         // null explícito

Cuidado con un caso especial: si el valor por defecto contiene : (una URL, por ejemplo), hay que tener presente que solo cuenta el primer : como separador, así que @Value("${url:http://localhost:8080}") funciona correctamente y el valor por defecto es la URL completa.

SpEL: el lenguaje de expresiones de Spring

@Value admite también expresiones SpEL con la sintaxis #{...} (almohadilla, no dólar):

// Aritmética sobre una propiedad
@Value("#{${ciclourbana.tarifa.precio-minuto} * 60}")
BigDecimal precioHora;

// Llamar a un método de otro bean
@Value("#{selectorTarifa.disponibles().size()}")
int numeroDeTarifas;

// Leer del Environment con lógica
@Value("#{environment['ciclourbana.ciudad'] ?: 'desconocida'}")
String ciudad;

// Convertir una cadena separada por comas en lista (útil de verdad)
@Value("#{'${ciclourbana.estaciones-destacadas}'.split(',')}")
List<String> destacadas;

// Propiedades del sistema
@Value("#{systemProperties['user.timezone']}")
String zonaHoraria;

Y una que sí es realmente práctica: valores aleatorios, para puertos o identificadores en pruebas.

@Value("${random.int(1000,9999)}")   int codigoSesion;
@Value("${random.uuid}")             String identificadorArranque;
Sintaxis Nombre Qué hace
${...} Marcador de propiedad Sustituye por el valor de la propiedad
#{...} Expresión SpEL Evalúa una expresión (puede contener ${...} dentro)

Las limitaciones de @Value

@Value es cómodo para uno o dos valores sueltos, pero se queda corto en cuanto la configuración crece. Sus problemas:

Limitación Consecuencia
Sin relajación de nombres La clave debe escribirse exactamente igual.
Sin validación Un valor absurdo (-5 de capacidad) se acepta sin protestar.
Sin estructuras No enlaza listas YAML ni mapas de forma natural.
Sin metadatos El IDE no autocompleta ni documenta las propiedades.
Errores dispersos Si faltan cinco propiedades, el arranque falla por la primera, una a una.
Configuración desperdigada Las claves aparecen en veinte clases: nadie sabe qué configura la aplicación.
Difícil de agrupar y reutilizar No hay un objeto que represente "la configuración de tarifas".

Todas ellas las resuelve @ConfigurationProperties, que es el tema de la lección siguiente. La regla que aplicaremos en CicloUrbana: @Value para valores aislados y ocasionales; @ConfigurationProperties para todo lo demás.

  1. Las propiedades que usa CicloUrbana

Esta es la configuración base del proyecto, con explicación de cada bloque:

# src/main/resources/application.yaml

spring:
  application:
    name: ciclourbana          # aparece en los logs, en Actuator y en trazas (módulo 9)

server:
  port: 8080                   # 0 = puerto aleatorio libre (muy útil en pruebas)
  servlet:
    context-path: /            # prefijo de TODAS las rutas
  shutdown: graceful           # apagado ordenado, visto en la lección 01-05

logging:
  level:
    root: INFO
    com.ciclourbana: DEBUG                    # nuestro código, con detalle
    org.springframework.web: INFO
    org.springframework.beans.factory: INFO   # a DEBUG para depurar el ciclo de vida
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%logger{20}] - %msg%n"

ciclourbana:
  ciudad: Ribalta
  tarifa:
    desbloqueo: 0.50
    precio-minuto: 0.12
  red:
    capacidad-minima: 8
    umbral-bateria: 20

Las propiedades del servidor y de la aplicación más útiles:

Propiedad Valor típico Para qué sirve
server.port 8080, 0 Puerto HTTP. 0 asigna uno libre.
server.servlet.context-path /, /ciclourbana Prefijo de todas las rutas.
server.shutdown graceful Espera a que terminen las peticiones en curso.
server.error.include-message always Incluye el mensaje de error en la respuesta (módulo 3).
server.compression.enabled true Comprime las respuestas grandes.
spring.application.name ciclourbana Identifica la app en logs, métricas y trazas.
spring.main.banner-mode off, console Controla el banner (lección 01-05).
spring.main.web-application-type servlet, none Fuerza el tipo de aplicación.
logging.level.<paquete> DEBUG Nivel de log por paquete. Se estudia a fondo en 09-05.
logging.file.name logs/ciclourbana.log Escribe el log también a fichero.

Un aviso sobre server.servlet.context-path: si lo pones a /ciclourbana, la URL de nuestro endpoint pasa a ser http://localhost:8080/ciclourbana/api/v1/estaciones. Es un cambio que rompe todos los clientes y todos los ficheros .http de la lección 01-02, así que en CicloUrbana lo dejaremos en /.

Verifica que la configuración se aplica:

./mvnw spring-boot:run
curl -s http://localhost:8080/api/v1/estaciones | head -3

  1. Configuración externa: spring.config.import y spring.config.location

Todo lo anterior vive dentro del jar. En un despliegue real hace falta configuración que no se empaquete.

Ficheros externos por convención

Spring Boot busca application.yaml automáticamente, y por este orden de prioridad, en:

  1. ./config/ (subdirectorio config junto al jar)
  2. ./ (directorio actual)
  3. classpath:/config/
  4. classpath:/ (dentro del jar)

Es decir, basta con dejar un fichero junto al jar para sobrescribir los valores empaquetados:

target/
├── ciclourbana-0.0.1-SNAPSHOT.jar
└── config/
    └── application.yaml          # sobrescribe lo que traiga el jar
# target/config/application.yaml — configuración del operador
server:
  port: 9090
ciclourbana:
  tarifa:
    desbloqueo: 0.60      # el ayuntamiento subió el desbloqueo
cd target && java -jar ciclourbana-0.0.1-SNAPSHOT.jar
Tomcat started on port 9090 (http) with context path '/'

spring.config.import

Permite incluir otros ficheros desde la configuración principal. Es la forma moderna y preferida frente a @PropertySource:

# src/main/resources/application.yaml
spring:
  config:
    import:
      - optional:file:./config/tarifas-ribalta.yaml   # opcional: no falla si no existe
      - optional:file:/etc/ciclourbana/secretos.yaml  # secretos del servidor

El prefijo optional: es clave: sin él, si el fichero no existe el arranque falla. Esto tiene su lógica —quieres saber si falta un fichero imprescindible— pero en desarrollo es incómodo, y por eso los ficheros propios de un entorno concreto se marcan casi siempre como opcionales.

spring.config.import admite también otros orígenes:

spring:
  config:
    import:
      - optional:file:./config/                       # un directorio entero
      - optional:configtree:/run/secrets/             # secretos de Docker/Kubernetes
      - optional:classpath:tarifas-por-defecto.yaml   # otro fichero del jar

El formato configtree: merece una nota: en Kubernetes y en Docker Swarm los secretos se montan como ficheros, uno por clave, dentro de un directorio. Con configtree: Spring Boot lee ese árbol y convierte cada fichero en una propiedad. Es el mecanismo idiomático para secretos en contenedores, y lo retomaremos en la lección 07-04.

spring.config.location

Sustituye por completo las ubicaciones por defecto, en lugar de añadirse a ellas:

java -jar ciclourbana.jar --spring.config.location=file:/etc/ciclourbana/produccion.yaml

Y spring.config.additional-location añade ubicaciones conservando las por defecto, que suele ser lo que realmente quieres:

java -jar ciclourbana.jar \
  --spring.config.additional-location=file:/etc/ciclourbana/
Opción Efecto
spring.config.location Reemplaza las ubicaciones por defecto. Control total, riesgo de perder valores.
spring.config.additional-location Añade ubicaciones con más prioridad que las por defecto. Más seguro.
spring.config.import Incluye ficheros concretos desde la propia configuración. Declarativo.

  1. Secretos y credenciales

Este apartado no es opcional. Es la parte de la lección con consecuencias más graves si se ignora.

Nunca, bajo ninguna circunstancia, escribas contraseñas, claves de API, tokens o certificados en un fichero que vaya al repositorio de código.

Lo que no debes hacer, aunque lo veas en tutoriales:

# MAL: esto acabará en Git y en el historial para siempre
spring:
  datasource:
    url: jdbc:postgresql://bd.ribalta.example:5432/ciclourbana
    username: ciclourbana_app
    password: Sup3rS3cr3t0!          # ← catástrofe
ciclourbana:
  pasarela-pago:
    api-key: sk_live_9f3a2b1c8d7e    # ← catástrofe

Por qué es tan grave, más allá de lo evidente:

  • Git no olvida. Borrar la línea en un commit posterior no elimina el secreto: sigue en el historial, accesible con git log -p. Rotar la credencial es obligatorio, no opcional.
  • El repositorio se copia. Forks, clones en portátiles, copias de seguridad, integraciones de CI, el ordenador de un becario. Un secreto en Git está, en la práctica, en muchos más sitios de los que crees.
  • Los repositorios cambian de visibilidad. Un repositorio privado que se hace público filtra todo su historial de golpe. Es un accidente sorprendentemente frecuente.
  • Los robots rastrean. Existen bots que escanean GitHub buscando patrones de claves; una clave de un proveedor cloud publicada por error se explota en minutos.

Qué hacer en su lugar:

1. Variables de entorno con marcador y sin valor por defecto. El fichero versionado declara qué necesita, no cuánto vale:

# src/main/resources/application.yaml — sí va al repositorio
spring:
  datasource:
    url: ${CICLOURBANA_BD_URL:jdbc:h2:mem:ribalta}
    username: ${CICLOURBANA_BD_USUARIO:sa}
    password: ${CICLOURBANA_BD_PASSWORD:}     # vacío en local, obligatorio fuera
ciclourbana:
  pasarela-pago:
    api-key: ${CICLOURBANA_PASARELA_API_KEY}  # sin defecto: falla si no está

Fíjate en el detalle: la clave de la pasarela no tiene valor por defecto. Si alguien despliega sin definirla, el arranque falla inmediatamente con un mensaje claro. Eso es mucho mejor que arrancar y fallar en el primer pago.

export CICLOURBANA_BD_PASSWORD='la-de-verdad'
export CICLOURBANA_PASARELA_API_KEY='sk_live_...'
java -jar ciclourbana.jar

2. Fichero externo fuera del árbol del proyecto, con permisos restringidos:

sudo mkdir -p /etc/ciclourbana
sudo tee /etc/ciclourbana/secretos.yaml > /dev/null <<'EOF'
spring:
  datasource:
    password: la-de-verdad
EOF
sudo chmod 600 /etc/ciclourbana/secretos.yaml
sudo chown ciclourbana:ciclourbana /etc/ciclourbana/secretos.yaml
spring:
  config:
    import: optional:file:/etc/ciclourbana/secretos.yaml

3. Un gestor de secretos, que es lo correcto en producción seria: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault o los Secret de Kubernetes montados como configtree:. Se tratan en el módulo 8.

4. Protege el repositorio. Añade al .gitignore cualquier fichero local de secretos y considera un hook de pre-commit que detecte patrones sospechosos:

# .gitignore
application-local.yaml
application-secrets.yaml
*.env
/config/secretos.yaml

5. Si un secreto se filtra, rótalo. No lo borres del fichero y sigas. Invalida la credencial y emite una nueva. Es la única acción que realmente cierra el agujero.

Una última nota: en la lección 02-05 veremos cómo evitar además que un secreto acabe accidentalmente en los logs, y en el módulo 7, cómo Actuator oculta los valores sensibles en el endpoint /actuator/env.

Errores Comunes y Consejos

Tener a la vez application.properties y application.yaml. Gana el .properties y el YAML parece ignorado. Borra uno.

Escribir mal el nombre de la variable de entorno. CICLOURBANA_RED_CAPACIDAD_MINIMA no es ciclourbana.red.capacidad-minima: los guiones se eliminan, no se convierten en guion bajo. El nombre correcto es CICLOURBANA_RED_CAPACIDADMINIMA.

Usar tabuladores en YAML. El fichero se rechaza con un error de análisis que no siempre señala la línea correcta. Configura tu editor para insertar espacios.

Poner --server.port antes del -jar. La JVM no lo entiende. Los -- van después del jar; los -D van antes.

Esperar relajación de nombres con @Value. No la hay. Con @Value la clave debe coincidir carácter a carácter.

Confundir ${...} con #{...}. El primero resuelve propiedades; el segundo evalúa SpEL. @Value("#{ciclourbana.ciudad}") intenta evaluar ciclourbana.ciudad como expresión y falla; lo correcto es ${ciclourbana.ciudad}.

Poner una contraseña en application.yaml. Repetido a propósito: es el error más caro de esta lección.

Consejo: nombra tus propiedades con un prefijo propio. Todo lo de CicloUrbana empieza por ciclourbana.. Así nunca colisionas con una propiedad de Spring Boot o de una librería, y grep -r "ciclourbana\." src/ te dice de un vistazo qué configura la aplicación.

Consejo: usa server.port=0 en las pruebas. Asigna un puerto libre y evita fallos cuando dos pruebas se ejecutan a la vez. Volveremos a ello en el módulo 6.

Consejo: documenta cada propiedad con un comentario. Quien despliegue tu aplicación dentro de un año lo agradecerá, y no necesitará leer el código para saber si umbral-bateria va en porcentaje o en voltios.

Consejo: en desarrollo, no dependas de la línea de comandos. Un application-dev.yaml con perfil (lección 07-02) es más reproducible que un comando largo que solo está en tu memoria.

Ejercicios

Ejercicio 1: demostrar la precedencia

Define ciclourbana.ciudad=Ribalta en application.yaml. Escribe un componente AvisoCiudad que registre su valor al arrancar. Después arranca la aplicación cuatro veces —normal, con variable de entorno, con propiedad de sistema y con argumento de línea de comandos— dando un valor distinto en cada una, y anota qué gana. Añade al componente el volcado de la fuente que aportó el valor.

Ejercicio 2: externalizar la tarifa de estudiante

TarifaEstudiante tiene todavía sus constantes escritas a fuego. Externalízalas a ciclourbana.tarifa.estudiante.precio-minuto y ciclourbana.tarifa.estudiante.minutos-gratis, con valores por defecto en el propio @Value para que la aplicación arranque aunque falten. Verifica que el importe de un alquiler de 45 minutos cambia al sobrescribir las propiedades por línea de comandos.

Ejercicio 3: configuración de operador con fichero externo

Prepara un despliegue realista: empaqueta el jar, crea un directorio config/ junto a él con un application.yaml que cambie el puerto a 9090, suba el desbloqueo a 0,60 € y lea la contraseña de la base de datos de una variable de entorno sin valor por defecto. Comprueba que la aplicación falla al arrancar si la variable no está definida, y que arranca correctamente cuando sí lo está.


Soluciones

Solución 1

# src/main/resources/application.yaml
ciclourbana:
  ciudad: Ribalta
package com.ciclourbana.comun;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.stereotype.Component;

@Component
public class AvisoCiudad implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(AvisoCiudad.class);

    private static final String CLAVE = "ciclourbana.ciudad";

    private final String ciudad;
    private final ConfigurableEnvironment entorno;

    public AvisoCiudad(@Value("${ciclourbana.ciudad}") String ciudad,
                       ConfigurableEnvironment entorno) {
        this.ciudad = ciudad;
        this.entorno = entorno;
    }

    @Override
    public void run(String... args) {
        log.info("Ciudad de la red: {}", ciudad);
        log.info("Aportada por la fuente: {}", fuenteQueGana());
    }

    /** Recorre las fuentes en orden y devuelve la primera que tiene la clave. */
    private String fuenteQueGana() {
        for (var fuente : entorno.getPropertySources()) {
            if (fuente instanceof EnumerablePropertySource<?> enumerable
                    && enumerable.containsProperty(CLAVE)) {
                return fuente.getName() + " -> " + enumerable.getProperty(CLAVE);
            }
        }
        return "ninguna (valor por defecto)";
    }
}

Las cuatro ejecuciones:

# 1. Solo el fichero
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Ciudad de la red: Ribalta
# Aportada por la fuente: Config resource 'class path resource [application.yaml]' -> Ribalta

# 2. Variable de entorno
CICLOURBANA_CIUDAD=Ribalta-Norte java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Ciudad de la red: Ribalta-Norte
# Aportada por la fuente: systemEnvironment -> Ribalta-Norte

# 3. Propiedad de sistema (gana a la variable de entorno)
CICLOURBANA_CIUDAD=Ribalta-Norte \
  java -Dciclourbana.ciudad=Ribalta-Sur -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Ciudad de la red: Ribalta-Sur
# Aportada por la fuente: systemProperties -> Ribalta-Sur

# 4. Argumento de línea de comandos (gana a todos)
CICLOURBANA_CIUDAD=Ribalta-Norte \
  java -Dciclourbana.ciudad=Ribalta-Sur -jar target/ciclourbana-0.0.1-SNAPSHOT.jar \
  --ciclourbana.ciudad=Ribalta-Centro
# Ciudad de la red: Ribalta-Centro
# Aportada por la fuente: commandLineArgs -> Ribalta-Centro

Comentario: el método fuenteQueGana() implementa literalmente el algoritmo del Environment descrito en el apartado 1 —recorrer la lista ordenada y quedarse con la primera coincidencia— y por eso su resultado coincide siempre con el valor inyectado. Es un buen diagnóstico para tener a mano.

Nota sobre la fuente configurationProperties que aparece la primera en el listado: es una fuente sintética que Spring Boot usa internamente para el enlazado; no aporta valores propios.

Solución 2

# src/main/resources/application.yaml
ciclourbana:
  tarifa:
    desbloqueo: 0.50
    precio-minuto: 0.12
    estudiante:
      precio-minuto: 0.08
      minutos-gratis: 15
package com.ciclourbana.alquileres;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

@Component("tarifaEstudiante")
public class TarifaEstudiante implements CalculadoraTarifa {

    private final BigDecimal porMinuto;
    private final long minutosGratis;

    public TarifaEstudiante(
            // Valor por defecto tras los dos puntos: la app arranca aunque falten
            @Value("${ciclourbana.tarifa.estudiante.precio-minuto:0.08}") BigDecimal porMinuto,
            @Value("${ciclourbana.tarifa.estudiante.minutos-gratis:15}") long minutosGratis) {
        this.porMinuto = porMinuto;
        this.minutosGratis = minutosGratis;
    }

    @Override
    public BigDecimal calcular(Duration duracion) {
        long facturables = Math.max(0, duracion.toMinutes() - minutosGratis);
        return porMinuto
                .multiply(BigDecimal.valueOf(facturables))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String nombre() {
        return "estudiante";
    }
}

Verificación con el DemostracionTarifas de la lección 02-02:

# Con los valores del fichero: (45-15) * 0,08 = 2,40 €
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Alquiler de 45 min con tarifa 'estudiante': 2.40 €

# Convenio ampliado: 30 minutos gratis y 0,06 €/min -> (45-30) * 0,06 = 0,90 €
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar \
  --ciclourbana.tarifa.estudiante.minutos-gratis=30 \
  --ciclourbana.tarifa.estudiante.precio-minuto=0.06
# Alquiler de 45 min con tarifa 'estudiante': 0.90 €

Comentario y error frecuente: si escribes @Value("${ciclourbana.tarifa.estudiante.minutosGratis:15}") —en camelCase— no enlazará con la propiedad minutos-gratis del fichero: tomará siempre el valor por defecto 15, en silencio y sin ningún error. Es exactamente la ausencia de relajación de nombres del apartado 5, y es una de las razones de peso para migrar a @ConfigurationProperties en la próxima lección.

Consejo: fíjate en que los precios se declaran como BigDecimal, no como double. En dinero, double produce errores de redondeo inaceptables (0.1 + 0.2 no es 0.3). Es una regla que no debe romperse nunca en un dominio con importes.

Solución 3

# src/main/resources/application.yaml — versionado, sin secretos
spring:
  application:
    name: ciclourbana
  datasource:
    url: ${CICLOURBANA_BD_URL:jdbc:h2:mem:ribalta}
    username: ${CICLOURBANA_BD_USUARIO:sa}
    # Sin valor por defecto: si falta la variable, el arranque falla
    password: ${CICLOURBANA_BD_PASSWORD}

server:
  port: 8080

ciclourbana:
  ciudad: Ribalta
  tarifa:
    desbloqueo: 0.50
    precio-minuto: 0.12
# Empaquetar
./mvnw clean package -DskipTests

# Preparar la configuración del operador junto al jar
mkdir -p target/config
cat > target/config/application.yaml <<'EOF'
# Configuración de producción de la red de Ribalta.
# Este fichero NO está en el repositorio: lo gestiona el operador.
server:
  port: 9090

ciclourbana:
  tarifa:
    desbloqueo: 0.60      # tarifa de temporada alta aprobada por el ayuntamiento
EOF

Primer intento, sin la variable de entorno:

cd target && java -jar ciclourbana-0.0.1-SNAPSHOT.jar
***************************
APPLICATION FAILED TO START
***************************

Description:

Could not resolve placeholder 'CICLOURBANA_BD_PASSWORD' in value
"${CICLOURBANA_BD_PASSWORD}"

Segundo intento, con ella definida:

cd target && CICLOURBANA_BD_PASSWORD='clave-de-produccion' java -jar ciclourbana-0.0.1-SNAPSHOT.jar
Tomcat started on port 9090 (http) with context path '/'
curl -s http://localhost:9090/api/v1/estaciones | head -3

Comentario: el fallo del primer intento es deseable. Un marcador sin valor por defecto convierte un olvido de despliegue en un error inmediato y evidente, en lugar de un fallo silencioso a las tres de la madrugada cuando alguien intente pagar. Es la misma filosofía de "fallar pronto y en voz alta" que vimos con la creación anticipada de singletons en la lección 02-03.

Consejo de despliegue: en un servidor real, la contraseña no se escribe en la línea de comandos —quedaría visible en ps aux y en el historial de la shell— sino en el fichero de unidad de systemd, en el EnvironmentFile correspondiente con permisos 600, o en el gestor de secretos de la plataforma. En Docker se pasa por variable de entorno del contenedor o, mejor, por secreto montado como fichero y leído con configtree: (lección 07-04).

Conclusión

La configuración de CicloUrbana ha salido del código. Sabes que el Environment no guarda valores sino una lista ordenada de PropertySource, y que toda la lógica de precedencia se reduce a en qué orden está esa lista: lo más externo gana, fuera del jar gana a dentro, y con perfil gana a sin perfil. Lo has comprobado tú mismo arrancando la misma aplicación con fichero, variable de entorno, propiedad de sistema y argumento de línea de comandos, y sabes distinguir la sintaxis de cada uno. Conoces las diferencias reales entre .properties y YAML —y por qué a partir de aquí el curso usa YAML— junto con la trampa de la indentación. Entiendes la relajación de nombres y las tres reglas que traducen ciclourbana.red.capacidad-minima a CICLOURBANA_RED_CAPACIDADMINIMA, incluida la eliminación de los guiones que tantos despliegues rompe. Sabes leer propiedades con @Value, darles valores por defecto, usar SpEL cuando aporta algo... y también conoces las siete limitaciones que hacen de @Value una herramienta de uso puntual. Sabes importar configuración externa con spring.config.import y spring.config.additional-location. Y, sobre todo, sabes que una credencial nunca va al repositorio, por qué el daño es permanente y cuáles son las alternativas reales.

Los precios de la red de Ribalta ya se pueden cambiar sin recompilar. Pero la solución tiene grietas evidentes: las claves están desperdigadas por varias clases, un camelCase mal escrito falla en silencio, nadie valida que el precio por minuto sea positivo y el IDE no ayuda a escribir las propiedades. La siguiente lección, Propiedades de Spring Boot, resuelve las cuatro cosas con @ConfigurationProperties: configuración tipada, agrupada en objetos inmutables, validada con Bean Validation, con conversión automática de Duration y DataSize, y con autocompletado en el IDE. Convertiremos toda la configuración de tarifas y de la red de Ribalta a esa forma, que es la que el proyecto mantendrá hasta el final del curso.

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