deployhealth

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’s System.getenv()["NAME"]

Config, a Java class and a Kotlin one, with a .env.example that declares three variables:

.env.example
DATABASE_URL=
DB_PASSWORD=
STRIPE_API_KEY=
src/main/java/com/acme/billing/StripeConfig.java
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");
}
src/main/kotlin/com/acme/Search.kt
package com.acme

val searchUrl: String? = System.getenv()["SEARCH_URL"]
src/main/resources/application.yml
spring:
  datasource:
    url: ${DATABASE_URL}
    password: ${DB_PASSWORD}

What deployhealth reports

  • STRIPE_API_KEY src/main/java/com/acme/billing/StripeConfig.java:8 · required
  • REDIS_URL src/main/java/com/acme/billing/StripeConfig.java:10 · required
  • SEARCH_URL src/main/kotlin/com/acme/Search.kt:3 · required
  • DATABASE_URL src/main/resources/application.yml:3 · required
  • DB_PASSWORD src/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:

.env.example
APP_MAIL_HOST=
CACHE_TTLSECONDS=
OLD_FEATURE_FLAG=
SPRING_PROFILES_ACTIVE=
src/main/java/com/acme/mail/MailProperties.java
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");
}
src/main/kotlin/com/acme/Logging.kt
package com.acme

val logLevel: String = System.getenv("LOG_LEVEL") ?: "info"
src/main/resources/application.yml
app:
  mail:
    from: ${MAIL_FROM:[email protected]}
cache:
  ttl-seconds: 300

What deployhealth reports

  • MAIL_REGION src/main/java/com/acme/mail/MailProperties.java:7 · optional (has a default)
  • LOG_LEVEL src/main/kotlin/com/acme/Logging.kt:3 · optional (has a default)
  • MAIL_FROM src/main/resources/application.yml:3 · optional (has a default)

UNUSED OLD_FEATURE_FLAG

What it skips

  • build output: target/ beside a pom.xml or 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-tests reads them)
  • Gradle’s *.gradle.kts build scripts: their env reads are CI and publishing secrets that don’t belong in .env.example

Only the placeholder in application.yml counts here:

.env.example
DATABASE_URL=
build.gradle.kts
val signingKey: String? = System.getenv("SIGNING_KEY")
build/generated/Config.java
import org.springframework.context.annotation.Configuration;
class Generated { String url = System.getenv("GENERATED_URL"); }
src/main/resources/application.yml
spring:
  datasource:
    url: ${DATABASE_URL}
src/test/resources/application-test.yml
spring:
  datasource:
    url: ${TEST_DATABASE_URL}

What deployhealth reports

  • DATABASE_URL src/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 @ConfigurationProperties prefix 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’s build.gradle isn’t read.

A computed name reads nothing:

src/main/java/com/acme/Flags.java
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
# .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:

terminal
npx -y deployhealth-scan --dry-run

Other stacks