Vladyslav Ratslav

Cloud Architect · DevOps · MLOps · SRE Consultant

My Quick and Free Website

Published by Vladyslav Ratslav · Cloud Architect · January 2026

Also published on LinkedIn: Read on LinkedIn

I built a one-page website in 120 minutes — and it cost me almost nothing.

Idea

Several weeks ago, I started a conversation with an AI about how to find potential customers for my DevOps and architecture consulting services. One suggestion the AI gave was to build a website — just a simple landing page. That page could act as a business card, service catalog, portfolio, and contact form at the start, and be expanded later.

Reluctance and mindset

At first, I was reluctant. I thought it would take too much time, and a lot of questions popped into my head: which framework should I use, do I need authentication, and so on. I tend to think in high dimensions and jump to complex solutions. I took a tea break to clear my head and realized I didn’t need a complex solution yet.

Building the site

I went back to my PC, uploaded my CV to the AI, and brainstormed a simple one-page site. I asked the AI to generate it. The first draft wasn’t great, but after a few iterations, it improved a lot. After about an hour, I had a decent site I could share with the world.

Hosting and launch

I opted for a minimal, serverless architecture to keep costs and operational overhead low:

  • CloudFront for global delivery and HTTPS.
  • S3 for static assets (origin).
  • Lambda Function URL for the contact endpoint.
  • Route 53 and ACM for DNS and TLS.

This approach avoids running servers, leverages edge delivery, and keeps the bill negligible for light traffic. AWS credits helped offset initial costs.

Deployment with Terraform

I deployed the site with Terraform and kept the configuration small and repeatable. Below is an overview of the architecture, the Terraform resources I created, and the critical CloudFront/Lambda configuration that made everything work reliably.

Architecture overview

What I built

A serverless static site hosted at the edge with a contact form handled by a Lambda Function URL. The design is production-ready for low traffic and easy to maintain.

Terraform resources created

After registering the .com domain I provisioned the following Terraform resources:

  • S3 bucket for static site files; public access blocked and served via CloudFront.
  • S3 bucket policy to allow CloudFront origin access and restrict direct public reads.
  • CloudFront distribution for CDN, HTTPS, caching, and the site’s domain.
  • ACM certificate in us-east-1 for CloudFront with DNS validation.
  • Route 53 hosted zone and records including A/ALIAS and DNS validation records.
  • Lambda function and Lambda Function URL to validate and forward form submissions.
  • IAM roles and policies with least privilege for Lambda and CloudFront origin access.
  • Lambda permissions to allow CloudFront to invoke the Lambda Function URL and the function itself.
  • CloudWatch log groups and alarms for Lambda monitoring and simple error alerts.

Tip: Create DNS validation records early so ACM can issue the certificate while other resources provision; this speeds up the overall deployment.

CloudFront origins and critical configuration

My CloudFront distribution has two origins:

  • static — static website content served from S3.
  • lambda — forwarding requests to the Lambda Function URL.

Important configuration To avoid persistent 403 Forbidden errors when CloudFront forwards requests to the Lambda origin, configure the CloudFront behavior for the Lambda origin with:

  • Cache policy: CachingDisabled (ID: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad)
  • Origin request policy: AllViewerExceptHostHeader (ID: b689b0a8-53d0-40ab-baf2-68738e2966ac)

Without these policies, CloudFront can strip or alter headers required by the Lambda origin and the requests will be rejected.

Packaging Lambda and permissions

Packaging with Terraform

I packaged the Lambda code as a zip and deployed it via Terraform using archive_file. Example:

data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/../backend"
  output_path = "${path.module}/../lambda_function.zip"
}

resource "aws_lambda_function" "contact_form" {
  filename         = data.archive_file.lambda_zip.output_path
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  function_name    = "site_contact_form"
  handler          = "index.handler"
  runtime          = "nodejs18.x"
  role             = aws_iam_role.lambda_exec.arn
  environment {
    variables = {
      DESTINATION = "https://hooks.example.com/your-webhook"
    }
  }
}

Lambda permissions for CloudFront

I added two permissions so CloudFront can invoke the Lambda Function URL and the function itself:

resource "aws_lambda_permission" "allow_cloudfront_principal" {
  statement_id  = "AllowCloudFrontServicePrincipal"
  action        = "lambda:InvokeFunctionUrl"
  function_name = aws_lambda_function.contact_form.function_name
  principal     = "cloudfront.amazonaws.com"
  source_arn    = aws_cloudfront_distribution.static.arn
}

resource "aws_lambda_permission" "allow_cloudfront_principal_invoke_function" {
  statement_id  = "AllowCloudFrontServicePrincipalInvokeFunction"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.contact_form.function_name
  principal     = "cloudfront.amazonaws.com"
  source_arn    = aws_cloudfront_distribution.static.arn
}

Static content deployment and invalidation

To publish the site I synced the built static files to S3 and invalidated CloudFront

aws s3 sync --delete . "s3://<site>"
aws cloudfront create-invalidation --paths "/*" --distribution-id <ID>

Lambda handler code

Below is the Lambda function code used exactly as provided (no changes):

const nodemailer = require('nodemailer');
const querystring = require('querystring');

// Email validator
const isValidEmail = (email) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
};

// Lambda handler
exports.handler = async (event) => {
    try {
        // console.log('Full event:', JSON.stringify(event, null, 2));

        // If API Gateway used Lambda proxy integration, body is a string
        const rawBody = event.body;
        const bodyStr = event.isBase64Encoded ? Buffer.from(rawBody, 'base64').toString('utf8') : rawBody;
        const body = querystring.parse(bodyStr);
        const { name, email, company, message, 'g-recaptcha-response': recaptchaResponse } = body;

        // Validate required fields
        if (!name || !email || !message || !recaptchaResponse) {
            return {
                statusCode: 400,
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ error: 'Missing required fields' }),
            };
        }
        // Validate email format
        if (!isValidEmail(email)) {
            return {
                statusCode: 400,
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ error: 'Invalid email format' }),
            };
        }

        // Create transporter
        const transporter = nodemailer.createTransport({
            service: 'gmail',
            auth: {
                user: process.env.USER,
                pass: process.env.PASS,
            },
        });

        // Send email
        await transporter.sendMail({
            from: process.env.USER,
            to: process.env.USER,
            subject: `New Contact from ${name}`,
            html: `
                <p><strong>Name:</strong> ${name}</p>
                <p><strong>Email:</strong> ${email}</p>
                <p><strong>Company:</strong> ${company}</p>
                <p><strong>Message:</strong></p>
                <p>${message}</p>
            `,
        });

        return {
            statusCode: 200,
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ 
                message: 'Email sent successfully',
                success: true
            }),
        };
    } catch (error) {
        return {
            statusCode: 500,
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ error: error.message }),
        };
    }
};

Domain purchase and cost note

I purchased a .com domain up front and switched the AWS account to a paid plan from the start. The domain cost about $15/year plus $3 tax on Amazon.

If you already have a domain or are OK using the native CloudFront domain, the site can be free for the first six months. After that, CloudFront’s free tier still applies until you exceed 1M requests, 100 GB of data transfer, or 5 GB of included S3 storage.

Quick takeaways

  • Time: Initial site built in about 120 minutes, including account setup and Google reCAPTCHA.
  • Cost: Minimal for light traffic; domain was the only out-of-pocket expense.
  • Approach: Start simple; iterate with AI; avoid overengineering.
  • Deployment: Terraform made the deployment repeatable and easy to tear down.