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
- Node 22.23.2 and npm. Every output on this page came from one Windows 11 machine on 2026-09-12, in Git Bash and in PowerShell where noted.
- Zod 4.1.8 for step 4. Any schema library does the same work. See the Zod object API.
- Port 9710 free on 127.0.0.1. Both servers below stop themselves after one request.
- The reader script used in steps 2 and 3, which reports presence and value separately.
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
- 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(); }); EOFnode lazy-server.mjsserver 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.
- 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: falseBlock 2 is the CI failure.
DB_URL=postgres://fakeon its own line is a shell variable, visible toecho $DB_URLand invisible to every child process. A script that prints the value and then fails to read it is describing this. - 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: falseThe same intent gives two different environments. Git Bash passes
EMPTY_VARthrough as an empty string, present inprocess.env. PowerShell andcmd.exeremove the key instead, so'EMPTY_VAR' in process.envis false. A guard written withinreports a different result depending on which shell the CI job used. - Step 4.
Replace the sequence of
ifguards 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>' }); EOFecho '=== 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 0The 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).PORTcomes back as the number 9710, not as"9710". - 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(); }); EOFecho '=== 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 0No
server listeningline 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
What to check next
- How to check config drift between environments: the keys that exist in one environment and not the next.
- How to mock environment variables in vitest: the same variables inside a test run, where they leak between files.
- How to check for secrets in environment variables: what the startup check should refuse to print.
- How to check environment parity: the wider comparison of staging against production.
- Test environment checklist: the full pass over a test environment before sign-off.
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.
Related on this site
basic7 minpublished updated Maks Verny