Configuration per Environment
Run one artifact in dev, staging and production by layering defaults, profiles and environment variables, and fail fast when a setting is missing.
# Lowest to highest priority
defaults -> profile (APP_ENV=prod) -> environment variables -> -Dsystem.propertiesThe same JAR or image should run everywhere. What differs between development, staging and production is configuration: database URLs, pool sizes, feature switches, log levels and credentials. Those values come from outside the artifact when it starts.
Layers, lowest priority first
- Defaults in code or a bundled file: safe values that suit local development.
- A profile: values shared by every instance of one environment, chosen by a name such as
prod. - Environment variables: set by the deployment (a Kubernetes manifest, a ConfigMap, a Secret).
- System properties or command-line arguments: one-off overrides when debugging.
Each layer overrides the one before. Write the order down; much configuration debugging is finding out which layer a value came from.
Rules that prevent incidents
- Fail fast. Read and validate all configuration at startup, then refuse to start with a clear message naming every missing or invalid key. A service that starts without a database URL and fails on the first request has already passed its readiness check and taken traffic.
- No production defaults for secrets. A default password works by accident in every environment until someone notices. Required secrets have no default at all.
- Typed values. Parse ports, sizes and durations into
intorDurationonce, into an immutable record, and pass that record around. Code deep in the service should never callSystem.getenv. - Log the effective configuration at startup, with secrets masked, so you can see what an instance is really using.
Secrets
Keep credentials out of the image, the Git repository and the profile files. In Kubernetes they come from a Secret, as environment variables or as files mounted into the container. Files are easier to rotate without restarting, and they do not appear in /proc/<pid>/environ or in crash reports that dump the environment. A secrets manager such as Vault or a cloud provider's service can supply them too.
In Spring Boot
Spring Boot implements these layers for you. application.yaml holds defaults, application-prod.yaml is loaded when the prod profile is active (SPRING_PROFILES_ACTIVE=prod), and environment variables override both through relaxed binding: SPRING_DATASOURCE_URL sets spring.datasource.url. @ConfigurationProperties on a record with @Validated gives you typed, checked settings. Keep the number of profiles small; a profile per developer or per server is configuration that belongs in the environment.
Example
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
record AppConfig(String env, int port, String dbUrl, int poolSize) {}
// Built-in defaults, then per-environment values, then the process environment.
final Map<String, String> defaults = Map.of(
"PORT", "8080", "DB_URL", "jdbc:postgresql://localhost:5432/orders", "POOL_SIZE", "5");
final Map<String, Map<String, String>> profiles = Map.of(
"dev", Map.of(),
"prod", Map.of("POOL_SIZE", "20", "DB_URL", "")); // prod must supply its own URL
AppConfig load(Map<String, String> environment) {
String env = environment.getOrDefault("APP_ENV", "dev");
Map<String, String> merged = new LinkedHashMap<>(defaults);
merged.putAll(profiles.getOrDefault(env, Map.of()));
merged.putAll(environment);
List<String> problems = new ArrayList<>();
if (merged.get("DB_URL").isBlank()) problems.add("DB_URL is required in " + env);
int port = parse(merged, "PORT", problems);
int pool = parse(merged, "POOL_SIZE", problems);
if (!problems.isEmpty()) {
throw new IllegalStateException("Invalid configuration: " + String.join("; ", problems));
}
return new AppConfig(env, port, merged.get("DB_URL"), pool);
}
int parse(Map<String, String> values, String key, List<String> problems) {
try {
return Integer.parseInt(values.get(key));
} catch (NumberFormatException e) {
problems.add(key + " must be a number, got '" + values.get(key) + "'");
return -1;
}
}
void main() {
IO.println(load(Map.of()));
IO.println(load(Map.of("APP_ENV", "prod", "DB_URL", "jdbc:postgresql://db.internal:5432/orders")));
try {
load(Map.of("APP_ENV", "prod", "PORT", "80a"));
} catch (IllegalStateException e) {
IO.println(e.getMessage());
}
}
// Output:
// AppConfig[env=dev, port=8080, dbUrl=jdbc:postgresql://localhost:5432/orders, poolSize=5]
// AppConfig[env=prod, port=8080, dbUrl=jdbc:postgresql://db.internal:5432/orders, poolSize=20]
// Invalid configuration: DB_URL is required in prod; PORT must be a number, got '80a'
When to use it
- Promote one tested image of a booking API from staging to production, changing only the ConfigMap and Secret it is given.
- Stop a misconfigured release from taking traffic by failing startup when the payment provider URL is missing.
- Give developers a working local setup with no environment variables at all, while production must set every connection detail.
More examples
Spring Boot: defaults and a prod profile
The second document applies only when SPRING_PROFILES_ACTIVE includes prod. The database URL and password are not in the file at all for prod; the deployment sets SPRING_DATASOURCE_URL and SPRING_DATASOURCE_PASSWORD.
# application.yaml: defaults for local development
server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://localhost:5432/orders
username: orders
hikari:
maximum-pool-size: 5
---
# Only applied when the prod profile is active
spring:
config:
activate:
on-profile: prod
datasource:
hikari:
maximum-pool-size: 20
logging:
level:
root: INFOKubernetes: a ConfigMap and a Secret as environment
Plain settings live in the ConfigMap and credentials in the Secret, so access to each can be controlled separately. In a real cluster the Secret is created by a secrets tool or pipeline, not committed to Git as shown here.
apiVersion: v1
kind: ConfigMap
metadata:
name: orders-config
data:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://db.internal:5432/orders
---
apiVersion: v1
kind: Secret
metadata:
name: orders-db
type: Opaque
stringData:
SPRING_DATASOURCE_PASSWORD: example-not-a-real-password
---
# In the Deployment's container spec
envFrom:
- configMapRef:
name: orders-config
- secretRef:
name: orders-dbTyped, validated settings in Spring Boot
Register it with @ConfigurationPropertiesScan or @EnableConfigurationProperties; a blank URL or a zero limit then stops the application at startup with a message naming the property. Values such as 2s or 500ms bind straight to Duration.
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
// Bound from orders.* in any layer: yaml, profile, or ORDERS_PAYMENT_BASE_URL etc.
@Validated
@ConfigurationProperties("orders")
public record OrdersProperties(
@NotBlank String paymentBaseUrl,
Duration paymentTimeout,
@Min(1) int maxItemsPerOrder) {
}
Discussion