Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate5 min readUpdated
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

First source that defines a key wins. Ship defaults inside the jar; override with environment variables per deployment.

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

AppProperties.java
@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

FeatureFlags.java
@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

application.yml
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
deployment.yaml
spec:
  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?
Yes, for anything risky. They decouple deploy from release, so code ships dark and is enabled gradually — and disabled in seconds when it misbehaves, without a rollback. The discipline they require is removing them once a feature is stable, or you accumulate untested code paths.
Why does my pod get killed during a deploy despite graceful shutdown?
terminationGracePeriodSeconds must exceed your shutdown timeout plus any preStop delay. If Spring is given 30 seconds to drain but Kubernetes SIGKILLs at 30, in-flight requests are cut. Set the grace period comfortably above the sum.
Should configuration be validated at startup?
Always. A missing or malformed value discovered at 3am on the first request that touches it is far more expensive than a deployment that refuses to start. @ConfigurationProperties with @Validated turns config errors into startup failures, which the deployment pipeline catches.

Related tutorials