How to check if env variable exists

Validate the whole of process.env against a schema at startup and exit non-zero listing every missing key: node require-env.mjs prints DB_URL: Invalid input: expected string, received undefined and stops. The alternative fails inside a request hours later, which the 500 in step 1 shows.

Why check this

Run this on every service before a test environment is handed over, and again in the deploy job. A test environment is where variables are set by hand most often and reviewed least, so it is where a missing one hides longest.

The failure it prevents is a deploy that reports success. The service starts, health checks pass, and the first request that touches the database returns a 500 naming an internal variable. In a low-traffic staging environment that request can arrive hours after the deploy, long after anyone is still watching the pipeline.

Prerequisites

cat > readenv.mjs <<'EOF'
for (const name of ['DB_URL', 'API_TOKEN', 'EMPTY_VAR']) {
  const present = name in process.env;
  const value = present ? JSON.stringify(process.env[name]) : '(absent)';
  console.log(`${name.padEnd(10)} in process.env: ${String(present).padEnd(5)} value: ${value.padEnd(18)} truthy: ${Boolean(process.env[name])}`);
}
EOF

Steps

  1. Step 1.

    Watch a service with no startup check begin normally and fail on its first request.

    cat > lazy-server.mjs <<'EOF'
    import { createServer } from 'node:http';
    
    function db() {
      const url = process.env.DB_URL;
      if (!url) throw new Error('DB_URL is not set');
      return { url };
    }
    
    const server = createServer((req, res) => {
      try {
        res.end(JSON.stringify({ ok: true, db: db().url }));
      } catch (e) {
        res.statusCode = 500;
        res.end(JSON.stringify({ error: e.message }));
      }
    });
    
    server.listen(9710, '127.0.0.1', async () => {
      console.log('server listening on http://127.0.0.1:9710, startup finished with no error');
      const r = await fetch('http://127.0.0.1:9710/orders');
      console.log('first request:', r.status, await r.text());
      server.close();
    });
    EOF
    
    node lazy-server.mjs
    
    server listening on http://127.0.0.1:9710, startup finished with no error
    first request: 500 {"error":"DB_URL is not set"}

    The process exits 0 on startup and the monitoring that watches for a crash sees nothing.

  2. Step 2.

    Check how a variable reaches the child process, because assigning it in the shell is not enough.

    echo '--- 1. nothing set ---'
    node readenv.mjs
    echo '--- 2. assigned in bash, not exported ---'
    DB_URL=postgres://fake
    node readenv.mjs
    echo '--- 3. exported, EMPTY_VAR exported with no value ---'
    export DB_URL=postgres://fake
    export EMPTY_VAR=
    node readenv.mjs
    echo '--- 4. inline prefix ---'
    API_TOKEN=fake-token-1 node readenv.mjs
    
    --- 1. nothing set ---
    DB_URL     in process.env: false value: (absent)           truthy: false
    API_TOKEN  in process.env: false value: (absent)           truthy: false
    EMPTY_VAR  in process.env: false value: (absent)           truthy: false
    --- 2. assigned in bash, not exported ---
    DB_URL     in process.env: false value: (absent)           truthy: false
    API_TOKEN  in process.env: false value: (absent)           truthy: false
    EMPTY_VAR  in process.env: false value: (absent)           truthy: false
    --- 3. exported, EMPTY_VAR exported with no value ---
    DB_URL     in process.env: true  value: "postgres://fake"  truthy: true
    API_TOKEN  in process.env: false value: (absent)           truthy: false
    EMPTY_VAR  in process.env: true  value: ""                 truthy: false
    --- 4. inline prefix ---
    DB_URL     in process.env: true  value: "postgres://fake"  truthy: true
    API_TOKEN  in process.env: true  value: "fake-token-1"     truthy: true
    EMPTY_VAR  in process.env: true  value: ""                 truthy: false

    Block 2 is the CI failure. DB_URL=postgres://fake on its own line is a shell variable, visible to echo $DB_URL and invisible to every child process. A script that prints the value and then fails to read it is describing this.

  3. Step 3.

    Repeat the empty-value case in the other two shells on the same machine.

    Write-Output '--- PowerShell: plain variable ---'
    $DB_URL = 'postgres://fake'; node readenv.mjs
    Write-Output '--- PowerShell: $env: variable, EMPTY_VAR set to "" ---'
    $env:DB_URL = 'postgres://fake'; $env:EMPTY_VAR = ''; node readenv.mjs
    Write-Output '--- cmd.exe: set EMPTY_VAR= ---'
    cmd /c "set DB_URL=postgres://fake&& set EMPTY_VAR=&& node readenv.mjs"
    
    --- PowerShell: plain variable ---
    DB_URL     in process.env: false value: (absent)           truthy: false
    API_TOKEN  in process.env: false value: (absent)           truthy: false
    EMPTY_VAR  in process.env: false value: (absent)           truthy: false
    --- PowerShell: $env: variable, EMPTY_VAR set to "" ---
    DB_URL     in process.env: true  value: "postgres://fake"  truthy: true
    API_TOKEN  in process.env: false value: (absent)           truthy: false
    EMPTY_VAR  in process.env: false value: (absent)           truthy: false
    --- cmd.exe: set EMPTY_VAR= ---
    DB_URL     in process.env: true  value: "postgres://fake"  truthy: true
    API_TOKEN  in process.env: false value: (absent)           truthy: false
    EMPTY_VAR  in process.env: false value: (absent)           truthy: false

    The same intent gives two different environments. Git Bash passes EMPTY_VAR through as an empty string, present in process.env. PowerShell and cmd.exe remove the key instead, so 'EMPTY_VAR' in process.env is false. A guard written with in reports a different result depending on which shell the CI job used.

  4. Step 4.

    Replace the sequence of if guards with one schema pass and compare what each reports.

    cat > naive-env.mjs <<'EOF'
    if (!process.env.DB_URL) throw new Error('DB_URL is not set');
    if (!process.env.API_TOKEN) throw new Error('API_TOKEN is not set');
    if (!process.env.PORT) throw new Error('PORT is not set');
    console.log('ok');
    EOF
    cat > require-env.mjs <<'EOF'
    import { z } from 'zod';
    
    const schema = z.object({
      DB_URL: z.string().min(1),
      API_TOKEN: z.string().min(1),
      PORT: z.coerce.number().int().positive(),
      LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
    });
    
    const result = schema.safeParse(process.env);
    
    if (!result.success) {
      console.error('Configuration check failed:');
      for (const issue of result.error.issues) {
        console.error(`  ${issue.path.join('.')}: ${issue.message}`);
      }
      process.exit(1);
    }
    
    export const config = result.data;
    console.log('Configuration check passed:', { ...config, API_TOKEN: '<redacted>' });
    EOF
    
    echo '=== naive, nothing set ==='; node naive-env.mjs; echo "exit $?"
    echo '=== zod, nothing set ==='; node require-env.mjs; echo "exit $?"
    echo '=== zod, DB_URL and API_TOKEN empty, PORT not a number ==='; DB_URL= API_TOKEN= PORT=eighty node require-env.mjs; echo "exit $?"
    echo '=== zod, all set ==='; DB_URL=postgres://fake API_TOKEN=fake-token-1 PORT=9710 node require-env.mjs; echo "exit $?"
    
    === naive, nothing set ===
    …
    Error: DB_URL is not set
    …
    exit 1
    === zod, nothing set ===
    Configuration check failed:
    DB_URL: Invalid input: expected string, received undefined
    API_TOKEN: Invalid input: expected string, received undefined
    PORT: Invalid input: expected number, received NaN
    exit 1
    === zod, DB_URL and API_TOKEN empty, PORT not a number ===
    Configuration check failed:
    DB_URL: Too small: expected string to have >=1 characters
    API_TOKEN: Too small: expected string to have >=1 characters
    PORT: Invalid input: expected number, received NaN
    exit 1
    === zod, all set ===
    Configuration check passed: {
    DB_URL: 'postgres://fake',
    API_TOKEN: '<redacted>',
    PORT: 9710,
    LOG_LEVEL: 'info'
    }
    exit 0

    The guards name one key and stop, so filling it in reveals the next one and the loop repeats three times. The schema names all three, and it separates unset (received undefined) from set to an empty string (Too small). PORT comes back as the number 9710, not as "9710".

  5. Step 5.

    Put the check in front of the server and confirm it exits before the port opens.

    cat > guarded-server.mjs <<'EOF'
    import { createServer } from 'node:http';
    import { config } from './require-env.mjs';
    
    const server = createServer((req, res) => {
      res.end(JSON.stringify({ ok: true, db: config.DB_URL }));
    });
    
    server.listen(config.PORT, '127.0.0.1', async () => {
      console.log(`server listening on http://127.0.0.1:${config.PORT}`);
      const r = await fetch(`http://127.0.0.1:${config.PORT}/orders`);
      console.log('first request:', r.status, await r.text());
      server.close();
    });
    EOF
    
    echo '=== guarded server, DB_URL missing ==='; API_TOKEN=fake-token-1 PORT=9710 node guarded-server.mjs; echo "exit $?"
    echo '=== guarded server, all set ==='; DB_URL=postgres://fake API_TOKEN=fake-token-1 PORT=9710 node guarded-server.mjs; echo "exit $?"
    
    === guarded server, DB_URL missing ===
    Configuration check failed:
    DB_URL: Invalid input: expected string, received undefined
    exit 1
    === guarded server, all set ===
    Configuration check passed: {
    DB_URL: 'postgres://fake',
    API_TOKEN: '<redacted>',
    PORT: 9710,
    LOG_LEVEL: 'info'
    }
    server listening on http://127.0.0.1:9710
    first request: 200 {"ok":true,"db":"postgres://fake"}
    exit 0

    No server listening line in the first run. The port never opened, the deploy job sees exit code 1, and the rollout stops there instead of on a customer request.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | expected string, received undefined | The key is absent from process.env | Set it in the job or the secret store for that environment | | Too small: expected string to have >=1 characters | The key is present and empty | Find what set it to nothing. An empty value is usually a failed lookup, not a choice | | (absent) from a variable you set in the shell | The assignment was not exported | Use export, an inline prefix, or the job's env: block | | Startup succeeds and the first request is a 500 | The check runs on the request path | Move it to module load, in front of listen | | The check passes in CI and fails on the deploy target | Two different environments were read | Run the same check as a step on the target, not on the build agent |

