Skip to content
My Guidelines
Guides
In this guide (27 items)

application.yml Standard

Guideline — application.yml en microservicios Spring Boot

Cuándo leer esto: al crear o tocar cualquier bootstrap/src/**/resources/application.yml, al añadir una property, al revisar un PR que cambie configuración, o cuando aparezcan las palabras application.yml, perfil, profile document, secret store, Parameter Store, OpenBao, Vault, SPRING_PROFILES_ACTIVE, actuator, outbox.task, job.lock.

Ámbito: el fichero de configuración y su ciclo de vida. El wiring de beans, @ConfigurationProperties como clases y los patrones de código viven en spring-boot.md.


1. Reglas duras

#Regla
R1Un solo application.yml por servicio, en el módulo bootstrap. Nada de application-develop.yml / application-prod.yml versionados: las variantes son documentos de perfil (--- + spring.config.activate.on-profile) dentro del mismo fichero. Un fichero aparte diverge del base sin que se note hasta el despliegue; un documento de perfil está tres líneas debajo de lo que sobreescribe.
R2Todo valor mutable es ${VAR:default}. El default arranca en local sin exportar nada. Sin ${} sólo lo invariante del código (ddl-auto: none, open-in-view: false, shutdown: graceful).
R3Ningún ${VAR} sin default — salvo la excepción deliberada de §6 (secretos que deben fallar al arrancar). Un placeholder sin default revienta el arranque en el primer entorno que lo olvide.
R4Nombre de variable = camino de la propiedad en SCREAMING_SNAKE: spring.datasource.url → SPRING_DATASOURCE_URL, outbox.task.fixed-delay → OUTBOX_TASK_FIXED_DELAY. Sin excepciones ni abreviaturas creativas: la variable debe ser deducible desde el YAML y viceversa.
R5El secret store sólo se activa en el documento de perfil desplegado. En el base va enabled: false: en un portátil no hay credenciales de nube y el arranque se cae.
R6application-local.yml está gitignored. Un default que deba viajar con el repo va en el documento base o en un documento on-profile: local, nunca en el -local.yml.
R7Secretos reales nunca como default. El default es un valor de laboratorio evidente (test, secret, cadena vacía) o no existe. Un secreto de un entorno real en el YAML es una fuga, no una comodidad.
R8Cada clave con consecuencia operativa no evidente lleva comentario del porqué. lock-at-most: 60000 a secas es ruido; con qué pasa si se baja, es documentación.
R9Orden de bloques fijo (§2). Dos servicios deben poder diffearse en paralelo.
R10El application.yml de test no hereda del de main. Fichero autónomo en bootstrap/src/test/resources/, sin ${}. Ver §8.
R11Toda property propia se enlaza a un @ConfigurationProperties validado. El YAML no es la fuente de verdad del tipo ni del rango: lo es el record con @Validated. Ver §9.

2. Anatomía canónica

Orden de arriba a abajo. Los bloques de una capacidad que el servicio no usa se omiten enteros — nunca se dejan comentados.

spring: # application → profiles → docker.compose → cloud/secret-store →
# datasource → jpa → liquibase → rabbitmq/kafka → data.redis → threads.virtual
server: # port, context-path, shutdown, error
springdoc: # api-docs, swagger-ui, show-actuator
management: # server → endpoints → endpoint → info → tracing → opentelemetry → metrics → health
logging: # level → file → logback.rollingpolicy → pattern → structured
<security>: # config del starter de seguridad: issuer, public-paths, cors
outbox: # sólo si el servicio publica eventos
<dominio>: # bloques propios (payment:, raffle:, job:, blockchain:, …)
--- # documento(s) de perfil

Al crear un servicio nuevo se copia el application.yml del servicio más pequeño de la plataforma y se le quitan bloques; no se escribe desde cero.


3. Bloque a bloque

3.1 Identidad y perfil

spring:
application:
name: ${APP_NAME:<nombre-servicio>}
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
docker:
compose:
file: ${DOCKER_COMPOSE_FILE:docker/docker-compose.yaml}
enabled: ${DOCKER_COMPOSE_ENABLED:true}

spring.application.name se reusa después en server.servlet.context-path, management.metrics.tags.application y los logs estructurados. No repetir el literal: ${spring.application.name}.

