Configuration, Profiles & Actuator

Secrets & Environment Variables

18 min Lesson 5 of 13

Secrets & Environment Variables

Every production application has secrets: database passwords, API keys, JWT signing keys, third-party service tokens. How you handle those secrets is one of the highest-impact security decisions you make as a developer. This lesson covers the full spectrum — from the most common mistakes to the patterns used by professional teams deploying Spring Boot 3 applications.

The Core Problem: Secrets in Source Code

The most common mistake is hardcoding credentials directly in application.properties and committing that file to version control. Once a secret reaches a Git repository, it is effectively public — even in private repos, because history persists and ex-employees, compromised accounts, or accidental exposure can all leak it.

Never commit real secrets to Git. GitHub, GitLab, and Bitbucket all have secret-scanning bots. Cloud providers regularly see AWS keys committed and used within minutes. Treat any secret that has touched a public repo as permanently compromised — revoke and rotate it immediately.

Here is the pattern you should never use in anything beyond a local demo:

# application.properties — DO NOT do this in production spring.datasource.password=mySuperSecretPassword123 stripe.api-key=sk_live_abc123xyz

Environment Variables: The Twelve-Factor Foundation

The Twelve-Factor App methodology defines the canonical solution: store config that varies between environments (dev, staging, prod) in environment variables. Spring Boot reads OS environment variables automatically and maps them to property keys using a relaxed binding rule: SPRING_DATASOURCE_PASSWORD maps to spring.datasource.password, and STRIPE_API_KEY maps to stripe.api-key.

Relaxed binding: Spring Boot normalises property names by converting to lowercase and replacing underscores, hyphens, and dots. So MY_APP_API_KEY, my.app.api-key, and myApp.apiKey all bind to the same property. Prefer SCREAMING_SNAKE_CASE for environment variables — it is the Unix convention and unambiguous in shell scripts.

On a Linux or macOS server you export variables before launching the JVM:

export DB_PASSWORD="vault:retrieved-at-deploy" export STRIPE_API_KEY="sk_live_abc123xyz" java -jar app.jar

In application.properties you reference them with ${} placeholders:

spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mydb} spring.datasource.username=${DB_USER} spring.datasource.password=${DB_PASSWORD} stripe.api-key=${STRIPE_API_KEY}

The colon-separated value (${DB_HOST:localhost}) is the default if the variable is absent. Use defaults for non-sensitive values like hostnames in development, but never provide a default for a secret — a missing secret should cause a startup failure so you notice it immediately rather than silently using a wrong value.

The .env File and Spring Profiles

For local development, typing export commands before every run is tedious. The conventional solution is a .env file at the project root:

# .env — LOCAL DEVELOPMENT ONLY — never commit this file DB_PASSWORD=localdevpassword STRIPE_API_KEY=sk_test_... JWT_SECRET=dev-only-secret-32-chars-minimum

Add .env to .gitignore immediately. Spring Boot does not read .env natively, but several approaches handle it:

  • IDE run configurations — IntelliJ IDEA and VS Code let you specify an env file in the run config. This is the cleanest local approach.
  • dotenv-spring-boot — a thin library (me.paulschwarz:spring-dotenv) that loads .env into Spring's Environment automatically.
  • Shell sourcingset -a; source .env; set +a; ./mvnw spring-boot:run exports every line before invoking Maven.
Provide a .env.example file in the repository. It lists every required variable with a placeholder value and a comment explaining what each one is. New team members copy it to .env and fill it in. This documents requirements without leaking secrets.
# .env.example — commit this file DB_HOST=localhost DB_PORT=5432 DB_NAME=myapp DB_USER=postgres DB_PASSWORD= # set to your local Postgres password STRIPE_API_KEY= # get from https://dashboard.stripe.com/test/apikeys JWT_SECRET= # generate: openssl rand -hex 32

Reading Secrets Programmatically with @Value and @ConfigurationProperties

Once a variable is in the environment (or in application.properties via a placeholder), Spring resolves it automatically. You can inject it with @Value:

import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service public class StripePaymentService { private final String apiKey; public StripePaymentService(@Value("${stripe.api-key}") String apiKey) { this.apiKey = apiKey; } public void charge(long amountCents, String token) { // use this.apiKey — never log it } }

Or bind a group of related secrets to a @ConfigurationProperties class (covered in depth in lesson 2, but worth a reminder here for secrets):

import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "stripe") public record StripeProperties(String apiKey, String webhookSecret) {}
Never log secrets — not even partially. Logging frameworks, distributed tracing systems, and error reporters (Sentry, Datadog) all capture log lines. A log statement like log.debug("API key: {}", apiKey) in a service called on every request will spray your secret into every log aggregator your organisation uses.

Secrets in Containerised Environments (Docker & Kubernetes)

When running in Docker, pass environment variables at container startup:

docker run \ -e DB_PASSWORD="$(vault kv get -field=password secret/myapp/db)" \ -e STRIPE_API_KEY="sk_live_..." \ -p 8080:8080 \ myapp:latest

In Docker Compose for development, use an env_file directive:

# docker-compose.yml services: app: image: myapp:latest env_file: - .env # reads your local .env, never committed ports: - "8080:8080"

In Kubernetes, the idiomatic mechanism is a Secret object mounted as environment variables or a volume. Never bake secrets into a Docker image or a ConfigMap (which is not encrypted at rest).

# kubernetes secret (base64-encoded values) apiVersion: v1 kind: Secret metadata: name: myapp-secrets type: Opaque data: db-password: bXlwYXNzd29yZA== # echo -n 'mypassword' | base64 --- # deployment excerpt env: - name: DB_PASSWORD valueFrom: secretKeyRef: name: myapp-secrets key: db-password

External Secrets Managers

For teams that need audit trails, automatic rotation, and fine-grained access control, a dedicated secrets manager is the right tool. The main options integrate with Spring Boot through Spring Cloud:

  • HashiCorp Vault — on-premises or cloud; spring-cloud-vault reads secrets at startup and injects them as properties.
  • AWS Secrets Manager / Parameter Storespring-cloud-aws resolves aws.secretsmanager.secret-name properties automatically.
  • Azure Key Vaultazure-spring-cloud-starter-keyvault-secrets maps vault entries to Spring properties.

With Spring Cloud Vault, your bootstrap.yml (or application.yml with the right import) is as simple as:

spring: cloud: vault: host: vault.internal.example.com port: 8200 authentication: APPROLE app-role: role-id: ${VAULT_ROLE_ID} secret-id: ${VAULT_SECRET_ID} kv: enabled: true default-context: myapp

Spring Boot then resolves ${stripe.api-key} by fetching the myapp secret path from Vault at startup — the secret never touches the filesystem or a properties file.

Summary: The Secret-Handling Checklist

  1. Add .env and any *-secrets.properties files to .gitignore before your first commit.
  2. Use ${ENV_VAR} placeholders in committed application.properties — no hardcoded values.
  3. Provide .env.example with comments but no real values.
  4. Never provide a default value for a secret placeholder (fail loudly when it is missing).
  5. Never log secret values — not even in DEBUG.
  6. In production, inject via OS environment variables, Docker secrets, Kubernetes Secrets, or a secrets manager.
  7. Rotate secrets that were ever accidentally exposed — treat them as permanently compromised.

Following these rules costs almost no extra effort and prevents the most common category of production security incidents. The next lesson builds on this foundation to cover the full YAML configuration syntax that makes complex Spring Boot config readable and maintainable.