← back to all posts

3 min read

A Static Blog for a Dollar a Month

The brief I gave myself: host a static blog for less than a coffee a month, without babysitting it every week. No Kubernetes. No CDN I do not need yet. No managed database for content that is just files on disk.

What I ended up with has two moving parts, and neither of them moves very often.

The shape

Markdown lives in Git. A build turns it into HTML. The HTML goes into a private S3 bucket, and CloudFront serves it. There is no origin server to overwhelm, because there is no origin server.

The bucket is private — genuinely private, with Block Public Access fully on. CloudFront reaches it through Origin Access Control, which signs every origin request with SigV4.

infra/bucket-policy.jsonjson
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipalReadOnly",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::example-blog/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1EXAMPLE"
        }
      }
    }
  ]
}

The AWS:SourceArn condition is the part people leave out. Without it the policy trusts the CloudFront service, which means any CloudFront distribution in any account can read your bucket. With it, only yours can.

The part that will catch you

An S3 REST origin does not resolve directory indexes. Request /posts/something/ and S3 looks for an object with that literal key, finds nothing, and returns 404. The static website endpoint would handle this, but using it forces the bucket public and drops HTTPS to the origin.

So the rewrite moves to the edge:

infra/viewer-request.jsjs
function handler(event) {
    var request = event.request;
    var uri = request.uri;

    if (uri.charAt(uri.length - 1) === '/') {
        request.uri = uri + 'index.html';
    } else if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {
        return {
            statusCode: 301,
            statusDescription: 'Moved Permanently',
            headers: { location: { value: uri + '/' } }
        };
    }

    return request;
}

A CloudFront Function, not Lambda@Edge. It runs in about a millisecond, costs a sixth as much, and this logic is never going to outgrow the runtime.

Cache headers earn their keep

Two rules do almost all the work. Fingerprinted assets are immutable forever; HTML must always be revalidated, or a deploy takes a day to become visible.

PathCache-Control
/_astro/*max-age=31536000, immutable
*.htmlmax-age=0, must-revalidate
/feed.xmlmax-age=3600

Get this backwards — long TTLs on HTML — and you will spend the rest of the site’s life issuing invalidations to work around it.

The bill

Storage is under ten cents. Requests are negligible, because CloudFront only touches the origin on a cache miss. Transfer is dominated by images, and there are not many. The Route 53 hosted zone is fifty cents a month flat, and is genuinely the largest line item.

Call it a dollar. It has not moved in three months, including the week a post reached the front page of a link aggregator and served forty thousand people — which cost nothing extra, because serving cached bytes from an edge is what the whole arrangement is for.