Common mistakes

Sign: The variable is visible to echo in the CI script and absent inside the process.Cause: A bare NAME=value line in a shell script sets a shell variable, not an environment variable. Child processes inherit only exported names, so the script can print the value on the line before the program fails to read it.
Sign: A guard using name in process.env passes on one CI runner and fails on another.Cause: Setting a variable to an empty value is not portable. Git Bash keeps the key with the value '', while PowerShell and cmd.exe remove the key. The same pipeline definition therefore produces two different environments.
Sign: The startup check reports one missing variable per run.Cause: A chain of if guards throws on the first failure, so each fix reveals the next. Collecting every issue before exiting turns three deploy attempts into one, which matters most when each attempt costs a pipeline run.
Sign: A missing number is reported as an invalid number.Cause: z.coerce.number() converts undefined to NaN before validation, so an unset variable and a misspelled one give the same message. Validate the raw string first when the distinction has to survive into the log.

What to check next

FAQ

How do I check a variable exists with dotenv?

Load the file, then ask process.env rather than the loader: 'DB_URL' in process.env. Loading a file and finding the key is not the same question, because a variable already present in the environment wins over the file, and a key absent from the file may still be set by the job.

Is an empty string the same as not set?

Not in process.env. 'X' in process.env is true for an empty value and false for an absent one, while Boolean(process.env.X) is false for both. Step 3 shows the shells disagreeing on which of the two they produce, so a check that relies on the difference needs testing on the runner you deploy from.

Where should the check run?

At module load, before anything opens a port or a connection, and as its own step in the deploy job on the target machine. A check that runs on the build agent validates the build agent.

Does a schema check slow startup?

The run in step 4 parses 4 keys and exits in the same command. Schema validation of a few dozen keys is a single pass over an object that is already in memory, and it happens once per process.

What about variables that are only needed later?

Declare them in the same schema and mark them optional. An optional key that is absent is then a recorded fact rather than an unknown, and the code that needs it can fail with a message naming the environment that omitted it.

Verified

Verified by Maks Vernynode 22.23.2zod 4.1.8

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic7 minpublished updated Maks Verny