Building a Serverless Contact Form with Terraform

Part 9 of the AWS Cloud Resume Challenge.

Frontend Website Code: https://github.com/jlayman09/Layman_CloudResumeWebsite
Backend Serverless Code (Lambda + DynamoDB): https://github.com/jlayman09/cloud-resume-backend
Serverless Contact Form API (Terraform + Lambda): https://github.com/jlayman09/cloud-resume-contact-api

After migrating my existing infrastructure to Terraform, the final piece of the Cloud Resume Challenge was to add a dynamic feature that demonstrated a complete, end-to-end serverless workflow: a contact form. While many websites use third-party services for this, building my own was a perfect opportunity to dive deeper into API Gateway, Lambda, and IAM, all managed through Infrastructure as Code.

The goal was to create a form that could take a visitor's name, email, and message, and securely deliver it to me without managing a single server. This required building a backend API from scratch using Terraform, connecting it to a Lambda function for processing, and integrating it with the frontend JavaScript.

Serverless contact form architecture diagram High-level architecture of the serverless contact form.

The Architecture

The final workflow follows a classic serverless pattern:
  1. A visitor fills out the contact form on the website.
  2. Frontend JavaScript sends the form data as a POST request to an API Gateway endpoint.
  3. API Gateway triggers a Lambda function, passing the request data along.
  4. The Lambda function validates the data, formats it into an email, and uses AWS Simple Email Service (SES) to send it.
  5. The function returns a success or error response, which the frontend JavaScript displays to the user.

Step 1: Structuring the Terraform Project

To keep the new infrastructure isolated from the existing visitor counter backend, I created a dedicated Terraform project for the contact form. This separation prevents accidental changes to other parts of the site and makes the new components easier to manage.

I organized the configuration into multiple files, which is a best practice that improves readability and maintainability:
provider.tf
variables.tf
main.tf
iam.tf
lambda.tf
apigateway.tf
outputs.tf
Terraform folder structure in VS Code The Terraform project was organized into separate files for clarity and maintenance.

Step 2: Initializing the Terraform Workflow

With the file structure in place, the first step in any Terraform workflow is configuring the provider and initializing the project. The provider block tells Terraform that we'll be working with AWS resources.

After running terraform init to download the necessary provider plugins, my workflow for any change followed a strict cycle:

# 1. Format the code for consistency
terraform fmt

# 2. Validate the syntax
terraform validate

# 3. Review the execution plan
terraform plan

# 4. Apply the changes
terraform apply
Sticking to this `plan -> review -> apply` cycle is critical. It ensures there are no surprises and that I know exactly what infrastructure changes will be made before they happen.

Step 3: Securing the Backend with an IAM Role

Following the principle of least privilege, the Lambda function needed an execution role with only the permissions required to do its job. I defined an IAM role in Terraform that granted the function two key capabilities:

  • The ability to write logs to Amazon CloudWatch for debugging.
  • Permission to send emails using Amazon Simple Email Service (SES).

The role's trust policy was configured to allow only the Lambda service to assume it, ensuring no other entity could use its permissions.

Lambda IAM execution role defined in Terraform The IAM role grants the Lambda function least-privilege permissions for logging and sending email.
Lambda IAM trust relationship allowing the Lambda service to assume the execution role
IAM trust relationship allowing the AWS Lambda service to assume the execution role using sts:AssumeRole.

Step 4: Building the Lambda Function

The Lambda function contains the Python code that runs when a visitor submits the form. Its job is to:

  • Receive the request body from API Gateway.
  • Read and validate the name, email, and message fields.
  • Format the data into a clean, readable email.
  • Use the Boto3 SDK to send the email via SES.
  • Return a JSON response to the frontend indicating success or failure.
After writing the function, I packaged it into a .zip archive that Terraform could deploy. AWS Lambda function overview in the AWS console The deployed Lambda function, ready to be triggered by API Gateway.

Step 5: Creating the API Gateway Endpoint

With the backend logic in place, the website needed a public endpoint to send requests to. I used API Gateway to create an HTTP API with a single /contact route that accepts POST requests. This route was then integrated with the Lambda function, so any request to the endpoint would trigger the function.

API Gateway POST route and stage The API Gateway route configuration, which forwards POST requests to the Lambda function.

Step 6: Configuring CORS

Because the browser sends a request from my domain (jonathanlayman.com) to the API Gateway domain, I had to configure Cross-Origin Resource Sharing (CORS). Without the correct CORS headers, the browser's security policy would block the request. I configured the API in Terraform to explicitly allow POST requests from my website's origin.

Step 7: Deploying the Infrastructure with Terraform

Once all the Terraform files were defined, deploying the entire backend was as simple as running one command:

terraform apply

Terraform automatically provisioned the IAM role, Lambda function, and API Gateway, including all the necessary permissions and integrations.

Successful terraform apply output in the terminal Terraform's output after successfully creating all the AWS resources.

Step 8: Connecting the Frontend Form to the API

The final step was updating the website's JavaScript. I wrote a script that:

  • Listens for the contact form's `submit` event.
  • Prevents the default browser submission.
  • Captures the form values and builds a JSON payload.
  • Sends a POST request to the API Gateway endpoint using the Fetch API.
  • Displays a success or error message to the user based on the API's response.
Frontend JavaScript for contact form submission The JavaScript code responsible for sending form data to the backend API.

Step 9: Testing the Full Workflow

With both the frontend and backend deployed, I tested the entire flow by submitting a message from the live site. The form submission was successful, and the message arrived in my inbox moments later.

A success message displayed on the contact form after submission The success message displayed to the user after the form is submitted.

Step 10: Troubleshooting with CloudWatch Logs

Throughout the process, Amazon CloudWatch Logs were essential for debugging. Whenever the form failed, I could check the Lambda function's logs to see the exact request data it received and identify any runtime errors in the Python code. This made it easy to troubleshoot issues like incorrect IAM permissions or data validation errors.

CloudWatch logs showing a successful execution of the contact form Lambda CloudWatch logs provided real-time visibility into the Lambda function's execution.

Conclusion and Lessons Learned

Building the serverless contact form was the perfect capstone for the Cloud Resume Challenge. It tied together nearly every concept from the previous stages: frontend development, backend logic, infrastructure as code, and security. The final result is a fully functional, automated, and serverless feature that enhances the website without adding operational overhead.

The biggest challenges were not in writing the code itself, but in managing the integrations between services. Debugging IAM policies, configuring CORS headers, and ensuring the data format was consistent from the browser all the way to Lambda were critical hurdles. Working through them provided invaluable hands-on experience that goes far beyond what can be learned from documentation alone. With this final piece in place, the entire website and its backend services are now managed as code, making the project resilient, repeatable, and easy to extend in the future.