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.

It helps to know exactly what the provider does on your behalf. A Lambda function in pulumi-aws is one CreateFunction call whose payload contains a base64 deployment package (or an S3 pointer), a role ARN, and a small block of runtime settings. Everything else you associate with "a Lambda" — the log group, the invoke permission, the URL, the alias, the event source mapping — is a separate AWS resource with its own lifecycle. Pulumi does not bundle them, which is why a naive program leaves orphans behind: delete the function and the log group survives, holding data and cost. Declaring each piece explicitly is not ceremony; it is the only way the resource graph and the AWS account stay in agreement.

The second thing worth internalising early is which arguments mutate in place and which force a replacement. memory_size, timeout, environment, handler, runtime, and the code archive are all in-place updates — Lambda applies them to the same function ARN and callers never notice. name and role behave differently: changing name replaces the function (new ARN, and every aws.lambda_.Permission pointing at the old name goes stale), and swapping role triggers a short window where in-flight invocations can still run under the old identity. Knowing the split ahead of time means pulumi preview output stops being a surprise.

Context Context: Context with 4 facets. Context Lambda key element RDS PostgreSQL key element Pulumi key element AWS KMS key element
Context: how Lambda, RDS PostgreSQL, Pulumi relate in this pattern.

Prerequisites

Prerequisites Prerequisites: layered from index.py down to Python. index.py handler PutRolePolicy mypy Python
Prerequisites: the building blocks this section assembles.
  • 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.

The deploying principal also needs iam:PassRole for the execution role it creates. Without it, the function create fails after the role has already been provisioned, leaving a half-built stack that pulumi up will happily retry once the permission lands. Confirm the toolchain before the first deploy:

# CLI: confirm versions and the caller identity before the first deploy
python -c "import pulumi, pulumi_aws; print(pulumi.__version__, pulumi_aws.__name__)"
aws sts get-caller-identity --query Arn --output text
# State implication: the ARN printed here is the principal recorded as the
# creator of every resource in this stack's state file.

Implementation

1. Define typed config and the execution role

Implementation Implementation: 1. Define typed then 2. Package the code then 3. Expose an invoke then 4. Wire it together 1. Define typed 2. Package thecode 3. Expose aninvoke 4. Wire ittogether
Implementation: the stages run left to right — 1. Define typed, 2. Package the code, 3. Expose an invoke, 4. Wire it together.

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).

The dataclass is frozen=True on purpose. Pulumi programs are ordinary Python that runs top to bottom, and the single most annoying class of bug is a config object mutated between the point where it is read and the point where a resource consumes it — the diff then shows a change nobody wrote. Freezing the object turns that into an AttributeError at the mutation site rather than a mystery in pulumi preview.

On the role: aws.iam.RolePolicy writes an inline policy that lives and dies with the role, whereas aws.iam.RolePolicyAttachment links a standalone managed policy. Inline is the right default here because the policy is meaningless outside this one function and you never want it accidentally attached elsewhere. The trust policy is separate from the permission policy — the first says who may assume the role, the second says what the role may do once assumed. Getting the first one wrong is the classic first-deploy failure: Lambda rejects the create with InvalidParameterValueException: The role defined for the function cannot be assumed by Lambda, which almost always means the Principal block names something other than lambda.amazonaws.com.

# 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.

pulumi.FileArchive("./handler") resolves relative to the directory containing Pulumi.yaml, not to the module that constructs it — a distinction that bites the moment you move resource code into a package. Pulumi walks the directory, builds a zip in memory, and hashes it; that hash is what lands in state and what the next preview compares against. The sibling asset types matter too: pulumi.FileAsset wraps a single file, pulumi.AssetArchive({...}) composes a zip from named parts (useful when the handler and a vendored dependency tree live in different places), and pulumi.RemoteArchive points at an already-published S3 object.

The log group name is load-bearing. Lambda writes to /aws/lambda/<function-name> and nowhere else, so the name argument on the log group must match the function name character for character — an f-string interpolation of cfg.name rather than Pulumi's auto-generated suffixed name. That is why name= is set explicitly on both resources here. The depends_on guarantees ordering: without it Pulumi is free to create the function first, the first invocation creates the group implicitly with no retention, and the explicit group then collides.

# 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.

Three details in the API Gateway path are easy to get wrong. payload_format_version="2.0" changes the shape of the event dict your handler receives: version 2.0 delivers event["requestContext"]["http"]["method"] and a flat event["rawPath"], while 1.0 uses event["httpMethod"] and event["path"]. Mismatch it and the handler raises KeyError: 'httpMethod' on every request while the deploy itself looks perfectly healthy. The $default stage with auto_deploy=True means route changes go live without a separate deployment resource. And source_arn scoped to f"{execution_arn}/*/*" restricts the invoke grant to this API's stages and routes — omit it and any API Gateway in the account can call the function.

# 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 stays deliberately thin: build the config, hand it to the factory functions, export the one value a caller needs. Keeping resource construction in importable modules is what makes the test in the next section possible — the test imports __main__ under mocks and inspects the resources it declared. Note that http_api(fn) returns a pulumi.Output[str], not a str; pulumi.export accepts the Output directly and resolves it after the engine finishes. Trying to print() or f-string that value yields Calling __str__ on an Output[T] is not supported, which is Pulumi telling you to use .apply() instead.

# __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.

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# 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

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks retention_in_d watch this boundary depends_on watch this boundary FileArchive watch this boundary code watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

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.

FAQ

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.