Docker Compose on en el documento base (levanta las dependencias en local), off en el documento de perfil desplegado.

Mejora — perfiles compuestos. Si un entorno es “el perfil X más un par de rasgos”, declararlo como grupo en vez de encadenar perfiles en la variable de entorno:

profiles:
group:
prod: [cloud, metrics-full]

3.2 Datasource, ORM, migraciones

datasource:
url: ${SPRING_DATASOURCE_URL:<url local>}
username: ${SPRING_DATASOURCE_USERNAME:sa}
password: ${SPRING_DATASOURCE_PASSWORD:<valor de laboratorio>}
driver-class-name: ${SPRING_DATASOURCE_DRIVER:<driver>}
hikari:
maximum-pool-size: ${SPRING_DATASOURCE_POOL_MAX_SIZE:10}
minimum-idle: ${SPRING_DATASOURCE_POOL_MIN_IDLE:2}
connection-timeout: ${SPRING_DATASOURCE_TIMEOUT_MS:30000}
# Mejora: por debajo del idle timeout del balanceador/BD, o el pool sirve conexiones muertas.
max-lifetime: ${SPRING_DATASOURCE_POOL_MAX_LIFETIME_MS:1200000}
# Mejora: avisa de la conexión que alguien se dejó sin devolver. 0 = desactivado.
leak-detection-threshold: ${SPRING_DATASOURCE_POOL_LEAK_MS:0}
jpa:
hibernate:
ddl-auto: none # migra Liquibase, no Hibernate
open-in-view: false # sin sesión abierta en la capa web
show-sql: ${SPRING_JPA_SHOW_SQL:false}
liquibase:
enabled: ${SPRING_LIQUIBASE_ENABLED:true}
change-log: ${SPRING_LIQUIBASE_CHANGELOG:classpath:/changelog/db.changelog-master.yaml}
url: ${SPRING_LIQUIBASE_URL:<misma url>}
user: ${SPRING_LIQUIBASE_USER:<usuario de migración>}
password: ${SPRING_LIQUIBASE_PASSWORD:<valor de laboratorio>}

Liquibase lleva su propia conexión a propósito: permite migrar con un usuario con más privilegios (DDL) que el de la aplicación (DML), que es la separación que querrás cuando alguien pregunte por qué el usuario de runtime puede borrar tablas.

Mejora — show-sql por defecto false. En true mete una línea por sentencia también en los entornos donde el fichero de log rota cada pocas horas. Se enciende por variable cuando hace falta.

maximum-pool-size es el techo de referencia del servicio. Todo pool de hilos que tome conexiones se dimensiona por debajo y se documenta la relación — scheduler de jobs, sondas de diagnóstico, consumidores. Un scheduler con más hilos que conexiones convierte una punta de trabajo en un timeout de adquisición.

3.3 server

server:
port: ${SERVER_PORT:8080}
servlet:
context-path: ${SERVER_CONTEXT_PATH:/${spring.application.name}}
shutdown: graceful

El context-path derivado del nombre hace predecibles las rutas del gateway.

Mejora — el graceful sin su timeout está a medias:

spring:
lifecycle:
timeout-per-shutdown-phase: ${SHUTDOWN_TIMEOUT:20s}

Debe ser menor que el terminationGracePeriodSeconds del orquestador; si no, el pod recibe SIGKILL en mitad del drenado y la petición en curso se pierde igual.

forward-headers-strategy: framework sólo si el servicio construye URLs absolutas detrás del proxy.

3.4 management — observabilidad y superficie de actuator

management:
# MEJORA PRINCIPAL: actuator en su propio puerto. Ver el recuadro de abajo.
server:
port: ${MANAGEMENT_SERVER_PORT:8081}
endpoints:
web:
exposure:
include: ${MANAGEMENT_ENDPOINTS:health,info,metrics,prometheus,loggers}
endpoint:
health:
show-details: ${MANAGEMENT_HEALTH_SHOW_DETAILS:when_authorized}
probes:
enabled: true
group:
# Mejora: liveness NO mira dependencias. Si la BD cae, el orquestador debe dejar
# de mandar tráfico (readiness), no reiniciar el pod en bucle (liveness).
liveness:
include: livenessState
readiness:
include: readinessState,db,rabbit
env:
show-values: ${MANAGEMENT_ENV_SHOW_VALUES:never}
configprops:
show-values: ${MANAGEMENT_CONFIGPROPS_SHOW_VALUES:never}
info:
env:
enabled: true
tracing:
enabled: ${MANAGEMENT_TRACING_ENABLED:true}
propagation:
type: ${MANAGEMENT_TRACING_PROPAGATION_TYPE:w3c}
sampling:
probability: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:1.0} # bajar por entorno
# Boot 4 movió la exportación OTLP de spans bajo management.opentelemetry.tracing.export.otlp.
opentelemetry:
tracing:
export:
otlp:
endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:http://otel-collector:4318/v1/traces}
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
http.server.requests: true

