Julian Unruh
DE

A lean website on AWS: secure, fast and almost free

· 9 min read #aws #opentofu #security #cloudfront

A tower of flat stones on a rock by the sea, with the evening sky behind it
Photo: Unsplash

A personal or company website usually comes down to two options. A site builder costs 20+ euros a month and only allows what the provider has planned for. A dedicated server offers full freedom, but it has to be patched, monitored and secured, even though it sits idle most of the time.

There is a third option that is often the best fit for portfolios, company sites, blogs and documentation: the site is built statically and delivered by a CDN. Only the few parts that really need to be dynamic run as individual functions. The result is fast, offers hardly any attack surface and costs almost nothing.

This site shows what that looks like in practice. nelumbo.dev runs exactly this way, and the AWS part of the bill stays below one dollar a month.

The principle: serve static files, keep only the essentials dynamic

A static site generator such as Astro, Hugo or Eleventy turns texts and templates into finished HTML, CSS and image files. After that, no code runs on the server, there is no database and no admin login. What does not run does not need patching, and it costs nothing as long as nobody visits.

Anything dynamic, such as a contact form, is handled by a Lambda function. It only runs when it is called and is billed per call.

The setup consists of a few building blocks:

  • S3 stores the built files.
  • CloudFront delivers them, with a free TLS certificate.
  • A CloudFront Function takes care of clean URLs.
  • A Lambda function with SES sends the contact form.
  • Cloudflare handles the domain, DNS and mail forwarding.

A private bucket as storage

Many tutorials recommend enabling S3 “Static Website Hosting” and making the bucket public. That is best avoided. With a custom domain there is no HTTPS that way, and the files would also be reachable without going through CloudFront.

A private bucket with every “Block Public Access” switch enabled is the better choice. Only the site’s own CloudFront distribution may read it. Origin Access Control takes care of that: CloudFront signs every request to S3, and the bucket policy only allows requests that come from exactly this distribution.

Two additions are worth it:

  • CloudFront should be allowed s3:ListBucket in addition to s3:GetObject. Otherwise S3 answers missing pages with “403 Forbidden” instead of “404 Not Found”, and the site’s own 404 page never shows.
  • The bucket policy denies all access without TLS through the condition aws:SecureTransport. On top of that, “Block Public Access” is enabled for the entire AWS account, so no bucket can become public by accident later on.

A typical website is a few hundred kilobytes to a few megabytes. Storage costs are barely measurable.

CloudFront for delivery

CloudFront serves the site from locations close to the visitors. HTTP/2 and HTTP/3, IPv6 and automatic compression with Brotli or gzip are enabled. The cheapest price class only uses locations in Europe, North America and Israel. That is plenty for most sites, and visitors from other regions still get the site.

For small sites, CloudFront usually costs nothing. The permanent free tier includes 1 TB of data transfer and 10 million requests per month.

AWS Certificate Manager issues the certificate for free. For CloudFront it has to live in the us-east-1 region. It renews itself and needs no further attention. The security policy for connections is set to TLSv1.2_2025, which only allows TLS 1.2 with strong ciphers and TLS 1.3.

Clean URLs with a CloudFront Function

Static site generators usually create pages as folders, so /blog/example/index.html. With private access, however, S3 does not add index.html. Without help, /blog/example/ leads nowhere.

A small CloudFront Function solves this. It runs at the edge on every request, before the cache is even checked. For nelumbo.dev it looks like this:

function handler(event) {
  var request = event.request;
  var uri = request.uri;

  if (request.headers.host.value !== 'nelumbo.dev') {
    return redirect('https://nelumbo.dev' + uri);
  }
  if (uri.endsWith('/')) {
    request.uri += 'index.html';
  } else if (!uri.split('/').pop().includes('.')) {
    return redirect(uri + '/');
  }
  return request;
}

The function appends index.html and adds a missing trailing slash. It also redirects www and the technical CloudFront address to the actual domain. That way every page exists under exactly one URL, which search engines appreciate as well. CloudFront Functions are much lighter than Lambda@Edge and run in under a millisecond. Two million invocations a month are free.

Caching that fits the deployment

Good generators add a hash to the names of CSS, JavaScript and font files. When the content changes, the name changes. Browsers may therefore keep these files for a year. HTML, on the other hand, is cached only by CloudFront, and that cache is cleared on every deployment. With the AWS CLI this takes three commands:

aws s3 sync dist/_astro s3://$BUCKET/_astro --delete \
  --cache-control "public, max-age=31536000, immutable"
