Hello! Welcome back.
In our last lesson, we made our Docker image more robust by embedding a HEALTHCHECK instruction. This makes our container self-aware, capable of reporting its own status to a container runtime. We now have a reliable, self-contained building block.
But how do we run this same image in different environments—your local machine, a staging server, or a production cluster in AWS—without rebuilding it each time? The key is to separate the configuration from the application code.
Today's lesson directly addresses this challenge. Our learning outcome is to configure a Spring Boot application to correctly read environment variables and secrets passed by a container runtime. This is a fundamental skill for building cloud-native applications and aligns with the "Config" principle of the twelve-factor app methodology you learned about in Module 1. Mastering this concept is crucial for discussing production-ready systems in a mid-senior level interview.
1. The "Why": Separating Configuration from Code
The core principle is that an application's codebase should be identical across all deployments, while the configuration is the only thing that changes. This includes things like:
- Database connection details (host, username, password)
- URLs for other services
- API keys for third-party integrations
- Tuning parameters (pool sizes, cache timeouts)
Forcing these values into the environment, rather than hardcoding them or placing them in version-controlled files, gives us deployment flexibility and enhances security.
A great way to think about this is to categorize your configuration.
99% of Spring Boot Developers Get Configuration Wrong
The article '99% of Spring Boot Developers Get Configuration Wrong' provides an excellent mental model for classifying configuration. Please read the first two sections, 'The 4 Types of Configuration' and 'Infrastructure Configuration'.
Focus on the distinction between Secrets, Infrastructure Configuration, and Application Behavior. Pay close attention to the 'Why' for each category and the critical distinction between a logical 'Environment' (dev, prod) and a physical 'Deployment' (your laptop, an AWS region).
To summarize the key idea from the article, which is a common best practice:
- Secrets (passwords, API keys): NEVER in code or Git. These must be injected at runtime via environment variables or a dedicated secrets management tool (like HashiCorp Vault or Kubernetes Secrets).
- Infrastructure Configuration (DB hosts, service URLs): These depend on where the application is deployed. They also belong in environment variables, often managed by the container platform (like Docker Compose or Kubernetes ConfigMaps).
- Application Behavior (pool sizes, feature flags): These depend on the logical environment (dev vs. prod). These are suitable for
application-{profile}.ymlfiles, which can be stored in Git.
This separation allows you to build a single, portable Docker image (build once) that can be promoted through different environments by simply providing the correct external configuration (deploy everywhere).
2. The "How": Spring Boot's Configuration Precedence
So, how does Spring Boot know which configuration value to use if a property is defined in multiple places? It follows a strict order of precedence. Understanding this hierarchy is essential for debugging configuration issues and is a frequent interview topic.
Let's look at the two main resources for this. First, a practical overview, then the official documentation.
99% of Spring Boot Developers Get Configuration Wrong
The same article provides a simplified, practical view of the configuration hierarchy. Please read the sections 'Spring Boot’s Configuration Hierarchy (Official)' and 'How Configuration Merges (Step-by-Step)'.
The key takeaway is that environment variables win over properties defined in your application.yml files. The step-by-step merge example clearly illustrates this.
For a complete and authoritative list, we can turn to the official Spring Boot documentation.
Externalized Configuration :: Spring Boot
The official Spring Boot documentation provides the definitive list of property sources and their order. Please review the introduction and the ordered list at the beginning of this document.
You don't need to memorize the entire list of 15+ sources, but locate 'OS environment variables' (item 5) and 'Config data' (item 3, which includes application.properties files). Notice how environment variables appear after config files, which means they have a higher precedence and can override them. (Note: The numbering in the documentation might seem counter-intuitive; sources later in the list override earlier ones).
Mapping Properties to Environment Variables
There's one more crucial detail: how does a property like spring.datasource.url get mapped to an environment variable? Operating systems have restrictions on variable names (e.g., you can't use dots). Spring Boot's relaxed binding automatically handles this. The rules are simple:
- Replace dots (
.) with underscores (_). - Remove dashes (
-). - Convert to uppercase.
So, spring.datasource.url becomes SPRING_DATASOURCE_URL, and my.api-key becomes MY_APIKEY.
3. Practical Implementation
Let's see how to apply this in code. We want to externalize our database configuration.
Step 1: Use Placeholders in application.properties
First, modify your application.properties (or .yml) to use placeholders for the values you want to externalize. The ${...} syntax tells Spring Boot to resolve this value from another property source, like an environment variable.
src/main/resources/application.properties:
# Database connection settings
spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
# Application behavior settings (can stay here)
spring.datasource.hikari.maximum-pool-size=20
Step 2: Provide Values via the Container Runtime
Now, you can provide the actual values when you run your container. Here's how it looks in a docker-compose.yml file:
docker-compose.yml:
version: '3.8'
services:
my-app:
image: my-company/my-app:1.0.0
ports:
- "8080:8080"
environment:
- DB_HOST=mysql_db
- DB_PORT=3306
- DB_NAME=mydatabase
- DB_USER=user
- DB_PASSWORD=secret_password # In a real project, this would come from a secrets tool
mysql_db:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=mydatabase
- MYSQL_USER=user
- MYSQL_PASSWORD=secret_password
When the my-app container starts, Spring Boot will:
- Read
spring.datasource.urlfromapplication.properties. - See the placeholder
${DB_HOST}. - Look for a property named
DB_HOSTin higher-precedence locations. - Find the
DB_HOSTenvironment variable and substitute its value (mysql_db). - Repeat for all placeholders, constructing the final property values.
Step 3 (Best Practice): Type-Safe Configuration with @ConfigurationProperties
While using placeholders works, it can get messy. A cleaner, type-safe, and more maintainable approach is to use the @ConfigurationProperties annotation. This binds a whole group of properties to a Java object.
Externalized Configuration :: Spring Boot
Please read the sections on 'Type-safe Configuration Properties' and 'Relaxed Binding' from the Spring Boot documentation. This is the recommended way to handle configuration in modern Spring applications.
Focus on the 'JavaBean Properties Binding' example. See how the @ConfigurationProperties("my.service") annotation creates a prefix. Then, understand from the 'Relaxed Binding' section how a property like firstName in Java can be set by an environment variable like MY_MAINPROJECT_PERSON_FIRSTNAME.
Here's a concrete example for our database:
-
Create a Properties Class:
import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "spring.datasource") public class DataSourceProperties { private String url; private String username; private String password; // Standard getters and setters... public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } -
Enable it in your main application class:
import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication @EnableConfigurationProperties(DataSourceProperties.class) public class MyApplication { public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } }
Now, Spring Boot will automatically populate an instance of DataSourceProperties using environment variables like SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, etc., thanks to relaxed binding. Your application.properties file can be completely empty of these secrets.
Test your understanding!
Your application has the following configurations:
-
src/main/resources/application.yml:logging: level: root: INFO -
docker-compose.yml:services: my-app: image: my-app:latest environment: - LOGGING_LEVEL_ROOT=WARN -
You start the container with an extra command-line argument:
docker-compose up
But the entrypoint in the Dockerfile is:CMD ["java", "-jar", "app.jar", "--logging.level.root=DEBUG"]
What will be the effective root logging level when the application starts, and why?
Show answer
The effective logging level will be DEBUG.
Here's the precedence order from highest to lowest:
- Command-line arguments:
--logging.level.root=DEBUG(Highest priority) - OS Environment variables:
LOGGING_LEVEL_ROOT=WARN - Application configuration file:
logging.level.root: INFO(Lowest priority)
Since command-line arguments have the highest precedence, DEBUG overrides both the environment variable (WARN) and the application.yml setting (INFO).
4. Advanced: Reading from Mounted Volumes (Kubernetes configtree)
In Kubernetes, the preferred way to handle secrets and configs is often not with environment variables, but by mounting them as files into the container's filesystem. This prevents secrets from being exposed via container inspection commands.
Spring Boot has a powerful feature for this using the configtree: prefix.
Externalized Configuration :: Spring Boot
To prepare for your work with Kubernetes, it's vital to understand how Spring Boot can consume configuration from mounted volumes. Please read the section 'Using Configuration Trees'.
Pay close attention to the example where a volume is mounted at /etc/config/myapp/. Notice how a file named username inside that directory automatically becomes the property myapp.username. This mechanism is designed specifically for platforms like Kubernetes.
In short, if a Kubernetes Secret creates a file at /etc/secrets/db-password, you can add this to your application.properties:
spring.config.import=optional:configtree:/etc/secrets/
Spring Boot will then read the contents of the db-password file and make it available as the db.password property in the application Environment. This is a highly secure and common pattern in production Kubernetes deployments.
Conclusion
Today, we've connected our containerized application to its runtime environment, a critical step towards production readiness. By externalizing configuration, we create portable and secure services.
Key Takeaways:
- Separate config from code: Store secrets and infrastructure-specific values in the environment, not in your Git repository.
- Environment variables are king: Spring Boot's configuration hierarchy ensures that environment variables and command-line arguments override values in
application.yml. - Use placeholders and
@ConfigurationProperties: Use${...}placeholders for simple substitution and adopt@ConfigurationPropertiesfor type-safe, maintainable configuration binding. - Understand relaxed binding: Know how
my.property-namemaps toMY_PROPERTY_NAME. - Prepare for Kubernetes: Be aware of the
configtree:mechanism for securely reading configuration and secrets from mounted files.
In our next lesson, we will learn how to build and push a Docker image to a container registry. With a configurable, health-checked image ready, the next logical step is to store it in a centralized, versioned repository so it can be deployed by orchestration tools.
Can't find a good explanation? Sign up and we'll make it for you
Sign up