The orders API from REST API with Boot 3 + Records as DTOs already has a clean HTTP boundary. It still has a production-shaped hole: a single application.yaml packed into the jar, complete with a datasource password or inventory API token.
That file is not configuration. It is cargo. Anyone who can read the artifact can read the secret. Split settings by profile, bind them to a typed record, and inject secrets from the environment. The jar may know where production lives. It must not know the password.
This post owns Boot configuration binding. Java records as a language feature live in Java Records: Immutable Data Without the Boilerplate. Request and response DTOs stay in the REST post. The record here is bound from the Environment, not deserialized from JSON.
The secret is already in the artifact
Spring Boot copies src/main/resources into the fat jar. If production credentials are in those YAML files, they ship with every build.
This is the failure mode. A committed “prod” password looks convenient until the artifact leaves your laptop:
spring:
application:
name: orders
datasource:
url: jdbc:postgresql://orders-db:5432/orders
username: orders_app
password: SuperSecretProdPassword123
orders:
inventory:
base-url: https://inventory.internal.example
token: inv_live_7f31c9e2
Package the application and list the config files inside the jar:
./mvnw -q -DskipTests package
jar tf target/orders-0.0.1-SNAPSHOT.jar | grep 'application.*yaml'
Boot puts them on the classpath, not behind any lock:
BOOT-INF/classes/application.yaml
Print the resource. The password is plaintext in an artifact CI will copy to a registry, a jump host, or a teammate’s Downloads folder:
unzip -p target/orders-0.0.1-SNAPSHOT.jar BOOT-INF/classes/application.yaml
Treat the jar as public to everyone who can pull the image or copy the file. Obfuscation, private Git history, and “we only deploy internally” do not change that. Delete the secret from the YAML. Supply it at runtime.
Split config by profile
Keep defaults in application.yaml. Put production overlays in application-prod.yaml. Boot loads the base file always, then the profile-specific file when that profile is active. Later sources override earlier ones for the same key.
Local defaults stay boring and obviously fake. H2 and a dummy inventory token are fine on a laptop:
spring:
application:
name: orders
datasource:
url: jdbc:h2:mem:orders
username: sa
password: local-only
orders:
inventory:
base-url: http://localhost:8081
token: local-dev-token
Production YAML names hosts and users. It does not name secrets:
spring:
datasource:
url: jdbc:postgresql://orders-db:5432/orders
username: orders_app
orders:
inventory:
base-url: https://inventory.internal.example
There is no password and no token in the prod file. Those keys stay absent so a missing environment variable is a startup failure instead of a silently empty override.
Note: File name is the activation rule. application-prod.yaml is loaded for the prod profile. application-local.yaml would load for local. You do not need spring.config.activate.on-profile for this layout.
Activate prod without committing it
Do not commit spring.profiles.active: prod in the default YAML. That bakes the production overlay into every process that starts from the jar, including a developer who only wanted H2.
Set the profile at runtime. Inside a process, Boot reads spring.profiles.active. In the environment, the same property is SPRING_PROFILES_ACTIVE:
SPRING_PROFILES_ACTIVE=prod
Command-line arguments work too, and they win over environment variables:
java -jar target/orders-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
Maven’s Boot plugin accepts the same property:
./mvnw spring-boot:run -Dspring-boot.run.profiles=prod
A useful mental model:
application.yaml always loaded
application-{profile}.yaml loaded when that profile is active
OS environment variables override YAML of the same key
command-line arguments override environment variables
Use the command line for the profile if you want. Do not put passwords on the command line. ps, shell history, and CI logs will keep them.
This post stops at environment variables. Kubernetes Secrets, Docker secrets, and a vault product usually inject the same ORDERS_INVENTORY_TOKEN and SPRING_DATASOURCE_PASSWORD names. Learn that contract first; the store can change later.
Bind settings to a record
Stringly-typed environment.getProperty("orders.inventory.token") calls scatter across the app and fail late. Bind the orders prefix to a record once, validate it, and inject that bean.
The record is a configuration object. It uses the same Java feature as a DTO, but it is not a request body and it is not mapped by Jackson for HTTP:
package com.geekmonks.orders;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "orders")
public record OrdersProperties(@Valid Inventory inventory) {
public record Inventory(
@NotBlank String baseUrl,
@NotBlank String token
) {}
}
Boot 3 constructor-binds records automatically. YAML base-url maps to component baseUrl. Accessors are inventory() and baseUrl(), the same component style as in the REST DTO post.
Register the type so it becomes a bean. @EnableConfigurationProperties is explicit in a small lab:
package com.geekmonks.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties(OrdersProperties.class)
public class OrdersApplication {
public static void main(String[] args) {
SpringApplication.run(OrdersApplication.class, args);
}
}
You need the validation starter for @Validated plus Jakarta constraints. Add it next to web, the same pair the REST lab already uses:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
spring.datasource.* stays with Boot’s own binder. When you add a JDBC starter later, the URL and username can live in profile YAML; the password still comes from the environment. Do not duplicate datasource fields into OrdersProperties unless you are building a custom DataSource bean.
Inject the secret from the environment
Relaxed binding turns a YAML key into an environment variable by uppercasing, replacing dots with underscores, and treating hyphens as separators. The inventory token is the lab secret:
YAML Environment
------------------- --------------------------
orders.inventory.token ORDERS_INVENTORY_TOKEN
orders.inventory.base-url ORDERS_INVENTORY_BASE_URL
spring.datasource.password SPRING_DATASOURCE_PASSWORD
spring.profiles.active SPRING_PROFILES_ACTIVE
Export the production values in the shell, process manager, or platform secret injection. The YAML files in the jar never contain them:
export SPRING_PROFILES_ACTIVE=prod
export SPRING_DATASOURCE_PASSWORD='not-in-the-jar'
export ORDERS_INVENTORY_TOKEN='not-in-the-jar'
Boot binds ORDERS_INVENTORY_TOKEN onto OrdersProperties.Inventory.token after application-prod.yaml has set the base URL. The datasource password binds onto spring.datasource.password the same way, even though that property is not on the record.
A component should use the record, not reread the Environment for the same keys. This logger proves the bind without printing the token:
package com.geekmonks.orders;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@Component
class OrdersRuntimeLogger implements ApplicationListener<ApplicationReadyEvent> {
private static final Logger log =
LoggerFactory.getLogger(OrdersRuntimeLogger.class);
private final Environment environment;
private final OrdersProperties properties;
OrdersRuntimeLogger(
Environment environment, OrdersProperties properties) {
this.environment = environment;
this.properties = properties;
}
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
log.info(
"orders runtime: profiles={} inventoryBaseUrl={} tokenBound={}",
List.of(environment.getActiveProfiles()),
properties.inventory().baseUrl(),
!properties.inventory().token().isBlank());
}
}
tokenBound=true is the success signal. If you log properties.inventory().token(), you put the secret back into the artifact’s sibling: the log drain.
An OrderService from the REST lab injects the same bean when it later calls inventory. Keep the token off the HTTP DTO records. Those stay request and response shapes; this record stays process configuration.
Fail at startup, not on the first order
@Validated runs when the properties bean binds. A missing production token should crash the process during boot, not return 500 on the first POST /api/orders.
Start with the prod profile and no token:
SPRING_PROFILES_ACTIVE=prod ./mvnw spring-boot:run
Boot refuses to start. The exact banner text varies by version, but the cause is a constraint on orders.inventory.token:
APPLICATION FAILED TO START
Binding validation errors on orders:
- inventory.token: must not be blank
That is the point of keeping the key out of application-prod.yaml. An empty override cannot hide behind a committed default.
Note: @Validated on @ConfigurationProperties is bind-time validation. It is not the same as @Valid on a @RequestBody DTO. Client mistakes belong on the HTTP boundary; missing process secrets belong here, before the server accepts traffic.
Prove the jar is clean
Rebuild after the YAML split and inspect both files in the artifact:
./mvnw -q -DskipTests package
jar tf target/orders-0.0.1-SNAPSHOT.jar | grep 'application.*yaml'
You should see both the base file and the prod overlay:
BOOT-INF/classes/application.yaml
BOOT-INF/classes/application-prod.yaml
Print the prod overlay and confirm there is no password and no token:
unzip -p target/orders-0.0.1-SNAPSHOT.jar \
BOOT-INF/classes/application-prod.yaml
Expected content is hosts and usernames only:
spring:
datasource:
url: jdbc:postgresql://orders-db:5432/orders
username: orders_app
orders:
inventory:
base-url: https://inventory.internal.example
If SuperSecretProdPassword123 or inv_live_ still appears, it is still baked. Search the jar before you call the build clean:
unzip -l target/orders-0.0.1-SNAPSHOT.jar \
| grep -E 'application.*\.(yaml|yml|properties)'
unzip -p target/orders-0.0.1-SNAPSHOT.jar \
BOOT-INF/classes/application.yaml \
| grep -Ei 'password|token|secret'
Local-only values in the default file can still match that grep. That is a reminder to keep them obviously fake (local-dev-token), not a reason to put live credentials in the default profile “just for now.”
Run the orders lab
Start with no profile. The process should bind the local inventory token from application.yaml and log tokenBound=true against http://localhost:8081:
./mvnw spring-boot:run
Typical ready log:
orders runtime: profiles=[] inventoryBaseUrl=http://localhost:8081 tokenBound=true
Stop it, then start as production with secrets supplied outside the jar:
SPRING_PROFILES_ACTIVE=prod \
SPRING_DATASOURCE_PASSWORD='not-in-the-jar' \
ORDERS_INVENTORY_TOKEN='not-in-the-jar' \
./mvnw spring-boot:run
The overlay should replace the base URL, and the environment should satisfy @NotBlank on the token:
orders runtime: profiles=[prod] inventoryBaseUrl=https://inventory.internal.example tokenBound=true
Packaged startup is the same contract. The profile and secrets still come from the environment, not from flags stored in the archive:
SPRING_PROFILES_ACTIVE=prod \
SPRING_DATASOURCE_PASSWORD='not-in-the-jar' \
ORDERS_INVENTORY_TOKEN='not-in-the-jar' \
java -jar target/orders-0.0.1-SNAPSHOT.jar
A lab-only diagnostic endpoint can echo the same non-secret view if you want a curl. Do not return the token, and do not ship this mapping:
package com.geekmonks.orders;
import java.util.List;
import java.util.Map;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/orders")
class OrdersRuntimeController {
private final Environment environment;
private final OrdersProperties properties;
OrdersRuntimeController(
Environment environment, OrdersProperties properties) {
this.environment = environment;
this.properties = properties;
}
@GetMapping("/runtime-config")
Map<String, Object> runtimeConfig() {
return Map.of(
"profiles", List.of(environment.getActiveProfiles()),
"inventoryBaseUrl", properties.inventory().baseUrl(),
"inventoryTokenBound",
!properties.inventory().token().isBlank());
}
}
Call it after the prod process is up:
curl -sS http://localhost:8080/api/orders/runtime-config
The body should show the prod overlay and a bound token, never the token value:
{
"profiles": ["prod"],
"inventoryBaseUrl": "https://inventory.internal.example",
"inventoryTokenBound": true
}
Delete /runtime-config before a real deployment. Actuator env and configprops endpoints have the same leak risk if you expose them without the lock-down in Spring Boot Observability.
Configuration cheat sheet
Files:
application.yaml defaults, obviously fake local secrets
application-prod.yaml hosts and usernames, no passwords
never spring.profiles.active: prod in git
Activation:
SPRING_PROFILES_ACTIVE=prod
--spring.profiles.active=prod
./mvnw spring-boot:run -Dspring-boot.run.profiles=prod
Binding:
@ConfigurationProperties(prefix = "orders") on a record
@Validated + Jakarta constraints -> fail at startup
accessors: inventory().token(), not getToken()
REST DTO records stay on the HTTP boundary
Secrets:
ORDERS_INVENTORY_TOKEN
SPRING_DATASOURCE_PASSWORD
environment (or a manager that injects env vars)
never YAML in the jar, never command-line args, never logs
Proof:
jar tf ... | grep application
unzip -p ... application-prod.yaml
grep the artifact for password|token|secret
Do:
- Keep one prefix (
orders) for application settings you own. - Let Boot bind
spring.datasource.*; override only the password at runtime. - Fail closed when a production secret is missing.
Don’t:
- Commit live tokens “temporarily” in
application.yaml. - Log property dumps,
toString()on the record, or Actuatorenvto debug a bind. - Reuse HTTP DTO records as configuration types, or the reverse.
Wrap-up
Production configuration is a runtime input, not a compile-time resource. Profile YAML can name the Postgres host and the inventory URL. Environment variables supply SPRING_DATASOURCE_PASSWORD and ORDERS_INVENTORY_TOKEN. A @ConfigurationProperties record with @Validated makes that contract typed and fail-fast.
Unpack the jar once. If a live secret is in BOOT-INF/classes, the build is wrong—regardless of how carefully you later inject the same value. The orders service can keep using records at the REST boundary; this record is the process boundary.