Serverless web hosting you can afford

These days even your grandma and your dog have a podcast, so what are you waiting for?

My intent is to give our nascent podcast a proper RSS feed in order to be consumable by podcast aggregators like iTunes. Patreon is great, but it does not give you an RSS feed you can submit to iTunes. The RSS feed is per-patron, and their terms and conditions prevent sharing the RSS feed of patron-only feed with the rest of the world. Now, we wouldn’t want to go against the rules, would we?

After researching this a (very little) bit, I found out there are a myriad options, and if you’re like me, you want to keep it simple, stupid and cheap, while maintaining control over your audio file hosting.

My podcast architecture includes:

  • an cloudfront distribution + s3 bucket to serve the website
  • a hugo generated website
  • an additional versioned s3 bucket for audio assets backup

This article focuses on the first part. If there’s interest, i’ll turn this into a series.

  • Install Terraform(?) and Terragrunt(?).
  • Create a directory in which to keep your infrastructure-as-code definitions. This is the directory you can keep under version control
  • Download widdix templates required for a static website:
  #! /usr/bin/env bash
  #
  # (Refer to widdix/aws-cf-templates github project for more info)
  #
  set -euo pipefail

  version="v${1:-7.3.0}"
  baseurl="https://s3-eu-west-1.amazonaws.com/widdix-aws-cf-templates-releases-eu-west-1/${version}"

  for template in \
      vpc/zone-public.yaml \
      static-website/static-website.yaml \
      static-website/lambdaedge-index-document.yaml; do
      wget -O "cf-$(basename "${template}")" "${baseurl}/${template}"
  done

new versions are published regularly, so if you try a newer version of the templates and encounter problems, let me know. The current released versions are to be found on github

  • save the following terraform template to a website.tf file:
  #
  # WARNING: This stack only works in us-east-1
  # which is where the certificate and the lambda@edge
  # function must be created!!!
  #
  variable "website_label" {
      description = "the dns label for you website. For example, if you wish to create a website called mywebsite.com, then set this variable to 'mywebsite'"
  }

  variable "website_tld" {
      description = "the tld for your website. Define a custom value if it differs from 'com'"
      default = "com"
  }

  terraform {
    backend "s3" {}
  }

  provider "aws" {
    region = "us-east-1"
  }

  locals {
    domain_name = "${var.website_label}.${var.website_tld}"
  }

  data "local_file" "zone" {
    filename = "${path.module}/cf-zone-public.yaml"
  }

  data "local_file" "website" {
    filename = "${path.module}/cf-static-website.yaml"
  }

  data "local_file" "lambdaedge-index-document" {
    filename = "${path.module}/cf-lambdaedge-index-document.yaml"
  }

  resource "aws_cloudformation_stack" "zone" {
    name = "${var.website_label}-zone-stack"
    template_body = "${data.local_file.zone.content}"
    parameters {
      Name = "${local.domain_name}"
    }
  }

  resource "aws_cloudformation_stack" "lambdaedge-index-document" {
    name = "${var.website_label}-lambdaedge-index-document-stack"
    template_body = "${data.local_file.lambdaedge-index-document.content}"
    capabilities = ["CAPABILITY_IAM"]
    parameters {
      DomainName = "${local.domain_name}"
      LogsRetentionInDays = 1
    }
  }

  resource "aws_cloudformation_stack" "website" {
    name = "${var.website_label}-website-stack"
    template_body = "${data.local_file.website.content}"
    on_failure = "ROLLBACK"
    parameters {
      SubDomainNameWithDot = ""
      LambdaEdgeSubdirectoriesVersionArn = "${aws_cloudformation_stack.lambdaedge-index-document.outputs["LambdaVersionArn"]}"
      ParentZoneStack = "${aws_cloudformation_stack.zone.name}"
      CertificateType = "CreateAcmCertificate"
    }
  }

  data "aws_route53_zone" "website" {
    zone_id = "${aws_cloudformation_stack.zone.outputs["HostedZoneId"]}"
  }

  output "website_url" {
    value = "${lookup(aws_cloudformation_stack.website.outputs, "URL", "(unknown)")}"
  }

  output "cloudfront_distribution_id" {
    value = "${lookup(aws_cloudformation_stack.website.outputs, "DistributionId", "(unknown)")}"
  }

  output "nameservers" {
    value = "${data.aws_route53_zone.website.name_servers}"
  }
  • Save the following file to terraform.tfvars (replace CHANGEME with the name of a non-existing bucket that will contain the terraform state):
  terragrunt = {
    terraform {
      source = "."
    }
    remote_state {
      backend = "s3"
      config {
        bucket         = "CHANGEME"
        key            = "terraform.tfstate"
        region         = "us-east-1"
        encrypt        = true
        dynamodb_table = "terraform-locks"
      }
    }
  }
  • Run terragrunt in the same directory. Here we want to create a website for edaqaandstephane.net, so we pass the appropriate variables in:
  terragrunt apply \
    -var website_label=edaqaandstephane \
    -var website_tld=net

you can alternatively save these variables in the terraform.tfvars to shorten the terragrunt apply command-line, and make it possible to version-control the current state of the infrastructure:

  website_label = "edaqaandstephane"
  website_tld = "net"
  terragrunt = {
  ...
  • The command runs for a long time, and in the process you’ll be asked to approve a certificate request by email. Click the link in the email when you receive it, or the terragrunt command will fail. In the resulting output, you’ll see 4 nameservers:
  cloudfront_distribution_id = ...
  nameservers = [
    ns-xx.awsdns-yy.org,
    ...
  ]
  website_url = https://edaqaandstephane.net
  • Assuming you’re registered a domain name, go to the registrar and add the 4 nameservers from the previous output to the registered domain configuration. The steps to do this depend on your registrar and are not explained here.

You now have a fully working (albeit empty) website. To deploy website changes, you can use aws s3 sync:

aws s3 sync \
  --acl "public-read" \
  "{local www directory}" \
  "s3://{website_label}.{website_tld}"
  ...

Example:

aws s3 sync \
  --acl "public-read" \
  --sse "AES256" \
  --cache-control "public, max-age=3600"
  "public/" \
  "s3://edaqaandstephane.net"

Your changes will be cached at various levels, so your changes will sometimes not be immediately visible. If you need to force the invalidation of the caches for a specific path, use:

aws cloudfront create-invalidation \
  --distribution-id "{cloudfront_distribution_id}" \
  --paths "/*"

The paths can be as specific as you need them to be. For example, you’d use "/css/*" to ensure the cache is reloaded for your css stylesheets.