Deploying AWS Lambda Functions with Pulumi (Python)

Deploying an AWS Lambda function with Pulumi Python means packaging the code, attaching a least-privilege IAM execution role, wiring environment variables, and exposing an invoke path — all as one typed program, part of the broader AWS Provider Deep Dive workflow. The recurring mistake is reaching for the AWSLambdaBasicExecutionRole managed policy and a wide-open function URL; this guide keeps both scoped.

This guide builds a Python Lambda from a local code directory, creates a dedicated CloudWatch log group, grants only the permissions the handler needs, and gives you the choice between a Function URL and an API Gateway HTTP route as the trigger.

Context

Lambda is deceptively easy to deploy badly: pulumi up succeeds with an over-permissive role and a public URL, and nothing complains until a security review. The cost of doing it right is a few extra typed lines — an inline policy instead of a managed one, an explicit log group instead of the implicit one Lambda creates with no retention. A function provisioned this way slots cleanly alongside an RDS PostgreSQL instance as a serverless consumer, and shares the secret-handling discipline of securing Pulumi secrets with AWS KMS and HashiCorp Vault.

Prerequisites

  • Python 3.9+ with pulumi>=3.0 and pulumi-aws>=6.0.
  • A handler directory (e.g. ./handler/) containing index.py with a handler(event, context) function.
  • IAM permissions for lambda:*, iam:CreateRole/PutRolePolicy, and logs:* on the deployment role.
  • For the API Gateway path: permission for apigatewayv2:*.
  • mypy for static checking of the typed config object.

Implementation

1. Define typed config and the execution role

Model the function settings in a frozen dataclass, then build an IAM role with a trust policy for lambda.amazonaws.com and an inline policy granting only log writes (extend it per handler need).

# infra/lambda_config.py
# CLI: mypy --strict infra/
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict

@dataclass(frozen=True)
class FunctionConfig:
    name: str
    handler: str = "index.handler"
    runtime: str = "python3.12"
    memory_mb: int = 256
    timeout_s: int = 30
    env: Dict[str, str] = field(default_factory=dict)
    # State implication: changing `runtime` updates the function in place;
    # changing `name` forces replacement.
# infra/lambda_role.py
# CLI: pulumi preview --diff
from __future__ import annotations
import json
import pulumi_aws as aws

def build_role(name: str) -> aws.iam.Role:
    role = aws.iam.Role(
        f"{name}-role",
        assume_role_policy=json.dumps({
            "Version": "2012-10-17",
            "Statement": [{
                "Effect": "Allow",
                "Principal": {"Service": "lambda.amazonaws.com"},
                "Action": "sts:AssumeRole",
            }],
        }),
    )
    # Provider note: inline scoped policy instead of the broad
    # AWSLambdaBasicExecutionRole managed policy.
    aws.iam.RolePolicy(
        f"{name}-logs",
        role=role.id,
        policy=json.dumps({
            "Version": "2012-10-17",
            "Statement": [{
                "Effect": "Allow",
                "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
                "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/*:*",
            }],
        }),
    )
    return role

2. Package the code and create the log group

Use pulumi.FileArchive to zip the handler directory at deploy time, and create the log group explicitly so it has a retention policy rather than Lambda's default never-expire.

# infra/lambda_fn.py
# CLI: pulumi up
from __future__ import annotations
import pulumi
import pulumi_aws as aws
from infra.lambda_config import FunctionConfig

def build_function(cfg: FunctionConfig, role: aws.iam.Role) -> aws.lambda_.Function:
    # Create the log group first so Lambda reuses it with retention set.
    log_group = aws.cloudwatch.LogGroup(
        f"{cfg.name}-logs",
        name=f"/aws/lambda/{cfg.name}",
        retention_in_days=14,
    )

    return aws.lambda_.Function(
        cfg.name,
        name=cfg.name,
        role=role.arn,
        runtime=cfg.runtime,
        handler=cfg.handler,
        # Provider note: FileArchive zips the directory during `pulumi up`.
        code=pulumi.FileArchive("./handler"),
        memory_size=cfg.memory_mb,
        timeout=cfg.timeout_s,
        environment=aws.lambda_.FunctionEnvironmentArgs(variables=cfg.env),
        opts=pulumi.ResourceOptions(depends_on=[log_group]),
    )

3. Expose an invoke path

Pick one trigger. A Function URL is the minimal path; an API Gateway HTTP API gives you routing, auth, and a stable domain. Both are shown — deploy whichever your design calls for.

# infra/triggers.py
# CLI: pulumi stack output invokeUrl
from __future__ import annotations
import pulumi
import pulumi_aws as aws

def function_url(fn: aws.lambda_.Function) -> pulumi.Output[str]:
    url = aws.lambda_.FunctionUrl(
        f"{fn._name}-url",
        function_name=fn.name,
        # Provider note: AWS_IAM requires SigV4-signed calls; use NONE only
        # for genuinely public endpoints.
        authorization_type="AWS_IAM",
    )
    return url.function_url

