DEV Community

Cover image for Validate Environment Variables in Lambda with Zod
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Validate Environment Variables in Lambda with Zod

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.env is 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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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}`);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
    },
  );
});
Enter fullscreen mode Exit fullscreen mode
npm run typecheck
npm test
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 "false" and "0". Use z.stringbool() or compare explicitly against a list of allowed values.

Fallbacks for required values

${env:NOTIFY_API_URL, ''} looks harmless, but it turns “missing” into an empty string. The schema catches that, but only at cold start. Without a fallback you notice it earlier.

Checking in the handler instead of at load time

If loadConfig() sits inside the handler function, the check runs on every invocation, and an error only affects individual events. At module level it runs once and stops the function completely, which is the more honest behavior for a broken configuration.

Schema and serverless.yml drift apart

If a new variable goes into the schema but not into serverless.yml, the cold start fails. That is exactly what the pattern is for, but it is nicer when the pipeline finds it. A pull request check that runs npm run check-env with the values of the target environment closes this gap.

Costs and security

Validating environment variables costs practically nothing: the check runs once per cold start and takes hardly any measurable time for six variables. Zod makes the bundle a bit larger; if you count every kilobyte, you can switch to zod/mini. I described how bundle size affects cold starts in Reduce Lambda Cold Starts: A TypeScript Guide. What a function costs overall for your memory size and invocation volume is shown by the Lambda cost calculator.

On security: Lambda encrypts environment variables at rest, but anyone with read access to the function configuration sees them in plain text. On top of that, the Serverless Framework documentation warns that values from environment variables can end up in build logs or CloudFormation templates. For passwords and keys, Secrets Manager therefore remains the better choice. Also keep in mind the 4 KB limit for all environment variables of a function combined.

My take

To validate environment variables at cold start is one of the cheapest patterns I know. It belongs to the things that almost always pay off in the serverless world. Twenty lines of schema save you an entire class of errors that would otherwise only show up in production, and they document along the way what the function actually needs. New people on the team read config.ts and know what is going on.

Where I disagree: some teams validate their configuration so thoroughly that they end up controlling every little detail through environment variables. That is not a good idea. Environment variables are meant for a few stable values. Anything that should change at runtime belongs in a service like AppConfig, and anything secret in Secrets Manager. The schema makes the configuration safer, but not automatically better structured.

Environment variables are only half the story: how to check the events themselves, meaning HTTP bodies and SQS messages, is what I show in Validate Lambda events with Powertools Parser and Zod.

FAQ

What happens if a Lambda function throws an error at cold start?

The init phase fails, and the triggering event is not processed. Lambda writes the error to the CloudWatch log. For asynchronous sources like SQS, the usual retries apply until the message reaches the dead-letter queue.

How large can environment variables in Lambda be?

All environment variables of a function combined may be at most 4 KB. Larger configurations belong in Parameter Store, AppConfig or a file in the deployment package.

Are environment variables in Lambda encrypted?

Yes, Lambda encrypts them at rest with KMS. However, anyone who may read the function configuration sees the values in plain text. That is why Secrets Manager is the better fit for secrets.

Can I do this without Zod?

Yes, you can also validate environment variables with your own function that checks and converts each variable. With more than two or three values, though, that quickly gets longer than the schema, and you have to maintain the type by hand.

Your next step

Take an existing Lambda function and search for every process.env in the code. Write a schema for these values that lets you validate environment variables, load it at module level and run the tests. I bet at least one value is optional although it should be required, or the other way round. Which variable has caused a production bug for you? Let me know in the comments.

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)