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 helpshows 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
jqfor 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 runto startmake execto debugmake testto triggermake tailto 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.