Skip to main content
  1. Posts/

Switching from WordPress to Hugo

·5 mins

My site for the past few years ran on an AWS LightSail instance, where I’d log in, write something, then post it to the internet. I would get lovely comments about nothing regarding my actual post (i.e. spam for who knows what). Between that, running a $5/mo server to basically serve static content, I felt it was time to make the jump to something a bit more advanced: Hugo + AWS CloudFront.

The Design #

I was going to make a diagram but it ended up being only three icons; so, I’ll just outline it here:

  1. I generate a static site locally on my laptop using Hugo.
  2. I upload the files to a public S3 bucket in my AWS account.
  3. Visitors would visit a CloudFront distribution that pulls the files from S3 then cache it.

The Implementation #

As with everything, the devil is in the details. What I thought would be a weekend project turned into a multi-weekend project (albeit there were plenty of life distractions as well).

Extracting content from WordPress to Hugo #

Luckily I found a great took in WordPress to Hugo Exporter on GitHub that really made this process pretty easy. There was certainly some data clean up, including rewriting some of the content to better mesh with Markdown as well as removing content that certainly had not aged well.

Using Terraform to manage the AWS resources #

One of the great features of Terraform is the concept of “workspaces” where I could spin up different versions of the stack, independent from one another to test new changes without causing an issue with the live site. This is a pattern I use regularly at work and wanted to apply this to my personal site.

There is a bootstrap problem in Terraform around storing the Terraform state. The recommendation is to use remote backend to store your state; however, it does involve creating AWS resources ahead of using Terraform. I ended up breaking up my infrastructure code into two directories: global and site.

The global directory contained the bootstrap resources for using Terraform, including the S3 bucket for storing the Terraform State file, the Route53 Hosted Zone for creating DNS records, and the necessary certificate for different versions of the sites could use. This would support both bryanjknight.com as well as *.bryanjknight.com. One manual step I had to do was make sure the DNS settings on the domain registration matched the hosted zone’s NS records; otherwise, you’ll have a bad day 😄

The site directory contained all the resources to support running the site, including the S3 bucket for storing the generated Hugo site, the CloudFront distribution, and the appropriate DNS records. One realization I had is the apex record (e.g. bryanjknight.com) must be an A record; however, there is no IP address in CloudFront. This is where Route53’s “alias” feature helps by creating an appropriate mapping between DNS and the CloudFront distribution:

resource "aws_route53_record" "apex_dns_record" {
  count   = local.is_prod ? 1 : 0
  zone_id = data.aws_route53_zone.bryanjknight_zone.id
  name    = local.apex_domain
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.hugo[0].domain_name
    zone_id                = aws_cloudfront_distribution.hugo[0].hosted_zone_id
    evaluate_target_health = false
  }
}

S3 Rewrites vs CloudFront Rewrites #

In order to properly support Hugo URLs, you need to be able to rewrite requests like https://mysite.com/posts/ to https://mysite.com/posts/index.html. There are two options:

  1. Make the S3 bucket public, have S3 host the files and do the URL rewrites, then have CloudFront fetch from S3 as a custom origin
  2. Make the S3 bucket private, write a CloudFront Lambda@Edge to rewrite the URL.

For simplicity, I went with the former (make the S3 Bucket public) as I wanted to minimize my exposure to lambdas for this website (I’ve been in situations where bad planning with Lambdas result in a very large bill). In Terraform, it looks like this:

resource "aws_s3_bucket" "static_site" {
  bucket = "${local.name_prefix}-bryanjknight-site"

  # we will destroy the bucket, even if there's data
  force_destroy = true

  # we use public read because I don't want to bother writing
  # lambda@edge when I can do this with s3 website routing
  acl = "public-read"

  website {
    index_document = "index.html"

    routing_rules = <<EOF
[{
    "Condition": {
        "KeyPrefixEquals": "/"
    },
    "Redirect": {
        "ReplaceKeyWith": "index.html"
    }
}]
EOF
  }

  cors_rule {
    allowed_headers = []
    allowed_methods = ["GET"]
    allowed_origins = ["https://s3.amazonaws.com"]
    expose_headers  = []
    max_age_seconds = 3000
  }
}

Picking a Theme #

My goal was something minimalist (not a lot of flashiness, support for light and dark modes) and responsive (works both on a laptop, tablet, and phone). I first tried ananke which is the theme you start off with in Hugo. This was all and well but I found a lot “stutter” with my name (doesn’t help I haven’t written anything in 3 years). While I liked the featured image on each page, it gave a weird user experience if I didn’t have one setup for each page.

I settled on congo which checked off everything on my wishlist for a blog theme. Obviously you might want a different design and the Hugo community has a great selection of different styles. The configuration took a bit of work, but starting with the example site, then slowing changing it to fit my needs resulted in the best outcome.

Reflections and Next Steps #

The only decision I might revisit is the global terraform project that is stored locally on my machine. In theory, this really should be a AWS CloudFormation file, but I was trying to not have everything scattered in different IaC frameworks. In the worst case scenario that I lost the tfstate file, I could also recreate the state file by hand. It’s only a few resources and would be about 20-30 mins of work.

The next step is to leverage GitHub Actions to auto-deploy on push to my git repo. This might be a bit overkill since I can do this in two commands (hugo then hugo deploy --target prod), so it’s not high on my priority list.