aws s3 sync dist s3://$BUCKET --delete --exclude "_astro/*" \
  --cache-control "public, max-age=0, s-maxage=31536000"
aws cloudfront create-invalidation --distribution-id $DISTRIBUTION --paths "/*"

CloudFront then answers almost every request straight from its cache, while new content is still visible immediately. The invalidation is free: the first 1,000 paths per month cost nothing, and /* counts as a single path.

Security headers

With a custom response headers policy, CloudFront adds headers to every response that tell the browser to treat the site more strictly. For nelumbo.dev these are:

  • Strict-Transport-Security for two years, covering all subdomains and with preload. Browsers then connect over HTTPS only.
  • Content-Security-Policy: it defines where scripts, styles, images and fonts may come from. At build time, Astro generates a hash for every embedded script and style and writes the matching policy into the page. Anything that does not match is not executed. The directive frame-ancestors 'none' only works as a header, so it comes from CloudFront.
  • X-Frame-Options and X-Content-Type-Options for older browsers.
  • Referrer-Policy, so other sites only see the domain, not the full address.
  • Permissions-Policy, which turns off camera, microphone, location and similar interfaces.
  • Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy, which shield the site from foreign windows and sites.

The policy also removes headers that only reveal what runs behind the site. None of this costs extra.

A lean CSP is easier when fonts and scripts are served from the site itself instead of third-party servers. That keeps the policy simple, saves connections and is good for privacy, too.

A contact form without a server

A form does not need an API Gateway. With a function URL, Lambda provides a public HTTPS address by itself, which saves a whole component. The ARM architecture is cheaper on Lambda, and 128 MB of memory is plenty for a form.

The function validates every input itself: required fields, lengths and the format of the email address. Validation in the browser alone is never enough. CORS only allows requests from the site’s own domain. The message goes out through SES with the sender’s address as the reply-to address, so a reply is one click away and no database is needed anywhere.

The function’s role gets only the permissions it really needs: sending mail from its own domain and writing to its own log. Logs are deleted after 30 days.

The costs are negligible. Lambda has a permanent free tier with one million invocations a month, and SES costs ten cents per thousand emails.

DNS and mail at Cloudflare

Domain and DNS can stay at Cloudflare without any drawback. DNS is free there, whereas a hosted zone in Route 53 costs 50 cents a month, more than everything else combined. The CloudFront entries are set to “DNS only”, without the Cloudflare proxy. Two CDNs in a row bring no benefit.

A few DNS records go a long way for security, too:

  • DNSSEC signs the records, so nobody can redirect visitors with forged answers. If the domain is registered at Cloudflare, a single switch is enough.
  • CAA records define which certificate authorities may issue certificates for the domain at all.
  • DKIM for SES and a strict DMARC policy with p=reject prevent anyone from convincingly sending mail in the domain’s name.

Cloudflare Email Routing forwards addresses such as info@ to an existing mailbox for free. No separate mail server is needed.

Everything as code

The entire infrastructure is described as code, here with OpenTofu, though Terraform would work just as well. For nelumbo.dev that is about 30 resources. Every change shows up in tofu plan beforehand, and the site can be rebuilt at any time.

The state lives in a versioned S3 bucket that is also only reachable over TLS. Since OpenTofu 1.10, a lock file in the same bucket is enough for locking, so a DynamoDB table is no longer needed. The AWS account ID is pinned in the provider. If the wrong profile is active by mistake, OpenTofu stops instead of creating resources in the wrong account. The Cloudflare provider creates all DNS records in the same configuration.

What it costs

The monthly bill for nelumbo.dev:

Item Cost per month
S3 (site and state) fractions of a cent
CloudFront including Function 0 USD within the free tier
Certificate (ACM) 0 USD
Lambda 0 USD within the free tier
SES 0.10 USD per 1,000 emails
CloudWatch Logs a few cents
DNS, DNSSEC, mail forwarding 0 USD at Cloudflare
.dev domain about 1 USD, spread from the yearly price

The AWS part stays well below one dollar. The only noticeable cost is the domain.

When this setup fits

For websites whose content is maintained by technically minded people, this setup is hard to beat. The site stays fast because almost everything comes from the cache, and secure because there is hardly anything to attack. It grows without rework, whether for a hundred or for millions of visitors. When more dynamic features are needed, such as search or a newsletter, further functions can be added that also only cost per call.

It is less suitable when editors without technical knowledge need to change content in the browser. That calls for an additional headless CMS that rebuilds the site whenever something changes.

After the initial setup, there is hardly any maintenance: nothing to patch, certificates renew themselves, and a deployment is a single command.


← All posts