If you want to validate environment variables, you usually only think about it after a Lambda function has spent a night working with undefined. The topic keeps bugging me because these errors are so unnecessary: the configuration is fixed before the first request arrives. Still, many teams only find out in production that a value is missing, misspelled or arrives as text instead of a number.
In this article I build a small, complete solution with TypeScript, Zod and the Serverless Framework. It checks the configuration twice: before the deployment in the pipeline and once more during the cold start of the function. The example is a fictional reminder service of a city library that reminds members to return their books.
In short
In Lambda, environment variables always arrive as text and are sometimes missing entirely. A Zod schema that is checked once at cold start turns them into a typed configuration object and lets the function fail immediately if something is wrong. The biggest pitfall is fallback values for required settings: they hide exactly the errors you want to find.
What you will learn in this article
- Why
process.envis deceptive in TypeScript - How to write a Zod schema for numbers, booleans, URLs and required values
- How to load the configuration once per cold start and fail fast
- How to catch missing values in the pipeline before you deploy
- How to build error messages that keep secrets out of your logs
Why you should validate environment variables
In Node.js, process.env is an object whose values are strings or undefined. Lambda behaves the same way: according to the AWS documentation, each variable consists of two strings, a name and a value, and Lambda never expands them. TypeScript does not help much here. The type only says “maybe a string”, and as soon as someone adds a ! or an as string, even that warning is gone.
The consequences are subtle. Number(process.env.LOAN_DAYS) returns NaN if the variable is missing, and JavaScript happily keeps calculating with NaN. Boolean("false") is true because every non-empty string is truthy. And a URL with a typo only shows up when the function calls it for the first time. That can be hours after the deployment, for example with the first real job of the day.
Fail fast at cold start
The idea behind this pattern is called “fail fast”: an error should surface as early and as loudly as possible. For Lambda, that means the init phase, the moment when the runtime loads your code and runs everything outside the handler. If the check fails there, the function does not process a single job with a broken configuration. Instead of a puzzling follow-up error, the log shows a clear list of problems.
A second check before the deployment is worth it as well. It runs in the pipeline with the same schema and stops before anything reaches AWS. The check at cold start still remains the safety net, because values can also change later, for example through an edit in the console.
🎬 Configuration check flow – this animated diagram only runs on my site: watch it on sascha-ms.de
Prerequisites
- Node.js 22 or newer and npm
- An AWS account with credentials for the AWS CLI (only for step 7)
- Serverless Framework v4 (
npm install -g serverless); on first start it asks you to log in or provide a license key - Basic knowledge of TypeScript and Lambda
Step 1: Create the project
Create an empty project and install Zod as the only runtime dependency. You only need TypeScript, the type definitions and tsx for running TypeScript files directly during development.
mkdir library-reminders && cd library-reminders npm init -y npm install zod npm install -D typescript tsx @types/node @types/aws-lambda Then add a few scripts to package.json. The important one is predeploy: npm runs it automatically before npm run deploy.
{ "name": "lambda-env-validation", "version": "1.0.0", "private": true, "type": "module", "scripts": { "typecheck": "tsc --noEmit", "test": "node --import tsx --test test/*.test.ts", "check-env": "tsx scripts/check-env.ts", "predeploy": "npm run check-env", "deploy": "serverless deploy" }, "dependencies": { "zod": "^4.1.12" }, "devDependencies": { "@types/aws-lambda": "^8.10.160", "@types/node": "^24.10.0", "tsx": "^4.20.6", "typescript": "^5.9.3" } } The tsconfig.json is deliberately strict. noUncheckedIndexedAccess makes sure TypeScript stays honest with accesses like process.env["X"] as well.
// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true, "allowImportingTsExtensions": true, "noEmit": true, "types": ["node"] }, "include": ["src", "scripts", "test"] } Step 2: The schema for the environment variables
Now for the core: with a schema you can validate environment variables that the function needs in one go. It describes every variable in exactly one place: whether it is required, which type it has after conversion and which values are allowed. Create the file src/config.ts.
// src/config.ts import { z } from "zod"; // All environment variables of the function, described in exactly one place export const envSchema = z.object({ STAGE: z.enum(["dev", "test", "prod"]), NOTIFY_API_URL: z.url({ protocol: /^https$/ }), SENDER_EMAIL: z.email(), LOAN_DAYS: z.coerce.number().int().min(1).max(60).default(28), SEND_EMAILS: z.stringbool().default(false), LOG_LEVEL: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("INFO"), }); export type AppConfig = z.infer<typeof envSchema>; export class ConfigError extends Error { constructor(public readonly problems: string[]) { super(`Invalid configuration:\n${problems.map((p) => ` - ${p}`).join("\n")}`); this.name = "ConfigError"; } } // Checks the environment and returns a typed object or throws with all problems at once export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const result = envSchema.safeParse(env); if (result.success) { return result.data; } // Report only the name and the rule, never the value: it might be secret const problems = result.error.issues.map( (issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`, ); throw new ConfigError(problems); } A few details matter. z.coerce.number() converts the string to a number with Number(), and then int(), min() and max() apply. z.stringbool() understands values such as "true", "1", "yes" or "off" and rejects everything else. So a “maybe” does not silently become true. In addition, .default() only applies when the variable is missing; an empty string is an error instead, which is usually exactly right.
z.object() strips unknown keys. That is why you can pass the complete process.env, including all of Lambda’s own variables such as AWS_REGION, and still only get your six values back. The AppConfig type is derived directly from the schema, so you do not maintain it twice.
Why the error message contains no values
loadConfig() uses safeParse() and builds its own list from the Zod issues: variable name and rule, but never the value. That is on purpose. Environment variables sometimes contain tokens in URLs or other things that do not belong in a log. The message also collects all problems at once. Anyone who has deployed five times in a row to find five missing variables one by one will appreciate that.
Step 3: Load the configuration once at cold start
So that you only have to validate environment variables once, the handler calls loadConfig() at module level, outside the handler function. This code runs exactly once per execution environment during the init phase. After that, config is available to every invocation without repeating the check for each event.
// src/handler.ts import type { SQSEvent } from "aws-lambda"; import { loadConfig } from "./config.ts"; // Runs once per cold start: if something is missing, the init phase already fails const config = loadConfig(); type LoanCreated = { loanId: string; memberEmail: string; borrowedAt: string }; export function computeDueDate(borrowedAt: string, loanDays: number): string { const due = new Date(borrowedAt); due.setUTCDate(due.getUTCDate() + loanDays); return due.toISOString().slice(0, 10); } export const handler = async (event: SQSEvent): Promise<void> => { for (const record of event.Records) { const loan = JSON.parse(record.body) as LoanCreated; const dueDate = computeDueDate(loan.borrowedAt, config.LOAN_DAYS); if (!config.SEND_EMAILS) { console.log(JSON.stringify({ level: "INFO", msg: "Sending disabled", loanId: loan.loanId, dueDate })); continue; } const response = await fetch(config.NOTIFY_API_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from: config.SENDER_EMAIL, to: loan.memberEmail, subject: `Please return by ${dueDate}`, }), }); if (!response.ok) { // Throw an error: SQS delivers the message again throw new Error(`Notification failed: HTTP ${response.status}`); } } }; config.LOAN_DAYS is a real number here and config.SEND_EMAILS a real boolean. No parseInt, no comparison with "true", no !. The function itself is deliberately simple: it calculates the due date and sends a request to an equally fictional mail service.
What happens if the check fails? The error is thrown during the init phase, the function does not process the event, and the CloudWatch log shows the list from ConfigError. With an SQS trigger, the messages return to the queue once the visibility timeout expires. After three attempts they end up in the dead-letter queue (DLQ), a queue for messages that cannot be processed. Nothing gets lost, and you can replay the messages after the fix.
Step 4: Required values in serverless.yml without fallback
The values are put together in serverless.yml. The Serverless Framework reads ${env:...} from the environment and from .env files. A second parameter after the comma is a fallback.
# serverless.yml service: library-reminders frameworkVersion: "4" params: default: logLevel: INFO prod: logLevel: WARN provider: name: aws runtime: nodejs24.x region: eu-central-1 memorySize: 512 timeout: 10 environment: STAGE: ${sls:stage} NOTIFY_API_URL: ${env:NOTIFY_API_URL} SENDER_EMAIL: ${env:SENDER_EMAIL} LOAN_DAYS: ${env:LOAN_DAYS, '28'} SEND_EMAILS: ${env:SEND_EMAILS, 'false'} LOG_LEVEL: ${param:logLevel} functions: sendLoanReminder: handler: src/handler.handler events: - sqs: arn: !GetAtt LoanEventsQueue.Arn batchSize: 10 resources: Resources: LoanEventsQueue: Type: AWS::SQS::Queue Properties: QueueName: ${self:service}-${sls:stage}-loan-events VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt LoanEventsDlq.Arn maxReceiveCount: 3 LoanEventsDlq: Type: AWS::SQS::Queue Properties: QueueName: ${self:service}-${sls:stage}-loan-events-dlq MessageRetentionPeriod: 1209600 Here is the rule I have adopted: fallbacks only for values that also have a default in the Zod schema. LOAN_DAYS and SEND_EMAILS may get one. NOTIFY_API_URL and SENDER_EMAIL may not, because an empty or made-up default would only move the error somewhere else. Values that differ per stage but are not secret fit well into the params block, in this case LOG_LEVEL.
Real secrets such as API keys do not belong in environment variables, by the way, but in Secrets Manager or Parameter Store. I show how to load such values cleanly in Parameter Store in Lambda: loading config the right way. The Zod schema works the same way there.
Step 5: Validate environment variables before you deploy
The script scripts/check-env.ts uses the same schema as the function. This lets you validate environment variables in the pipeline already, and the script checks the environment of the shell or the pipeline.
// scripts/check-env.ts import { ConfigError, loadConfig } from "../src/config.ts"; try { const config = loadConfig(process.env); console.log(`Configuration for stage "${config.STAGE}" is complete.`); } catch (error) { if (error instanceof ConfigError) { console.error(error.message); process.exit(1); } throw error; } Locally, you can pass a .env file directly, because tsx forwards the Node.js option --env-file:
npx tsx --env-file=.env.dev scripts/check-env.ts If all values are valid, you see Configuration for stage "dev" is complete. Otherwise the output looks like this, for example, and the script exits with code 1:
Invalid configuration: - STAGE: Invalid option: expected one of "dev"|"test"|"prod" - NOTIFY_API_URL: Invalid URL - SENDER_EMAIL: Invalid input: expected string, received undefined - SEND_EMAILS: Invalid option: expected one of "true"|"1"|"yes"|"on"|"y"|"enabled"|"false"|"0"|"no"|"off"|"n"|"disabled" In GitHub Actions, add the check as a separate step before the deployment. The values come from repository variables; set STAGE to match the target stage:
# .github/workflows/deploy.yml (excerpt) - name: Check configuration run: npm run check-env env: STAGE: prod NOTIFY_API_URL: ${{ vars.NOTIFY_API_URL }} SENDER_EMAIL: ${{ vars.SENDER_EMAIL }} SEND_EMAILS: "true" This step has one limit: it checks the inputs, not the final CloudFormation template. It does not see values from params such as LOG_LEVEL. That is why the check at cold start remains mandatory.
Step 6: Tests for the schema
A few tests pin down what the schema is supposed to do. I use the built-in Node.js test runner, so no extra library is needed.
// test/config.test.ts import assert from "node:assert/strict"; import { test } from "node:test"; import { ConfigError, loadConfig } from "../src/config.ts"; const valid = { STAGE: "dev", NOTIFY_API_URL: "https://notify.example.com/v1/mails", SENDER_EMAIL: "loans@citylibrary.example", }; test("a valid environment returns typed values with defaults", () => { const config = loadConfig(valid); assert.equal(config.LOAN_DAYS, 28); assert.equal(config.SEND_EMAILS, false); assert.equal(config.LOG_LEVEL, "INFO"); }); test("strings are converted to number and boolean", () => { const config = loadConfig({ ...valid, LOAN_DAYS: "14", SEND_EMAILS: "yes" }); assert.equal(config.LOAN_DAYS, 14); assert.equal(config.SEND_EMAILS, true); }); test("missing required values are reported all at once", () => { assert.throws( () => loadConfig({ LOAN_DAYS: "0" }), (error: unknown) => { assert.ok(error instanceof ConfigError); const names = error.problems.map((p) => p.split(":")[0]); assert.deepEqual(names.sort(), ["LOAN_DAYS", "NOTIFY_API_URL", "SENDER_EMAIL", "STAGE"]); return true; }, ); }); test("unclear boolean values are rejected", () => { assert.throws(() => loadConfig({ ...valid, SEND_EMAILS: "maybe" }), ConfigError); }); test("the error message contains no values", () => { const secret = "http://secret-token@notify.example.com"; assert.throws( () => loadConfig({ ...valid, NOTIFY_API_URL: secret }), (error: unknown) => { assert.ok(error instanceof ConfigError); assert.ok(!error.message.includes("secret-token")); return true; }, ); }); npm run typecheck npm test Expected result: the type check passes without output, and the test runner reports pass 5 and fail 0. The last test matters most to me, because it prevents someone from later adding the value to the log “to make debugging easier”.
Step 7: Deploy and try it out
Set the required values in your shell or in a .env.dev file and deploy. Serverless Framework v4 bundles TypeScript automatically with esbuild.
export NOTIFY_API_URL=https://notify.example.com/v1/mails export SENDER_EMAIL=loans@citylibrary.example export STAGE=dev npm run deploy -- --stage dev Then send a test message to the queue and look at the log:
QUEUE_URL=$(aws sqs get-queue-url --queue-name library-reminders-dev-loan-events --query QueueUrl --output text) aws sqs send-message --queue-url "$QUEUE_URL" --message-body '{"loanId":"L-1","memberEmail":"reader@example.com","borrowedAt":"2026-11-04T10:00:00Z"}' serverless logs -f sendLoanReminder --stage dev Since SEND_EMAILS defaults to false, the log shows {"level":"INFO","msg":"Sending disabled","loanId":"L-1","dueDate":"2026-12-02"}. As a counter-test, change NOTIFY_API_URL in the Lambda console to an http:// address. On the next cold start, the ConfigError message appears in the log, and the message moves to the DLQ after three attempts.
To clean up, run serverless remove --stage dev.
Common mistakes when you validate environment variables
Applying Boolean() to a string
Boolean(process.env.SEND_EMAILS) is true for


