Production-Grade Application Configuration
Configuration that survives production: the 12-factor principles applied to Java, property precedence, fail-fast validation, feature flags, and graceful shutdown done properly.
On this page
Configuration decides how the same artefact behaves in dev, staging and production. Get it wrong and you get the two classic failures: a secret in Git, and a service that starts happily pointing at the wrong database.
Key Takeaways
- Config that varies by environment belongs in the environment, not the artefact.
- Validate at startup so a bad value fails the deploy, not a request at 3am.
- Feature flags decouple deploy from release and give you a kill switch.
- Graceful shutdown needs the grace period to exceed the drain timeout plus
preStop. - One artefact, many environments — never rebuild per environment.
The principles that matter
Of the twelve factors, four carry most of the practical weight for a Java service.
Config in the environment. Anything that differs between deployments — URLs, credentials, feature toggles, pool sizes — comes from environment variables or a config service, never from a file baked into the jar. The test is whether you could deploy the same artefact to staging and production with only environment differences. If not, config has leaked into the build.
Backing services as attached resources. A database, a broker, a cache are all identified by a URL in config. Swapping a local PostgreSQL for RDS should be a config change and nothing else.
Disposability. Fast startup and graceful shutdown. A process that takes three minutes to start
makes autoscaling useless and deploys slow; one that drops requests on SIGTERM makes every deploy a
small outage.
Dev/prod parity. The same database engine and version, the same broker, the same JVM. Testcontainers is what makes this practical — the difference between H2 and PostgreSQL is exactly where the bugs you did not catch are living.
Precedence
That combination — packaged defaults plus environment overrides — needs no rebuild to change a value
and puts no secret in the repository. Relaxed binding means SPRING_DATASOURCE_URL maps onto
spring.datasource.url with no application change, which is what makes Kubernetes ConfigMaps and
Secrets work out of the box.
When a value's origin is unclear, /actuator/env shows every source in order and which one supplied
the effective value.
Fail-fast validation
@ConfigurationProperties(prefix = "app")
@Validated
public record AppProperties(
@NotBlank @URL String publicBaseUrl,
@Valid @NotNull Payments payments,
@Valid @NotNull Limits limits
) {
public record Payments(
@NotBlank @URL String baseUrl,
@NotBlank String apiKey,
@DurationMin(seconds = 1) @DurationMax(seconds = 30)
@DefaultValue("5s") Duration timeout) { }
public record Limits(
@Min(1) @Max(10_000) @DefaultValue("100") int requestsPerMinute,
@Min(1) @DefaultValue("50") int maxPageSize) { }
}A missing apiKey now fails the context with a message naming the property. The deployment fails, the
pipeline reports it, and nobody discovers the problem when the first payment is attempted.
This is the highest-value ten minutes in a configuration refactor: converting scattered @Value
fields into one validated record turns a class of runtime failures into deploy-time ones.
Feature flags
@Component
@RefreshScope
@ConfigurationProperties(prefix = "app.features")
public class FeatureFlags {
private boolean newPricingEngine = false;
private boolean asyncFulfilment = false;
private int newCheckoutPercentage = 0; // gradual rollout
// getters and setters
}
@Service
public class PricingService {
public Money quote(Order order) {
// Ship dark, enable gradually, disable in seconds if it misbehaves.
if (flags.isNewPricingEngine()) {
return newEngine.calculate(order);
}
return legacyEngine.calculate(order);
}
}The value of a flag is that a bad feature becomes a config change rather than a rollback. Rollbacks are slow, they revert unrelated changes shipped in the same release, and they are stressful. Flipping a flag is none of those.
The discipline is removal. A flag left in place for a year is an untested code path plus a branch nobody understands. Put an expiry date in the flag's description and audit them quarterly.
For anything beyond a boolean, a dedicated service — LaunchDarkly, Unleash, or FF4J self-hosted — gives you percentage rollouts, targeting rules and an audit trail of who changed what.
Graceful shutdown
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30sspec:
template:
spec:
# Must exceed preStop (10s) + shutdown timeout (30s), with margin.
terminationGracePeriodSeconds: 60
containers:
- name: order-service
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 10"] }The sequence on SIGTERM: the preStop hook runs while the pod still serves, giving endpoint removal
time to propagate through kube-proxy and the ingress; then Spring stops accepting new requests and
lets in-flight ones finish; then the process exits. If it has not exited by
terminationGracePeriodSeconds, Kubernetes sends SIGKILL and whatever was in flight is lost.
The arithmetic is what people get wrong. A 30-second grace period with a 30-second shutdown timeout
and a 10-second preStop guarantees a SIGKILL on every deploy.
Background work needs the same treatment — setWaitForTasksToCompleteOnShutdown(true) on executors,
and a bounded awaitTermination, or async tasks are dropped mid-flight.
Startup time
Slow startup makes autoscaling ineffective and deploys long. Three things usually dominate.
Eager bean initialisation of things rarely used — a client for a third-party API called once a
day. Mark it @Lazy.
Classpath scanning across a large dependency tree. Narrowing @ComponentScan to your own packages
helps measurably on big applications.
Connection pool warm-up against a slow database. minimum-idle set low speeds startup at the cost
of the first request paying for connection creation.
Measure before optimising: -Ddebug prints a startup timeline, and Actuator's /actuator/startup
endpoint gives a precise breakdown per bean.
What to take away
Ship defaults in the artefact and override from the environment, so one build serves every
environment. Bind config into validated @ConfigurationProperties so mistakes fail the deploy. Use
feature flags to separate deploy from release, and get the shutdown arithmetic right — grace period
above preStop plus drain timeout — so deploys drop nothing.
Frequently Asked Questions
Are feature flags worth the complexity?
Why does my pod get killed during a deploy despite graceful shutdown?
Should configuration be validated at startup?
Related tutorials
- Production Observability — Full StackAssembling a production observability stack: the OTel agent and collector pipelines, Mimir, Loki and Tempo, alerting strategy that avoids fatigue, and runbooks that get used.
- Database Migrations with FlywaySchema changes you can deploy safely: Flyway naming and ordering, repeatable migrations, baselining an existing database, and expand-and-contract for zero downtime.
- Infrastructure as Code for Java AppsProvisioning the infrastructure a Java service needs: Terraform state and locking, reusable modules, managed databases and brokers, and where Pulumi fits.
- Performance Tuning & JVM OptimisationDiagnosing and fixing JVM performance: the memory model, choosing and tuning a collector, reading GC logs, profiling with JFR and async-profiler, and container-aware settings.