Mejora estructural — actuator en puerto propio. El patrón habitual es exponer actuator en el puerto de la aplicación y luego excluir /actuator/** del filtro de autenticación para que Prometheus pueda hacer scrape sin JWT. Eso deja toda la superficie de actuator accesible sin credenciales a quien alcance el puerto. management.server.port la saca a un puerto distinto que el orquestador expone sólo a la red interna y que el Ingress no publica. Con eso:

  • deja de hacer falta la exclusión en el filtro de seguridad;
  • heapdump, threaddump, env y beans dejan de ser alcanzables desde fuera;
  • las probes del orquestador siguen funcionando, apuntando al puerto de management.

Mientras no se migre: heapdump y threaddump fuera de la lista expuesta. Un heap dump anónimo es un volcado con todos los secretos en claro — credenciales de BD, tokens de admin del IdP, claves de firma. Para depurar, MANAGEMENT_ENDPOINTS explícito y por sesión.

Otras decisiones del bloque:

  • show-details: when_authorized en vez de always: el detalle de health enumera dependencias, hosts y a veces mensajes de error de driver.
  • env.show-values: never / configprops.show-values: never: Boot enmascara por heurística de nombre (password, secret, key), y esa heurística no ve un connection-string o un admin-user que sí es sensible.
  • sampling.probability: 1.0 es default de desarrollo. En producción se baja por variable, o el coste del backend de trazas escala con el tráfico.
  • Los indicadores de health de dependencias (db, rabbit, redis) se declaran explícitamente: el autodescubrimiento cambia con las dependencias del classpath y la probe se vuelve silenciosa.

3.5 logging

logging:
level:
root: ${LOG_LEVEL_ROOT:INFO}
<paquete.raiz.del.servicio>: ${LOG_LEVEL_APP:INFO}
org.springframework: ${LOG_LEVEL_SPRING:INFO}
file:
name: ${PATH_LOGS:/tmp}/appLog.log
logback:
rollingpolicy:
file-name-pattern: ${PATH_LOGS:/tmp}/appLog.log.%d{yyyy-MM-dd}.%i
max-file-size: ${LOG_MAX_FILE_SIZE:200MB}
max-history: ${LOG_MAX_HISTORY:3}
total-size-cap: ${LOG_TOTAL_SIZE_CAP:1GB}
clean-history-on-start: true
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] [traceId=%X{traceId:-},spanId=%X{spanId:-}] %-5level %logger{5} - %msg%n"
structured:
format:
file: logstash
json:
add:
application: ${spring.application.name}
environment: ${SPRING_PROFILES_ACTIVE:default}
stacktrace:
max-length: 4096
include-hashes: true

Consola legible con traceId/spanId, fichero en JSON. El patrón de consola es idéntico byte a byte en todos los servicios: si se cambia, se cambia en todos, o se rompe cualquier parseo ad-hoc.

Mejora — niveles por variable. root: INFO fijo obliga a redesplegar para subir el detalle. Con ${LOG_LEVEL_ROOT:INFO} (o el endpoint loggers, ya expuesto) se ajusta en caliente.

Mejora — en contenedor, logging.file sobra. Si el recolector lee stdout, escribir además a disco duplica I/O y llena el volumen. Dejar el fichero sólo donde haya un agente que lo lea.

3.6 Seguridad del servicio

Forma canónica del starter de seguridad de la plataforma:

<security-prefix>:
security:
url: ${IDP_URL:http://localhost:8082}
realm: ${IDP_REALM:<realm>}
client-id: ${IDP_CLIENT_ID:}
client-secret: ${IDP_CLIENT_SECRET:}
public-paths: # rutas de negocio sin JWT: login, registro, webhooks del proveedor
- /api/v1/login
excluded-paths: # infraestructura: documentación (y actuator, sólo si comparte puerto)
- /v1/api-docs/**
- /swagger-ui/**
- /swagger-ui.html
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:*}
allowed-methods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
allowed-headers: ["*"]
allow-credentials: false
max-age: 3600
  • excluded-paths es para infraestructura; public-paths es para negocio. No son sinónimos: meter /api/v1/** en excluded-paths desactiva la autenticación del servicio entero de un plumazo, y el diff que lo hace tiene una línea.
  • Los credenciales de administración del IdP sólo existen en los servicios que administran el realm. Default vacío, siempre.
  • CORS "*" sólo vale si el edge real es el gateway. Mejora: parametrizarlo (${CORS_ALLOWED_ORIGINS:*}) para poder cerrarlo por entorno sin tocar código.
  • Mejora: la lista de rutas públicas es una decisión de seguridad; merece un comentario por entrada que diga por qué esa ruta puede vivir sin token.

3.7 outbox — sólo si el servicio publica eventos

El poller lo aporta el SDK de messaging; aquí sólo se afinan tiempos.

outbox:
enabled: ${OUTBOX_ENABLED:true}
publish:
# Dispatch monohilo: acota cuánto puede un mensaje sin confirmar frenar el resto del lote.
confirm-timeout-ms: ${OUTBOX_PUBLISH_CONFIRM_TIMEOUT_MS:5000}
task:
# Cadencia efectiva = max(fixed-delay, lock-at-least). Bajar AMBOS para acortarla.
fixed-delay: ${OUTBOX_TASK_FIXED_DELAY:5000}
lock-at-least: ${OUTBOX_TASK_LOCK_AT_LEAST:10000}
lock-at-most: ${OUTBOX_TASK_LOCK_AT_MOST:60000}
lock-name: outbox-${spring.application.name} # por servicio — NO unificar

Si hay outbox, el broker debe confirmar la publicación, o el dispatcher marca PUBLISHED filas que nunca se aceptaron y el evento se pierde en silencio:

spring:
rabbitmq:
publisher-confirm-type: ${RABBITMQ_PUBLISHER_CONFIRM_TYPE:correlated}
publisher-returns: ${RABBITMQ_PUBLISHER_RETURNS:true}

Consumidores: concurrencia y reintentos siempre explícitos, y con las unidades escritas igual en todos los servicios (1000ms, no 1000 en uno y 1000ms en otro).

listener:
simple:
concurrency: ${RABBITMQ_CONSUMER_CONCURRENCY:3}
retry:
enabled: true
max-attempts: ${RABBITMQ_CONSUMER_MAX_ATTEMPTS:5}
initial-interval: ${RABBITMQ_CONSUMER_RETRY_INITIAL:1000ms}
multiplier: ${RABBITMQ_CONSUMER_RETRY_MULTIPLIER:2.0}
max-interval: ${RABBITMQ_CONSUMER_RETRY_MAX:30000ms}

3.8 Jobs programados

Todo @Scheduled que toque filas compartidas necesita lock, y el lock debe estar declarado en el YAML, no escondido en un @ConditionalOnProperty: una property que sólo existe en una anotación no se puede encontrar ni sobreescribir.

job:
lock:
# memory → flag por proceso: con >1 réplica todas ejecutan el job a la vez sobre las mismas filas.
# redis/jdbc → decisión única para el clúster.
# PONER el distribuido DONDE HAYA MÁS DE UNA RÉPLICA: no falla ruidosamente si se olvida,
# los jobs simplemente duplican trabajo en silencio.
store: ${JOB_LOCK_STORE:memory}
ttl-ms: ${JOB_LOCK_TTL_MS:1800000} # que una réplica muerta no retenga la llave

Cada job expone las cuatro mismas claves: enabled, cron o interval-ms, lock-at-most, lock-at-least.

Mejora — enabled en todos los jobs, sin excepción. Es la única forma de apagar un job que se ha vuelto loco sin desplegar una versión.

Mejora — el pool del scheduler, explícito y justificado. La autoconfiguración de Boot es @ConditionalOnMissingBean(TaskScheduler.class): si cualquier @Configuration publica un TaskScheduler, spring.task.scheduling.pool.size configura un bean que no llega a existir y la property parece no hacer nada. Declarar el tamaño en la config propia del servicio y anotar por qué ese número, en relación con maximum-pool-size.

3.9 Bloques de dominio

Prefijo propio del servicio (payment:, raffle:, blockchain:, notification:, job:) o bajo un prefijo de plataforma cuando es transversal. Regla práctica: un feature flag de negocio lleva comentario que diga qué pasa en producción si queda mal puesto. Un flag que en false regala producto no puede documentarse con “activa el pago”.


4. Secret store: Parameter Store u OpenBao

4.1 Principio común

El store no se activa nunca en el documento base. En un portátil no hay ni rol de nube ni token de Bao, y un import obligatorio deja el servicio sin arrancar. El documento base lo deja apagado; el documento de perfil desplegado lo enciende.

---
spring:
docker:
compose:
enabled: false
config:
activate:
on-profile: develop,cert,prod
import: <ver 4.2 o 4.3>

Jerarquía de rutas, de menos a más específica (gana la última), igual con los dos backends:

  1. /shared/general/ — común a todos los entornos
  2. /<entorno>/general/ — común al entorno
  3. /<entorno>/<nombre-servicio>/ — propio del servicio en ese entorno

Ese orden es el orden literal de la cadena import: Spring aplica las localizaciones de izquierda a derecha y la última pisa. Invertirlo hace que el valor compartido tape al específico, y el síntoma es un servicio que ignora “su” parámetro.

Trampa — ${spring.profiles.active} dentro de la ruta. Es lo natural de escribir y funciona mientras haya un perfil activo. Con dos (SPRING_PROFILES_ACTIVE=prod,metrics-full) el placeholder resuelve a prod,metrics-full y la ruta apunta a un prefijo inexistente. Como el import es optional:, no falla: el servicio arranca con los defaults de laboratorio y lo descubres por comportamiento, no por un error. Usar una variable dedicada al entorno (${ENV_NAME}), no el perfil activo.

4.2 AWS Parameter Store (spring-cloud-aws)

---
spring:
config:
activate:
on-profile: develop,cert,prod
import: >-
optional:aws-parameterstore:/shared/general/;
optional:aws-parameterstore:/${ENV_NAME}/general/;
optional:aws-parameterstore:/${ENV_NAME}/${spring.application.name}/;
cloud:
aws:
parameterstore:
enabled: ${SPRING_CLOUD_AWS_PARAMETERSTORE_ENABLED:true}
region: ${AWS_REGION:us-east-1}

Reglas:

  • optional: en cada localización, no sólo en la primera. El prefijo aún no creado no debe impedir el arranque; y el prefijo es por definición lo que todavía no existe cuando se despliega un servicio nuevo.
  • Tipo SecureString + KMS para todo lo que sea secreto. String para lo que sólo es configuración. Mezclarlos obliga a dar kms:Decrypt a quien sólo necesitaba leer un timeout.
  • Credenciales por rol, jamás estáticas. IRSA en EKS, instance profile en EC2, task role en ECS. Un AWS_ACCESS_KEY_ID en el docker-compose.yml del host es una credencial de larga duración legible por todos los contenedores de esa máquina.
  • IAM de mínimo privilegio, acotado por prefijo: ssm:GetParametersByPath sobre arn:aws:ssm:<region>:<cuenta>:parameter/<entorno>/<servicio>/* y sobre los dos prefijos compartidos, más kms:Decrypt sobre la clave concreta. Nada de parameter/*.
  • Coste y throttling: GetParametersByPath se factura por llamada y tiene límite de tasa. La lectura ocurre una vez en el arranque, así que no es un problema en régimen — sí lo es en un CrashLoopBackOff, donde cada reinicio vuelve a pedir. Si un servicio no arranca, mirar también si está siendo estrangulado.
  • Sin refresco automático. Cambiar un parámetro no cambia el proceso vivo: hay que reiniciar (§4.5).

4.3 OpenBao / Vault (spring-cloud-vault)

OpenBao es fork de Vault y habla su API, así que el cliente es spring-cloud-starter-vault-config y las properties son spring.cloud.vault.*. Sólo cambian la URL y la operativa del servidor.

---
spring:
config:
activate:
on-profile: develop,cert,prod
import: >-
optional:vault://shared/general;
optional:vault://${ENV_NAME}/general;
optional:vault://${ENV_NAME}/${spring.application.name};
cloud:
vault:
enabled: ${SPRING_CLOUD_VAULT_ENABLED:true}
uri: ${BAO_ADDR:https://openbao.internal:8200}
# Falla el arranque si el store no responde: en un entorno desplegado, arrancar
# con los defaults de laboratorio es peor que no arrancar.
fail-fast: ${SPRING_CLOUD_VAULT_FAIL_FAST:true}
# KV v2: el cliente inserta el segmento /data/ por su cuenta. La ruta del import
# se escribe SIN él, o la lectura falla con un 404 que parece "secreto no existe".
kv:
enabled: true
backend: ${SPRING_CLOUD_VAULT_KV_BACKEND:secret}
authentication: ${SPRING_CLOUD_VAULT_AUTH:KUBERNETES}
kubernetes:
role: ${SPRING_CLOUD_VAULT_K8S_ROLE:${spring.application.name}}
service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token

Reglas:

  • Autenticación por identidad de plataforma, no por token. KUBERNETES (el service account del pod) donde haya orquestador; APPROLE con secret-id de un solo uso donde no lo haya. El método TOKEN sólo vale para un Bao en modo dev en el portátil.
  • Un rol y una policy por servicio, con lectura acotada a sus tres prefijos. Un rol compartido entre servicios convierte cualquier compromiso en compromiso de todos.
  • fail-fast: true en entornos desplegados. Es la diferencia frente a Parameter Store: allí optional: significa “sigue sin ello”; aquí, si el store existe y no responde, arrancar con los defaults del repo es peor que no arrancar. Combinación recomendada: optional: en las rutas (una ruta puede no existir aún) y fail-fast: true en el cliente (el servidor sí debe responder).
  • KV v2 y el segmento data: la ruta del import va sin /data/; el cliente lo añade. Escribirlo a mano da un 404 que se lee como “el secreto no está”.
  • Credenciales dinámicas de BD: si se usa el motor database, Bao emite usuario y contraseña con lease. Entonces spring.datasource.username/password dejan de venir del YAML, el lease hay que renovarlo (spring.cloud.vault.config.lifecycle.enabled: true) y el pool debe poder rehacerse cuando rota. No mezclar con credenciales estáticas: o una cosa o la otra, y documentado.
  • TLS al store siempre, y namespace si la instalación es multi-tenant.

4.4 Cuál de los dos

CriterioParameter StoreOpenBao / Vault
Infra a operarNinguna (servicio gestionado)Un clúster con su sellado, backups y rotación de unseal keys
AlcanceUn proveedor de nubeCualquier entorno, incluido on-prem e híbrido
Secretos dinámicos (BD, PKI, nube)NoSí, es la razón principal para elegirlo
RotaciónManual o por automatismo propioNativa, con leases y revocación
AuditoríaCloudTrailAudit device propio, por petición
CostePor llamada y por parámetro avanzadoCómputo del clúster + operación

Regla práctica: si toda la plataforma vive en una nube y los secretos son estáticos, Parameter Store; el día que hagan falta credenciales de BD de vida corta, PKI interna o un segundo entorno fuera de esa nube, OpenBao. Elegir el segundo “por si acaso” añade un componente crítico — si el store no arranca, no arranca nada — que hay que operar de verdad.

Convivencia: admisible durante una migración, nunca como estado final. Mientras dure, la misma clave no puede existir en los dos: gana el import más a la derecha y el diagnóstico se vuelve adivinanza. Migrar prefijo a prefijo, y dejar en el YAML un comentario con la fecha objetivo.

4.5 Rotación y refresco

Ninguno de los dos backends cambia un proceso vivo por sí solo. Al rotar un secreto:

  1. Escribir el valor nuevo en el store.
  2. Reiniciar el servicio de forma controlada (rolling restart). Es el camino por defecto y el que no sorprende a nadie.
  3. Sólo si el reinicio no es aceptable, spring-cloud-context con /actuator/refresh y @RefreshScope en los beans afectados. Tiene coste: los beans se recrean, y un DataSource o un consumidor de mensajes bajo @RefreshScope es un problema distinto al que se venía a resolver.

Para credenciales con lease (Bao), el renovador del cliente hace el trabajo mientras el proceso vive; lo que hay que probar es qué pasa cuando el lease expira estando el servicio arriba.

4.6 Modos de fallo que hay que dejar previstos

FalloCon optional:Qué hacer
Prefijo no existeArranca con defaults del repoEs el comportamiento buscado en un servicio nuevo
Store caídoArranca con defaults del repoNo es aceptable en desplegado: fail-fast (Bao) o probe que verifique un valor que sólo puede venir del store
Permiso denegadoSilencioso, indistinguible de “no existe”Log al arranque enumerando las localizaciones resueltas
Ruta mal formada (§4.1)SilenciosoVariable dedicada al entorno; verificación en el smoke test

Mejora — que el arranque diga de dónde salió la configuración. Un log de una línea con las localizaciones importadas y cuántas claves aportó cada una convierte todos los fallos silenciosos de esta tabla en un fallo visible. Sin volcar valores, evidentemente.

4.7 Local y test

Ni store ni import: enabled: false en el documento base y defaults de laboratorio. Si se necesita ejercitar el camino real, un contenedor del store en modo dev levantado por Compose y un perfil propio — nunca apuntando al store de un entorno real desde un portátil.

En los tests, el store va explícitamente apagado (§8): un test que dependa de la red no es un test.


5. Precedencia efectiva

variable de entorno > secret store (/<entorno>/<servicio>/ > /<entorno>/general > /shared)
> documento de perfil del application.yml
> documento base del application.yml

Corolario: una variable de entorno gana siempre al secret store. Es la palanca de emergencia (apagar un flag sin redeploy) y también la trampa recurrente: un valor viejo en el docker-compose.yml del host tapa el parámetro correcto y la investigación se va a mirar la nube.

Al diagnosticar “el valor no es el que puse”, el primer comando es mirar el entorno del proceso, no el store.


6. Secretos

  • Default = valor de laboratorio evidente. Nunca un secreto de un entorno real: el repo tiene historia, y borrarlo después no lo borra del pasado. Si se coló uno, rotarlo, no sólo quitarlo.

  • En entornos desplegados, los secretos llegan por el store, no por YAML.

  • Mejora — fallar en arranque en vez de arrancar inseguro. Para el puñado de claves donde un default es peor que un error (clave de firma, pepper, secreto de cliente en producción), el documento de perfil cert,prod las deja sin default a propósito:

    ---
    spring:
    config:
    activate:
    on-profile: cert,prod
    app:
    signing:
    key: ${APP_SIGNING_KEY} # sin default: si falta, el servicio no arranca

    Es la única excepción legítima a R3, y va comentada como tal.

  • Comprobación antes de commitear: rg -nE '(secret|password|token|key|pepper)\s*:\s*[^$#]' bootstrap/src/main/resources/application.yml — todo resultado debe ser un valor de laboratorio evidente o un ${VAR:}.


7. Nombres de variable y relaxed binding

Spring mapea SPRING_DATASOURCE_URL → spring.datasource.url quitando guiones bajos. Dos casos que rompen la intuición:

  • Guion en la property: outbox.task.fixed-delay → OUTBOX_TASK_FIXEDDELAY y OUTBOX_TASK_FIXED_DELAY funcionan. Usar siempre la segunda forma, por legible.
  • Listas: app.hosts[0] → APP_HOSTS_0_. Sobreescribir una lista YAML desde el entorno es frágil (no se puede acortar, sólo pisar índices). Para lo que el entorno deba controlar, preferir una property de tipo string separada por comas y partirla en el @ConfigurationProperties.

8. application.yml de test

En bootstrap/src/test/resources/, con ese nombre exacto (no application-test.yml). Fichero autónomo y sin ${}: los tests son deterministas o no son tests.

ClaveValor en testPorqué
spring.profiles.activetestno carga los documentos de perfil desplegado
secret store enabledfalsesin red
spring.datasourceBD en memoria en modo compatiblearranque sin contenedor
spring.jpa.hibernate.ddl-autocreate-dropel esquema lo crea Hibernate
spring.liquibase.enabledfalseno se migra sobre la BD en memoria
springdoc.*.enabledfalsemenos beans, arranque más rápido
management.tracing.enabledfalsesin exportador OTLP colgando el test
management.endpoints...includehealth,infosuperficie mínima
logging.level.rootWARN + paquete propio en DEBUGsalida legible

Mejora — la BD en memoria miente. Sirve para tests de contexto; los tests que ejercitan SQL real (migraciones, índices, dialecto, bloqueos) van con Testcontainers contra el motor de producción, con su propia configuración vía @DynamicPropertySource, no en este fichero.


9. El YAML no es el contrato: @ConfigurationProperties

Un bloque de dominio sin clase de properties se lee con @Value, se equivoca de tipo en runtime y falla en la primera petición, no en el arranque.

@Validated
@ConfigurationProperties(prefix = "outbox.task")
public record OutboxTaskProperties(
@Positive long fixedDelay,
@Positive long lockAtLeast,
@Positive long lockAtMost) {
public OutboxTaskProperties {
if (lockAtMost <= lockAtLeast) {
throw new IllegalArgumentException("lock-at-most must exceed lock-at-least");
}
}
}

Gana tres cosas: falla en el arranque y no en producción, documenta el rango válido junto al tipo, y las invariantes entre claves (lockAtMost > lockAtLeast) dejan de vivir sólo en un comentario.

Mejora — metadatos para el IDE. spring-boot-configuration-processor en el build genera el autocompletado de las properties propias; para las que se declaran sólo en YAML, un META-INF/additional-spring-configuration-metadata.json evita el subrayado amarillo y da descripción.


10. Anti-patrones

Anti-patrónQué provoca
application-prod.yml versionado aparteDiverge del base en silencio; se descubre desplegando
${VAR} sin default fuera de §6El primer entorno que lo olvide no arranca, y el error apunta al bean, no a la variable
Secreto real como defaultFuga permanente: queda en la historia del repo
heapdump expuesto con actuator sin autenticarVolcado anónimo con todos los secretos en claro
/api/** en excluded-pathsServicio entero sin autenticación, en un diff de una línea
Outbox sin confirms del brokerFilas marcadas PUBLISHED que nunca salieron
store: memory con más de una réplicaCada réplica ejecuta el mismo job sobre las mismas filas
Pool de scheduler ≥ pool de conexionesPunta de trabajo = timeout de adquisición en el hilo web
graceful sin timeout-per-shutdown-phase acorde al orquestadorSIGKILL a mitad del drenado
${spring.profiles.active} en la ruta del importCon dos perfiles activos, ruta inexistente e import optional: que calla
Credenciales estáticas de nube en el host para leer el storeCredencial de larga duración legible por todos los contenedores de esa máquina
Rol o policy del store compartido entre serviciosUn compromiso se vuelve compromiso de todos
debug: true de defaultVolcado de autoconfiguración en cada arranque, en cada entorno
Bloque comentado “por si acaso”Nadie sabe si está desactivado a propósito; git ya guarda la historia

11. Checklist pre-merge

  • Ningún ${VAR} sin default, salvo la excepción documentada de §6 (rg -n '\$\{[A-Z0-9_]+\}' application.yml).
  • Ningún secreto real como default; si se coló alguna vez, está rotado.
  • Bloques en el orden de §2; los no usados, ausentes — no comentados.
  • Toda clave nueva es ${VAR:default} con nombre derivado del camino de la propiedad.
  • Toda property propia nueva está en un @ConfigurationProperties validado.
  • heapdump/threaddump fuera de la lista expuesta mientras actuator comparta puerto con la app.
  • Health groups liveness/readiness declarados; liveness sin dependencias externas.
  • Si hay outbox: confirms y returns del broker presentes.
  • Si hay @Scheduled sobre filas compartidas: job.lock.store declarado, con enabled por job.
  • Secret store sólo dentro del documento de perfil desplegado; todos los segmentos optional:.
  • Store con fail-fast en desplegado (Vault/OpenBao) o probe que verifique un valor que sólo pueda venir del store.
  • La policy/IAM del servicio está acotada a sus prefijos, no al árbol entero.
  • La ruta del import no depende de spring.profiles.active.
  • spring.lifecycle.timeout-per-shutdown-phase < grace period del orquestador.
  • El application.yml de test cubre la clave nueva si el contexto de test la necesita.
  • Toda clave con consecuencia operativa no evidente lleva comentario del porqué.