def http_api(fn: aws.lambda_.Function) -> pulumi.Output[str]:
    api = aws.apigatewayv2.Api(f"{fn._name}-api", protocol_type="HTTP")
    integ = aws.apigatewayv2.Integration(
        f"{fn._name}-integ",
        api_id=api.id,
        integration_type="AWS_PROXY",
        integration_uri=fn.arn,
        payload_format_version="2.0",
    )
    aws.apigatewayv2.Route(
        f"{fn._name}-route",
        api_id=api.id,
        route_key="GET /invoke",
        target=integ.id.apply(lambda i: f"integrations/{i}"),
    )
    stage = aws.apigatewayv2.Stage(f"{fn._name}-stage", api_id=api.id, name="$default", auto_deploy=True)
    # Provider note: API Gateway must be granted lambda:InvokeFunction.
    aws.lambda_.Permission(
        f"{fn._name}-perm",
        action="lambda:InvokeFunction",
        function=fn.name,
        principal="apigateway.amazonaws.com",
        source_arn=api.execution_arn.apply(lambda arn: f"{arn}/*/*"),
    )
    return stage.invoke_url

4. Wire it together and export

# __main__.py
# CLI: pulumi up && pulumi stack output invokeUrl
import pulumi
from infra.lambda_config import FunctionConfig
from infra.lambda_role import build_role
from infra.lambda_fn import build_function
from infra.triggers import http_api

cfg = FunctionConfig(name="orders-api", env={"TABLE": "orders"})
role = build_role(cfg.name)
fn = build_function(cfg, role)
pulumi.export("invokeUrl", http_api(fn))

Verification

Assert the function uses the scoped role and has a finite log retention, then invoke it for real.

# tests/test_lambda.py
# CLI: pytest tests/test_lambda.py
from __future__ import annotations
import pulumi
from typing import Any, Dict, Tuple

class Mocks(pulumi.runtime.Mocks):
    def new_resource(self, args: pulumi.runtime.MockResourceArgs) -> Tuple[str, Dict[str, Any]]:
        return (f"{args.name}-id", {**args.inputs, "arn": f"arn:aws:lambda:::function:{args.name}"})
    def call(self, args: pulumi.runtime.MockCallArgs) -> Dict[str, Any]:
        return {}

pulumi.runtime.set_mocks(Mocks(), preview=False)

import importlib
main = importlib.import_module("__main__")

@pulumi.runtime.test
def test_runtime_pinned() -> pulumi.Output:
    return main.fn.runtime.apply(
        lambda r: None if r == "python3.12" else (_ for _ in ()).throw(AssertionError(f"unexpected runtime {r}"))
    )
# CLI: invoke the live function through API Gateway
curl -s "$(pulumi stack output invokeUrl)/invoke"
# Confirm log retention took effect
aws logs describe-log-groups --log-group-name-prefix /aws/lambda/orders-api \
  --query 'logGroups[0].retentionInDays'

Gotchas & Edge Cases

The implicit log group has no retention. If you let Lambda create its own log group on first invocation, it never expires and you pay storage forever. Create the aws.cloudwatch.LogGroup explicitly with retention_in_days and add a depends_on so it exists before the function runs — but note that if the implicit group already exists, the create will fail with ResourceAlreadyExistsException; import it or delete the stray group first.

FileArchive only re-zips when files change on disk. Pulumi hashes the archive contents to decide whether to update the function. If your build step writes the same bytes (e.g. a non-deterministic zip with timestamps), it may either churn every deploy or, worse, not update when you expect. Build a deterministic artifact, or point code at a versioned S3 object for reproducible deploys.

A Function URL with authorization_type="NONE" is fully public. That is occasionally what you want, but it is an unauthenticated internet endpoint. Default to AWS_IAM and require SigV4-signed requests, or front the function with API Gateway and an authorizer.

Frequently Asked Questions

How do I package Python dependencies with the Lambda code? Install them into the handler directory (pip install -r requirements.txt -t ./handler) before pulumi up so FileArchive zips them alongside index.py, or build a Lambda layer and attach it via the layers argument. For compiled wheels, install on an Amazon Linux image to match the runtime.

Should I use a Function URL or API Gateway? A Function URL is fewer resources and lower latency for a single endpoint. API Gateway adds routing, request validation, throttling, custom domains, and authorizers — use it once you have more than one route or need those features.

Why does my explicit log group fail to create? Lambda already created /aws/lambda/<name> on a prior invocation. Either delete that group, or run pulumi import aws:cloudwatch/logGroup:LogGroup <name> /aws/lambda/<name> to bring it under management before adding retention.

How do I keep the IAM role least-privilege? Start with only logs:CreateLogStream and logs:PutLogEvents, then add one statement per AWS action the handler actually performs (e.g. a single dynamodb:GetItem on one table ARN). Avoid AWSLambdaBasicExecutionRole and never attach wildcard resource ARNs you do not need.

How do I pass secrets to the function safely? Read them as Pulumi config secrets and pass them through environment.variables; Pulumi keeps them encrypted in state. For rotation and broader access, store the value in Secrets Manager or Vault and have the handler fetch it at cold start instead.