Deploying AWS Lambda Functions with Pulumi

Learn how to package and deploy Lambda functions with Pulumi and TypeScript, including IAM roles and an API Gateway HTTP API trigger

This guide covers deploying a Lambda function with Pulumi - packaging code inline with pulumi.asset, wiring up the IAM execution role, and exposing it through an API Gateway HTTP API.

Execution Role

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

const lambdaRole = new aws.iam.Role("processor-role", {
    assumeRolePolicy: JSON.stringify({
        Version: "2012-10-17",
        Statement: [{
            Action: "sts:AssumeRole",
            Effect: "Allow",
            Principal: { Service: "lambda.amazonaws.com" },
        }],
    }),
});

new aws.iam.RolePolicyAttachment("processor-basic-execution", {
    role: lambdaRole.name,
    policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
});

Packaging Code with pulumi.asset

For small functions, pulumi.asset.AssetArchive lets you define the deployment package inline in your program instead of maintaining a separate build/zip step:

const fn = new aws.lambda.Function("processor", {
    role: lambdaRole.arn,
    runtime: "nodejs20.x",
    handler: "index.handler",
    timeout: 10,
    code: new pulumi.asset.AssetArchive({
        "index.js": new pulumi.asset.StringAsset(`
exports.handler = async (event) => {
    console.log("Received event:", JSON.stringify(event));
    return {
        statusCode: 200,
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ message: "Hello from Lambda" }),
    };
};
`),
    }),
});

pulumi.asset.StringAsset embeds a literal string as a file inside the archive; pulumi.asset.FileAsset reads a file from disk instead, and pulumi.asset.FileArchive points at an entire pre-built directory or zip - reach for FileArchive once your handler grows past a few lines or needs node_modules.

// Packaging a real build output directory instead of an inline string:
const fn = new aws.lambda.Function("processor", {
    role: lambdaRole.arn,
    runtime: "nodejs20.x",
    handler: "index.handler",
    code: new pulumi.asset.FileArchive("./dist"),
});

Environment Variables and Config

const config = new pulumi.Config();

const fnWithEnv = new aws.lambda.Function("processor", {
    role: lambdaRole.arn,
    runtime: "nodejs20.x",
    handler: "index.handler",
    code: new pulumi.asset.FileArchive("./dist"),
    environment: {
        variables: {
            LOG_LEVEL: config.get("logLevel") ?? "info",
        },
    },
});

Exposing It Through API Gateway

const api = new aws.apigatewayv2.Api("processor-api", {
    protocolType: "HTTP",
});

const integration = new aws.apigatewayv2.Integration("processor-integration", {
    apiId: api.id,
    integrationType: "AWS_PROXY",
    integrationUri: fn.arn,
    payloadFormatVersion: "2.0",
});

const route = new aws.apigatewayv2.Route("processor-route", {
    apiId: api.id,
    routeKey: "POST /process",
    target: pulumi.interpolate`integrations/${integration.id}`,
});

const stage = new aws.apigatewayv2.Stage("processor-stage", {
    apiId: api.id,
    name: "$default",
    autoDeploy: true,
});

new aws.lambda.Permission("api-invoke", {
    action: "lambda:InvokeFunction",
    function: fn.name,
    principal: "apigateway.amazonaws.com",
    sourceArn: pulumi.interpolate`${api.executionArn}/*/*`,
});

export const apiEndpoint = api.apiEndpoint;

The aws.lambda.Permission resource is easy to skip and easy to get wrong when you do remember it - without it, API Gateway can create the route but every request fails with an authorization error, since Lambda’s own resource policy (separate from the function’s IAM role) never granted API Gateway permission to invoke it. pulumi.interpolate is what makes building that sourceArn string possible without manually calling .apply() and returning a plain string from it.

Best Practices

  1. Prefer FileArchive over inline StringAsset once a function has real logic or dependencies - inline strings are fine for the smallest examples but become unreadable and untestable fast.
  2. Never forget the lambda.Permission resource when wiring up any event source (API Gateway, EventBridge, S3 notifications) - the function’s execution role controls what the function can call, not who’s allowed to invoke it.
  3. Set an explicit timeout - the default is short (3 seconds) and silently truncates anything that legitimately takes longer, like a cold-starting database connection.

Conclusion

Packaging is the main place Lambda-on-Pulumi differs meaningfully from Lambda-on-Terraform: instead of a data "archive_file" block pointing at a zip Terraform expects to already exist on disk, pulumi.asset lets the packaging step live inside the same program as the infrastructure, in the same language.

For more Pulumi topics, check out: