by skunxicat

Makefile-Driven Lambda Development

The best developer tools disappear into the workflow

After building dozens of Lambda functions, I’ve learned that the difference between a good project and a great one often comes down to the quality of the development tooling. Today I want to share a Makefile that has transformed how I work with Lambda functions—turning what used to be a collection of manual steps into a seamless, parameterized workflow.

The Problem with Lambda Development

Lambda development typically involves juggling multiple tools and contexts:

  • Infrastructure: Terraform for AWS resources
  • Build: Docker for container images
  • Local Testing: Lambda Runtime Interface Emulator
  • Deployment: AWS CLI for function updates
  • Debugging: CloudWatch logs and container inspection
  • Testing: S3 uploads, direct invocations, log monitoring

Each step requires remembering specific commands, container IDs, function names, and resource identifiers. The cognitive overhead adds up quickly.

The Solution: A Self-Aware Makefile

Here’s the Makefile that changed everything:

# Load environment variables
include .env
export

# Add current directory to PATH
export PATH := .:$(PATH)

# Get function name from Terraform output
FUNCTION_NAME := $(shell tf output -json lambda | jq -r .function_name 2>/dev/null || echo "unknown")
REPOSITORY_URL := $(shell tf output -json runtime | jq -r .ecr.repository_url 2>/dev/null )
SOURCE_BUCKET := $(shell tf  output -json source_bucket | jq -r .bucket_id  2>/dev/null || echo "unknown")

# Handler function (can be overridden)
HANDLER ?= handler.thumb
# Detach mode (can be overridden with DETACH=false)
DETACH ?= true

# Default target
.PHONY: help
help:
	@echo "Available targets:"
	@echo "  debug     - Print Terraform output values"
	@echo "  build     - Build and push Docker image"
	@echo "  deploy    - Deploy Lambda function"
	@echo "  run       - Run container locally (HANDLER=$(HANDLER), DETACH=$(DETACH))"
	@echo "  exec      - Connect to running container"
	@echo "  test      - Upload test image to trigger function (FILE=$(FILE))"
	@echo "  invoke    - Test function invocation"
	@echo "  logs      - View recent function logs (SINCE=$(SINCE))"
	@echo "  tail      - Tail logs in real-time"
	@echo "  clean     - Remove deployment artifacts"
	@echo ""
	@echo "Function: $(FUNCTION_NAME)"
	@echo "Repository: $(REPOSITORY_URL)"
	@echo "Source Bucket: $(SOURCE_BUCKET)"
	@echo "Handler: $(HANDLER)"

What Makes This Special

1. Dynamic Configuration from Terraform

Instead of hardcoding values, the Makefile pulls live configuration from Terraform outputs:

FUNCTION_NAME := $(shell tf output -json lambda | jq -r .function_name 2>/dev/null || echo "unknown")
REPOSITORY_URL := $(shell tf output -json runtime | jq -r .ecr.repository_url 2>/dev/null )
SOURCE_BUCKET := $(shell tf  output -json source_bucket | jq -r .bucket_id  2>/dev/null || echo "unknown")

This means:

  • No hardcoded values - everything stays in sync with infrastructure
  • Environment-aware - works across dev/staging/prod automatically
  • Self-documenting - make help shows current state

2. Parameterized Targets with Smart Defaults

# Handler function (can be overridden)
HANDLER ?= handler.thumb
# Detach mode (can be overridden with DETACH=false)
DETACH ?= true

This enables flexible usage:

make run                                    # Uses defaults
make run HANDLER=handler.resize             # Custom handler
make run DETACH=false                       # Run in foreground
make run HANDLER=handler.resize DETACH=false # Both custom

3. Intelligent Error Handling

The exec target demonstrates thoughtful error handling:

.PHONY: exec
exec: 
	@CONTAINER_ID=$$(docker ps -q --filter name="$(FUNCTION_NAME)"); \
	if [ -z "$$CONTAINER_ID" ]; then \
		echo "No running container found for $(FUNCTION_NAME). Run 'make run' first."; \
		exit 1; \
	fi; \
	docker exec -it $$CONTAINER_ID /bin/bash

Instead of cryptic Docker errors, you get helpful guidance.

4. Complete Development Workflow

The Makefile covers the entire development lifecycle:

Build and Deploy:

make build    # Build and push Docker image
make deploy   # Deploy to AWS (includes build)

Local Development:

make run      # Start Lambda RIE container
make exec     # Connect to running container for debugging
make invoke   # Test function directly

Testing and Monitoring:

make test FILE=my-image.jpg  # Upload file to trigger function
make tail                    # Watch logs in real-time
make logs SINCE=30m         # View recent logs

Cleanup:

make clean        # Remove artifacts
make clean-docker # Remove Docker images

Real-World Usage Examples

Development Workflow

# Start development session
make run DETACH=false HANDLER=handler.debug

# In another terminal, trigger the function
make test FILE=test-image.png

# Watch logs in real-time
make tail

# Debug inside the container
make exec

Testing Different Handlers

# Test thumbnail generation
make run HANDLER=handler.thumb
make test FILE=photo.jpg

# Test image resizing  
make run HANDLER=handler.resize
make test FILE=large-image.png

# Test with custom parameters
make logs SINCE=5m

Production Deployment

# Build, test locally, then deploy
make build
make run HANDLER=handler.thumb
make test FILE=production-test.jpg
make deploy

The Technical Details

Dynamic Container Management

The run target handles both detached and interactive modes:

.PHONY: run
run:
	@echo "Running the runtime image locally "
	@if [ "$(DETACH)" = "true" ]; then \
		docker run --rm \
			--entrypoint /usr/local/bin/aws-lambda-rie \
			-p 9000:8080 \
			-v ~/.aws:/root/.aws:rw \
			-v ./app/src/handler.sh:/var/task/handler.sh:rw \
			-v ./app/runtime/bootstrap:/var/runtime/bootstrap:rw \
			-v /tmp/images:/tmp/images:rw \
			--name $(FUNCTION_NAME) \
			--detach \
			$(REPOSITORY_URL):latest \
			/var/runtime/bootstrap \
			$(HANDLER); \
	else \
		# Same command without --detach
	fi

Terraform Integration

The Makefile seamlessly integrates with Terraform by parsing JSON outputs:

FUNCTION_NAME := $(shell tf output -json lambda | jq -r .function_name 2>/dev/null || echo "unknown")

This pattern:

  • Uses tf (Terraform wrapper) for consistent behavior
  • Parses JSON with jq for reliability
  • Provides fallback values with || echo "unknown"
  • Suppresses errors with 2>/dev/null

Parameter Passing

Make’s ?= operator enables parameter overrides:

FILE ?= test-image.jpg
SINCE ?= 1h
HANDLER ?= handler.thumb

Command-line values take precedence over defaults.

Why This Matters

Cognitive Load Reduction

Instead of remembering:

  • Container IDs that change every run
  • Function names from Terraform outputs
  • ECR repository URLs
  • S3 bucket names
  • CloudWatch log group names

You remember:

  • make run to start
  • make exec to debug
  • make test to trigger
  • make tail to watch

Consistency Across Projects

This Makefile pattern works for any Lambda project. The only changes needed are:

  • Handler function names
  • Terraform output structure
  • Project-specific parameters

Team Onboarding

New team members can be productive immediately:

git clone project
cd project
make help    # See what's available
make deploy  # Deploy to their environment
make test    # Verify it works

No documentation needed beyond the self-documenting help.

Lessons Learned

1. Make Variables vs Shell Variables

Understanding the difference is crucial:

# Make variable (evaluated once when Makefile is parsed)
FUNCTION_NAME := $(shell tf output -json lambda | jq -r .function_name)

# Shell variable (evaluated each time the target runs)
@CONTAINER_ID=$$(docker ps -q --filter name="$(FUNCTION_NAME)")

Use $$ to escape Make’s variable expansion and pass literal $ to shell.

2. Error Handling Patterns

Always provide helpful error messages:

@if [ -z "$$CONTAINER_ID" ]; then \
	echo "No running container found. Run 'make run' first."; \
	exit 1; \
fi

3. Self-Documentation

The help target should show current state, not just available commands:

@echo "Function: $(FUNCTION_NAME)"
@echo "Repository: $(REPOSITORY_URL)"
@echo "Handler: $(HANDLER)"

Beyond Lambda

This pattern works for any containerized application:

  • API services - Replace S3 triggers with HTTP endpoints
  • Data processing - Replace images with data files
  • Microservices - Add service discovery and health checks

The key principles remain:

  • Dynamic configuration from infrastructure
  • Parameterized targets with defaults
  • Complete development workflow
  • Intelligent error handling

Conclusion

A well-crafted Makefile transforms development from a collection of manual steps into a seamless workflow. By integrating with Terraform, providing smart defaults, and handling edge cases gracefully, it becomes an invisible productivity multiplier.

The best developer tools disappear into the workflow. You stop thinking about the mechanics and focus on the problem you’re solving.

This Makefile does exactly that.


The complete example is available in the cloudless-examples repository.