Spring Boot Environment Variables: Externalized Configuration Guide for Production
Master Spring Boot environment variables for externalized configuration.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Java 17+ installed (we'll use Java 21 features)
- ✓Spring Boot 3.2+ project (Maven or Gradle)
- ✓Basic understanding of Spring Boot application.properties
• Spring Boot externalizes configuration via application.properties, YAML, environment variables, and command-line args
• Environment variables override properties with relaxed binding rules (e.g., DB_URL maps to db.url)
• Use @ConfigurationProperties for type-safe binding and @Value for simple injection
• Profile-specific files (application-dev.properties) allow environment-specific overrides
• Never hardcode secrets; use environment variables or external vaults like HashiCorp Vault
Think of a restaurant kitchen. The head chef has a master recipe book (application.properties) with default ingredients. But when a VIP guest with allergies arrives, the waiter passes a note (environment variable) to override the recipe – use almond milk instead of regular milk. Spring Boot works the same: it starts with defaults, then lets runtime environment variables override settings without changing code.
Every production system I've worked on – from a SaaS billing platform processing $2M/month to a real-time analytics pipeline handling 50K events/sec – has one thing in common: configuration must change without code deploys. Hardcoding database URLs, API keys, or feature flags is a direct ticket to a pager-duty nightmare. Spring Boot's externalized configuration is your shield. It allows you to define defaults in application.properties, then override them with environment variables, command-line arguments, or profile-specific files at runtime. The Spring Environment abstraction merges these sources in a well-defined priority order: command-line args beat environment variables, which beat application.properties. This means you can ship the same artifact to dev, staging, and prod, and each environment configures itself through environment variables. But there's a catch – relaxed binding rules can trip you up. DB_URL becomes db.url, but DB_HOST becomes db.host. If you don't understand the binding rules, you'll waste hours debugging why my-app.database.url isn't overriding myapp.database.url. In this guide, I'll walk you through the mechanics, show you production patterns, and share war stories from incidents I've debugged at 3 AM. By the end, you'll externalize configuration like a senior engineer who's been burned one too many times.
Understanding Spring Boot's Property Resolution Order
Spring Boot merges configuration from multiple sources in a strict priority order. The highest priority wins. Here's the hierarchy from top (highest) to bottom (lowest): command-line arguments (--server.port=8081), JNDI attributes, system properties (-Dserver.port=8081), OS environment variables, application-{profile}.properties, application.properties, and @PropertySource on configuration classes. This means you can set a default port in application.properties as 8080, but if the production environment sets SERVER_PORT=8081 as an environment variable, Spring Boot will use 8081. The key insight: environment variables are case-insensitive and use relaxed binding. For example, SPRING_DATASOURCE_URL maps to spring.datasource.url. But be careful: MYAPP_DATABASE_URL maps to myapp.database.url (note the dot conversion from underscore). This is where most developers get confused. The binding rules convert uppercase to lowercase, replace underscores with dots, and remove hyphens. So MY-APP_DATABASE_URL becomes myapp.database.url. If you have a property like myapp.database-url, the environment variable MYAPP_DATABASEURL (no hyphen) won't work – you need MYAPP_DATABASE_URL. Always test your overrides by logging the resolved value.
What the Official Docs Won't Tell You
The official Spring Boot documentation covers the basics well, but it glosses over the painful realities. First, environment variables from Docker or Kubernetes are injected as strings – even if you set SERVER_PORT=8081, Spring Boot treats it as a string and converts it internally. But if you have a custom property like myapp.feature.flag=true, the environment variable MYAPP_FEATURE_FLAG with value "true" (with quotes) will be a literal string "true" (including quotes), not a boolean. This happens when you use Docker Compose or Kubernetes ConfigMaps incorrectly. Second, the @Value annotation is fine for simple cases, but it doesn't support validation or complex types. I've seen teams use @Value("${myapp.database.url}") and then wonder why the application fails at startup with a missing property – the error message is cryptic. Third, profile-specific environment variables are a trap. You might set SPRING_PROFILES_ACTIVE=prod in the environment, but if you also have application-prod.properties with conflicting values, the profile-specific file wins over environment variables for the properties it defines. This leads to debugging nightmares. The fix: use @ConfigurationProperties with validation, log all resolved values, and never mix profile-specific files with environment variables for the same property.
MYAPP_FLAG: "false" (string) instead of MYAPP_FLAG: "false" (the quotes were part of the value). Spring Boot parsed it as a non-null string, so it was truthy. Always trim quotes in environment variable values.Using @Value vs @ConfigurationProperties for Environment Variables
The choice between @Value and @ConfigurationProperties depends on complexity. @Value is simple and works for single values: @Value("${myapp.api.key}") private String apiKey;. But it has limitations: no validation, no type conversion beyond basics, and it's hard to test because you can't easily mock the property source. @ConfigurationProperties is the enterprise-grade approach. It binds a group of related properties to a POJO with full validation support via Jakarta Bean Validation annotations. For example, you can define a PaymentGatewayProperties class with @NotBlank on the API key and @Min(1) on the retry count. The binding happens at startup, so failures are immediate and explicit. For environment variables, @ConfigurationProperties uses the same relaxed binding: PAYMENT_GATEWAY_API_KEY maps to payment.gateway.api-key if you set the prefix to payment.gateway. I recommend @ConfigurationProperties for any group of 2+ related properties. For single, simple properties (like a feature flag), @Value is fine – but always provide a default: @Value("${myapp.feature.new-checkout:false}"). One more thing: @Value doesn't support SpEL with environment variables directly – you need to use the Environment object for dynamic lookups.
Profile-Specific Configuration with Environment Variables
Spring Boot profiles allow you to define different configurations for different environments. You can have application-dev.properties, application-staging.properties, and application-prod.properties. But the real power comes from combining profiles with environment variables. Set the active profile via SPRING_PROFILES_ACTIVE environment variable. Then, environment variables can override profile-specific properties. For example, if application-prod.properties has myapp.database.url=jdbc:mysql://prod-db:3306/payments, you can override it with MYAPP_DATABASE_URL environment variable. The priority is: environment variable > profile-specific file > default file. This is useful for temporary overrides during incidents. But there's a common trap: if you define the same property in both application.properties and application-prod.properties, the profile-specific file wins over environment variables for that property? No – environment variables still win over profile-specific files. The confusion arises because developers often put all prod config in application-prod.properties and then try to override with environment variables, which works. But if you have a default in application.properties and a profile-specific override, the environment variable still wins. The real issue is when you have multiple profiles active – environment variables apply globally, not per profile. Use @Profile on beans to conditionally load components based on active profiles.
Security Best Practices for Sensitive Configuration
Never, ever hardcode secrets in application.properties or environment variables in plain text. I've seen production database passwords in Git history – it's a career-limiting move. For Spring Boot, use environment variables for secrets, but ensure they are injected securely. In Kubernetes, use Secrets (not ConfigMaps) for sensitive data. In Docker Compose, use .env files that are excluded from version control. For cloud platforms, use their secret management services (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager). Spring Boot can integrate with these via Spring Cloud Config or custom PropertySource implementations. Another pattern: use environment variables to point to a secrets file or vault path. For example, MYAPP_SECRETS_PATH=/etc/secrets and then read the file at startup. Avoid passing secrets as command-line arguments – they are visible in process listings. Also, never log resolved secrets. I've seen teams log the entire configuration at startup, including passwords. Use a custom EnvironmentPostProcessor to mask sensitive values before logging. Finally, consider using Spring Cloud Vault for dynamic secrets that rotate automatically. It's overkill for small projects, but for enterprise systems handling PCI or PII data, it's mandatory.
Debugging Environment Variable Overrides in Production
When things go wrong – and they will – you need tools to debug configuration. Spring Boot Actuator exposes the /actuator/env endpoint that shows all property sources and their values. But in production, you should secure this endpoint with authentication and ideally mask sensitive values. You can also use the /actuator/configprops endpoint to see @ConfigurationProperties beans with their resolved values. For real-time debugging, I've used a custom endpoint that dumps the resolved configuration for a specific prefix. Another technique: add a startup listener that logs the effective configuration for critical properties. This is especially useful when you have multiple property sources and want to verify the override chain. For Kubernetes environments, use kubectl exec to check environment variables inside the pod: kubectl exec <pod> -- env | grep MYAPP. But remember that environment variables set by Kubernetes may differ from what Spring Boot resolves due to relaxed binding. The most reliable way is to use the /actuator/env endpoint or a custom health check that logs resolved values. One more thing: if you use Spring Cloud Config, the configuration is fetched at startup and cached. Environment variables won't override remote config unless you configure the priority correctly. I've spent hours debugging why an environment variable wasn't taking effect – turned out the remote config server had a higher priority.
Environment Variables in Docker and Kubernetes
Containerized environments add another layer of complexity. In Docker, you can pass environment variables via -e flag or an env file: docker run -e MYAPP_DATABASE_URL=jdbc:mysql://db:3306/app myapp:latest. In Docker Compose, use the environment key or env_file. In Kubernetes, use ConfigMaps for non-sensitive data and Secrets for sensitive data. Both can be injected as environment variables or mounted as files. The key difference: environment variables in Kubernetes are static – they are set when the pod starts and cannot change without restarting the pod. For dynamic configuration, use mounted files that a sidecar can update. Spring Boot can watch for file changes using spring.config.import=optional:file:/etc/config/ with spring.cloud.kubernetes.config enabled. Another gotcha: Kubernetes environment variable names must follow the DNS-1123 label format – uppercase letters, digits, and underscores only. No hyphens. So MYAPP-DATABASE-URL is invalid. Use MYAPP_DATABASE_URL instead. Also, if you use a ConfigMap with data field, the values are plain strings. If you use binaryData, they are base64-encoded. For Secrets, the values are base64-encoded in the YAML but decoded when injected as environment variables. Always verify the final value inside the pod.
Advanced: Custom Property Sources and Spring Cloud Config
Sometimes environment variables aren't enough. You might need to fetch configuration from a remote source like a database, a REST API, or a vault. Spring Boot allows you to implement custom PropertySource objects that are added to the Environment. For example, you can create a DatabasePropertySource that reads configuration from a database table. Or integrate with Spring Cloud Config Server, which centralizes configuration for multiple microservices. Spring Cloud Config uses a Git backend by default, but you can also use JDBC, Redis, or Vault. The key concept: custom property sources can be ordered relative to environment variables. If you want your database configuration to override environment variables, add it with higher priority. But be careful: if you have multiple sources with the same property, the highest priority wins. I've seen teams create a custom source that loads secrets from AWS Secrets Manager and then wonder why environment variables don't override them – because the custom source was added with higher priority. Always define your priority strategy explicitly. Another advanced pattern: use @ConditionalOnProperty to conditionally enable beans based on environment variables. For example, enable a feature only if MYAPP_FEATURE_NEW_CHECKOUT=true. This is cleaner than if-else in code.
The 3 AM Database Connection Blowup
spring.datasource.url=jdbc:mysql://localhost:3306/payments. In staging, the environment variable SPRING_DATASOURCE_URL was set correctly. But in production, the DevOps engineer set SPRING_DATASOURCE_URL (note the missing 'R') due to a typo in the CI/CD configuration. Spring Boot's relaxed binding didn't catch it because the misspelled variable didn't match any property, so it fell back to the default localhost.EnvironmentPostProcessor to warn on unrecognized properties.- Always log resolved configuration at startup to verify overrides are working
- Use a validation mechanism to detect misspelled or missing environment variables
- Never assume environment variables are set correctly – automate verification
kubectl exec <pod> -- env | grep MYAPPcurl -s http://localhost:8080/actuator/env | jq '.propertySources[] | select(.name=="systemEnvironment")'| File | Command / Code | Purpose |
|---|---|---|
| PropertyResolutionDemo.java | @Component | Understanding Spring Boot's Property Resolution Order |
| ConfigurationPropertiesDemo.java | @Validated | What the Official Docs Won't Tell You |
| ValueVsConfigurationProperties.java | @Component | Using @Value vs @ConfigurationProperties for Environment Var |
| ProfileConfigurationDemo.java | @Configuration | Profile-Specific Configuration with Environment Variables |
| SecureConfigurationDemo.java | public class SecureEnvironmentPostProcessor implements EnvironmentPostProcessor ... | Security Best Practices for Sensitive Configuration |
| EnvDebugEndpoint.java | @Component | Debugging Environment Variable Overrides in Production |
| kubernetes-deployment.yaml | apiVersion: apps/v1 | Environment Variables in Docker and Kubernetes |
| CustomPropertySourceDemo.java | @Component | Advanced |
Key takeaways
Interview Questions on This Topic
Explain the property resolution order in Spring Boot. Which source has the highest priority?
--server.port=8081 have the highest priority, so they override all other sources.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't