How to catch a missing env var in Spring Boot before it reaches production
Spring Boot refuses to start when a ${DATABASE_URL} placeholder has no value, but only on the machine that’s missing it, and that’s often production.
deployhealth compares the variables your code and application.yml read with the ones your env files declare, on every pull request and every push, and names each gap with its file and line. It reads names and line numbers only, never values.
What deployhealth reads in a Spring Boot app
In .java, .kt and .kts files, and in application* and bootstrap* .properties, .yml and .yaml files at any depth, on one line each:
${NAME}placeholders in that config, for UPPER_SNAKE names only (${server.port}is a property, not an env var)@Value("${NAME}")in Java, and@Value("\${NAME}")in Kotlin (a bare${…}is Kotlin string interpolation)System.getenv("NAME"),System.getenv().get("NAME")and Kotlin’sSystem.getenv()["NAME"]
Config, a Java class and a Kotlin one, with a .env.example that declares three variables:
DATABASE_URL=
DB_PASSWORD=
STRIPE_API_KEY=
package com.acme.billing;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StripeConfig {
@Value("${STRIPE_API_KEY}") private String apiKey;
private final String redisUrl = System.getenv("REDIS_URL");
}
package com.acme
val searchUrl: String? = System.getenv()["SEARCH_URL"]
spring:
datasource:
url: ${DATABASE_URL}
password: ${DB_PASSWORD}
What deployhealth reports
STRIPE_API_KEYsrc/main/java/com/acme/billing/StripeConfig.java:8 · requiredREDIS_URLsrc/main/java/com/acme/billing/StripeConfig.java:10 · requiredSEARCH_URLsrc/main/kotlin/com/acme/Search.kt:3 · requiredDATABASE_URLsrc/main/resources/application.yml:3 · requiredDB_PASSWORDsrc/main/resources/application.yml:4 · required
MISSING REDIS_URL, SEARCH_URL
Defaults, OPTIONAL and relaxed binding
A default means the read can’t be MISSING; when no env file declares it, the scan lists it as OPTIONAL. Defaults are ${NAME:default} (and a placeholder inside another’s default), System.getenv().getOrDefault("NAME", "x"), Kotlin’s ?: "x" and Optional.ofNullable(…).orElse("x") on the same line. null isn’t a default, and neither are ?: throw, error(…) or requireNotNull.
Spring also binds env vars to properties without a placeholder (relaxed binding). In a directory where some Java or Kotlin source mentions org.springframework, a declared name counts as used, never UNUSED, when it’s the env form of a key your config sets (cache.ttl-seconds → CACHE_TTLSECONDS), starts with SPRING_, SERVER_, MANAGEMENT_ or LOGGING_, or starts with the env form of a @ConfigurationProperties prefix. Those are never reads, so they’re never MISSING either.
Defaults and relaxed binding together; only OLD_FEATURE_FLAG is reported:
APP_MAIL_HOST=
CACHE_TTLSECONDS=
OLD_FEATURE_FLAG=
SPRING_PROFILES_ACTIVE=
package com.acme.mail;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(String host, String from) {
static final String REGION = System.getenv().getOrDefault("MAIL_REGION", "eu");
}
package com.acme
val logLevel: String = System.getenv("LOG_LEVEL") ?: "info"
app:
mail:
from: ${MAIL_FROM:[email protected]}
cache:
ttl-seconds: 300
What deployhealth reports
MAIL_REGIONsrc/main/java/com/acme/mail/MailProperties.java:7 · optional (has a default)LOG_LEVELsrc/main/kotlin/com/acme/Logging.kt:3 · optional (has a default)MAIL_FROMsrc/main/resources/application.yml:3 · optional (has a default)
UNUSED OLD_FEATURE_FLAG
What it skips
- build output:
target/beside apom.xmlor Gradle build file,build/beside a Gradle build file, and.gradle/and.mvn/anywhere - tests and test tooling:
src/test/,*Test.java,*Tests.kt,*IT.java,application-test.yml(--include-testsreads them) - Gradle’s
*.gradle.ktsbuild scripts: their env reads are CI and publishing secrets that don’t belong in.env.example
Only the placeholder in application.yml counts here:
DATABASE_URL=
val signingKey: String? = System.getenv("SIGNING_KEY")
import org.springframework.context.annotation.Configuration;
class Generated { String url = System.getenv("GENERATED_URL"); }
spring:
datasource:
url: ${DATABASE_URL}
spring:
datasource:
url: ${TEST_DATABASE_URL}
What deployhealth reports
DATABASE_URLsrc/main/resources/application.yml:3 · required
Known limits
- A placeholder or read split across lines, or a computed name (
System.getenv(name)), isn’t seen. - Properties set only on the platform, profiles activated only there, a
@ConfigurationPropertiesprefix held in a constant, and keys inside YAML list items aren’t known to relaxed binding. - Rule (b) of relaxed binding can hide a genuinely unused
SERVER_…name. Groovy’sbuild.gradleisn’t read.
A computed name reads nothing:
package com.acme;
class Flags {
static String flag(String name) { return System.getenv(name); }
}
What deployhealth reports
No reads.
Set it up
Pull request checks
Sign in with GitHub, add a project for the repository (owner/repo) and install the deployhealth GitHub App from the project's settings. Every pull request that reads an env var no .env.example declares gets a comment and a check. No token, no secret, no workflow.
Deploy history and alerts
Add the project's ingest token as the repository secret DEPLOYHEALTH_TOKEN (a secret, not a variable) and scan every push. The project's settings page gives you a workflow that runs the CLI pinned to an exact version, npx --yes [email protected], which you can also run from any other CI.
Or use the deployhealth env check Action:
# .github/workflows/deployhealth.yml
name: deployhealth
on:
push:
branches: [main]
jobs:
env-scan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: Utsav-mishra-25/deployhealth-action@v1
with:
token: ${{ secrets.DEPLOYHEALTH_TOKEN }}
@v1 follows the latest 1.x release and can move; pin a full commit SHA if you want it fixed. An npm version never changes once published.
Try it now, without signing up
From your repository's root. It prints MISSING, UNUSED and MISMATCH with file and line in your terminal, and sends nothing:
npx -y deployhealth-scan --dry-run