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 |
|---|---|
| R1 | Un 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. |
| R2 | Todo 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). |
| R3 | Ningú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. |
| R4 | Nombre 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. |
| R5 | El 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. |
| R6 | application-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. |
| R7 | Secretos 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. |
| R8 | Cada 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. |
| R9 | Orden de bloques fijo (§2). Dos servicios deben poder diffearse en paralelo. |
| R10 | El application.yml de test no hereda del de main. Fichero autónomo en bootstrap/src/test/resources/, sin ${}. Ver §8. |
| R11 | Toda 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.virtualserver: # port, context-path, shutdown, errorspringdoc: # api-docs, swagger-ui, show-actuatormanagement: # server → endpoints → endpoint → info → tracing → opentelemetry → metrics → healthlogging: # level → file → logback.rollingpolicy → pattern → structured<security>: # config del starter de seguridad: issuer, public-paths, corsoutbox: # sólo si el servicio publica eventos<dominio>: # bloques propios (payment:, raffle:, job:, blockchain:, …)--- # documento(s) de perfilAl 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: gracefulEl 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: trueMejora 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.portla 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,envybeansdejan de ser alcanzables desde fuera;- las probes del orquestador siguen funcionando, apuntando al puerto de management.
Mientras no se migre:
heapdumpythreaddumpfuera 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_ENDPOINTSexplícito y por sesión.
Otras decisiones del bloque:
show-details: when_authorizeden vez dealways: 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 unconnection-stringo unadmin-userque sí es sensible.sampling.probability: 1.0es 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: trueConsola 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: 3600excluded-pathses para infraestructura;public-pathses para negocio. No son sinónimos: meter/api/v1/**enexcluded-pathsdesactiva 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 unificarSi 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 llaveCada 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:
/shared/general/— común a todos los entornos/<entorno>/general/— común al entorno/<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 aprod,metrics-fully la ruta apunta a un prefijo inexistente. Como el import esoptional:, 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.Stringpara lo que sólo es configuración. Mezclarlos obliga a darkms:Decrypta 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_IDen eldocker-compose.ymldel 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:GetParametersByPathsobrearn:aws:ssm:<region>:<cuenta>:parameter/<entorno>/<servicio>/*y sobre los dos prefijos compartidos, máskms:Decryptsobre la clave concreta. Nada deparameter/*. - Coste y throttling:
GetParametersByPathse 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/tokenReglas:
- Autenticación por identidad de plataforma, no por token.
KUBERNETES(el service account del pod) donde haya orquestador;APPROLEcon secret-id de un solo uso donde no lo haya. El métodoTOKENsó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: trueen 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) yfail-fast: trueen 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. Entoncesspring.datasource.username/passworddejan 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
namespacesi la instalación es multi-tenant.
4.4 Cuál de los dos
| Criterio | Parameter Store | OpenBao / Vault |
|---|---|---|
| Infra a operar | Ninguna (servicio gestionado) | Un clúster con su sellado, backups y rotación de unseal keys |
| Alcance | Un proveedor de nube | Cualquier entorno, incluido on-prem e híbrido |
| Secretos dinámicos (BD, PKI, nube) | No | Sí, es la razón principal para elegirlo |
| Rotación | Manual o por automatismo propio | Nativa, con leases y revocación |
| Auditoría | CloudTrail | Audit device propio, por petición |
| Coste | Por llamada y por parámetro avanzado | Có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:
- Escribir el valor nuevo en el store.
- Reiniciar el servicio de forma controlada (rolling restart). Es el camino por defecto y el que no sorprende a nadie.
- Sólo si el reinicio no es aceptable,
spring-cloud-contextcon/actuator/refreshy@RefreshScopeen los beans afectados. Tiene coste: los beans se recrean, y unDataSourceo un consumidor de mensajes bajo@RefreshScopees 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
| Fallo | Con optional: | Qué hacer |
|---|---|---|
| Prefijo no existe | Arranca con defaults del repo | Es el comportamiento buscado en un servicio nuevo |
| Store caído | Arranca con defaults del repo | No es aceptable en desplegado: fail-fast (Bao) o probe que verifique un valor que sólo puede venir del store |
| Permiso denegado | Silencioso, indistinguible de “no existe” | Log al arranque enumerando las localizaciones resueltas |
| Ruta mal formada (§4.1) | Silencioso | Variable 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.ymlCorolario: 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,prodlas deja sin default a propósito:---spring:config:activate:on-profile: cert,prodapp:signing:key: ${APP_SIGNING_KEY} # sin default: si falta, el servicio no arrancaEs 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_FIXEDDELAYyOUTBOX_TASK_FIXED_DELAYfuncionan. 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.
| Clave | Valor en test | Porqué |
|---|---|---|
spring.profiles.active | test | no carga los documentos de perfil desplegado |
secret store enabled | false | sin red |
spring.datasource | BD en memoria en modo compatible | arranque sin contenedor |
spring.jpa.hibernate.ddl-auto | create-drop | el esquema lo crea Hibernate |
spring.liquibase.enabled | false | no se migra sobre la BD en memoria |
springdoc.*.enabled | false | menos beans, arranque más rápido |
management.tracing.enabled | false | sin exportador OTLP colgando el test |
management.endpoints...include | health,info | superficie mínima |
logging.level.root | WARN + paquete propio en DEBUG | salida 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ón | Qué provoca |
|---|---|
application-prod.yml versionado aparte | Diverge del base en silencio; se descubre desplegando |
${VAR} sin default fuera de §6 | El primer entorno que lo olvide no arranca, y el error apunta al bean, no a la variable |
| Secreto real como default | Fuga permanente: queda en la historia del repo |
heapdump expuesto con actuator sin autenticar | Volcado anónimo con todos los secretos en claro |
/api/** en excluded-paths | Servicio entero sin autenticación, en un diff de una línea |
| Outbox sin confirms del broker | Filas marcadas PUBLISHED que nunca salieron |
store: memory con más de una réplica | Cada réplica ejecuta el mismo job sobre las mismas filas |
| Pool de scheduler ≥ pool de conexiones | Punta de trabajo = timeout de adquisición en el hilo web |
graceful sin timeout-per-shutdown-phase acorde al orquestador | SIGKILL a mitad del drenado |
${spring.profiles.active} en la ruta del import | Con dos perfiles activos, ruta inexistente e import optional: que calla |
| Credenciales estáticas de nube en el host para leer el store | Credencial de larga duración legible por todos los contenedores de esa máquina |
| Rol o policy del store compartido entre servicios | Un compromiso se vuelve compromiso de todos |
debug: true de default | Volcado 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
@ConfigurationPropertiesvalidado. -
heapdump/threaddumpfuera de la lista expuesta mientras actuator comparta puerto con la app. - Health groups
liveness/readinessdeclarados; liveness sin dependencias externas. - Si hay outbox: confirms y returns del broker presentes.
- Si hay
@Scheduledsobre filas compartidas:job.lock.storedeclarado, conenabledpor job. - Secret store sólo dentro del documento de perfil desplegado; todos los segmentos
optional:. - Store con
fail-fasten 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.ymlde test cubre la clave nueva si el contexto de test la necesita. - Toda clave con consecuencia operativa no evidente lleva comentario del porqué.