This blog post serves as documentation of my experience of optimizing the workflow for deploying and maintaining my personal website. Secondary to this, it served as a test post that allowed me to develop my website’s layout and styling. While this post offers a few pointers for fleshing out the process of deploying and maintaining a static site with the use of Continuous Integration and Continuous Deployment, it is not meant to be an in-depth tutorial for any of the tools mentioned in this post.
We All Do Dumb Things…
As I left school, I decided that it would be a good idea to make a website so I could better market myself. I already bought myself a domain to use for email and a couple of applications, so I might as well use it to host a resume and a blog too.
WYSIWYG/CMS platforms such as WordPress or Squarespace were not appealing; a static site generator was the way to go. Jekyll was an attractive option, but I decided to go with Hugo. My decision was mainly based on Jekyll being written in Ruby while Hugo is written in Go; the speed of compiled Go could prove beneficial if I ever decided to expand my site in the distant future.
I started out with the minimal xmin theme. Initially I kept the blog components in, but they’ve since been hidden since I didn’t really use them. I found a nice-looking responsive resume template (I can’t currently find the source of this otherwise I would give credit) and I hacked it into xmin’s index.html page. I did some extensive modifications to resume template until I had something that I thought looked nice. Then I…
Look, we all make stupid mistakes while we’re still learning things; I have to admit, sometimes it takes me a bit to really get something. I was itching to get my website up, so instead of learning how to properly create a template out of the resume HTML, I built the entire resume, content and all, in the theme’s index.html with no templating functionality used besides tacking a header and footer on.
This could have been done without any sort of HTML-generating program at all; I would have been better off not involving any tooling other than a text editor. I had Hugo sticking static HTML snippets together for a single static page, and any time I needed to update anything on that page, I had to make changes to the theme submodule. It was a mess, but it worked just well enough to give me a functioning website, so I left it that way.
I used a script to upload the published site files via FTP to my email provider, who happens to offer static website hosting. The only things I’ve done to that site since its original inception are change the accent color and update the experience section. The latter part involved making sure that I had the right HTML tags on the new data. Having all that HTML in there made it difficult to proofread and edit.
Here we go again…
I came across some inspiration in the form of a series of posts; I remembered I had a Hugo-generated static website that hasn’t seen much in the way of TLC over the past couple of years. I’ve already been in the process of brushing up on my devops-related skills, such as using IaC tools to make running infrastructure easier at the cost of some upfront effort. I decided that turning this static site into a DevOps practice project would be a good way to brush up on my Terraform and CI/CD skills while giving myself a marginally updated and easier to maintain web resume.
This wouldn’t be my first time using Terraform; I’ve used it to deploy a couple of personal-use web apps into DigitalOcean, and I’ve encountered it quite a few times while going through DevOps training materials. That said, I was naive when I had last used Terraform for any real-life work. The code I wrote was not very reusable and not well-organized. Going through this project gave me the opportunity to reevaluate how I use this invaluable tool.
The tools I ended up working with include:
-
Hugo static site generator
-
Git version control system
-
ImageMagick for programmatically generating favicons
Site Unseen
The first step to deploying something is having something to deploy.
I created a new empty Hugo site using hugo new site
and initialized both a main git repository elijahlove-hugo
and a submodule for the theme resume-focus-theme
.
I dropped in the theme files from my old site and made enough changes to the site’s config.toml
to get a working static site. This new site was exactly like my old site, so I created my first commits on the master
branches of both my site repository and my theme submodule.
In style.css
With a functioning site base in place and a git commit to keep me safe from breaking changes, it was now time to get my hands dirty.
I decided that I would start out by cleaning up the CSS a little; I knew that the content and structure of my resume page wasn’t going to change in any drastic manner, so it made sense to get styling out of the way.
My old site was mostly gray with sparsely used accent lines; it needed livening up, so I added more color to the mix. I drew inspiration from window manager configurations I’ve seen on r/unixporn; the idea was to separate my content into bordered boxes to look a bit like a vertical stack of terminal windows. I created a new branch of the resume-focus-theme
submodule, put gradient borders around each section of content, and set a monospace font for all content on the page.
At the advice of a friend, I ended up changing the font to something easier on the eyes; I backed off from the terminal idea a tiny bit, but kept the window motif.
Templatization
With theming out of the way, I could no longer put off templatizing the resume portion of the index page.
As a source of inspiration for how to approach this task, I looked to eddiewebb/hugo-resume.
Hugo has Data Templates as a means of populating HTML pages with various sources of data; YAML, JSON, TOML, and XML files that are placed in a site’s or themes data
directory are supposed to be accessible using the variable .Site.Data
. For some reason I was unable to get this working; half an hour of searching yielded nothing useful.
As a workaround, I used Hugo’s mechanism for getting external data; two functions, getJSON
and getCSV
, are available to fetch data from outside the Hugo site’s source tree. As their names suggest, this only works with JSON and CSV formatted data; CSV would be difficult to use since the structure of my resume naturally calls for nesting, so with JSON had to go. In the future I’ll look closer into how I can get the .Site.Data
variable working so I can format my resume details as YAML.
I made 6 files in the site’s data
directory, each file containing a JSON object for a resume section. I could have put all the objects into one file, that separating them makes working with my resume data easier down the road.
$ tree data
data
├── activities.json
├── basicInfo.json
├── certifications.json
├── education.json
├── experience.json
├── keySkills.json
└── relevantCoursework.json
# data/experience.json:
{
"experience": [
{
"date": "March 2022 - Present",
"title": "Provisioning Engineer for SD-WAN",
"company": "Vertek Corporation",
"location": "Colchester, VT",
"details": [
"Provisioned and deployed highly available VMware SD-WAN for enterprise customers",
"Leveraged VMware SD-WAN REST APIs using shell scripts to retrieve and sort customer configurations"
]
},
{
"date": "September 2020 - March 2022",
"title": "Technical Support Specialist II for SD-WAN",
"company": "Vertek Corporation",
"location": "Colchester, VT",
"details": [
"Worked with customers to create network designs to implement SD-WAN into both new and existing network infrastructure",
"Provisioned and supported managed VMware SD-WAN edge devices"
]
}
[...]
]
}
# data/relevantCoursework.json:
{
"relevantCoursework": [
"System Administration I & II",
"Systems Security",
"Cloud Administration & Deployment",
"TCP/IP",
"Technical Writing",
"Communications Telecoms",
"Network Design",
"Systems Software",
"Enterprise & Perimeter Security",
"Computer & Network Security"
]
}
# themes/resume-focus-theme/layouts/partials/portfolio/experience.html:
{{ $json := getJSON "data/experience.json" }}
[...]
<div class="sectionContent">
{{ range $json.experience }}
<article>
<p class="detailDate">{{ .date }}</p>
<h2 class="detailTitle">{{ .title }}</h2>
<h3 class="detailCompany">{{ .company }} - {{ .location }}</h3>
<ul>
{{ range .details }}
<li>{{ . }}</li>
{{ end }}
</ul>
</article>
{{ end }}
</div>
[...]
I put the HTML templates for this data in the resume-focus-theme
submodule, under the layouts
folder; I access these templates by referring to them in the main index.html
file for the theme:
# themes/resume-focus-theme/layouts/index.html:
{{ define "main" }}
{{ partial "portfolio/education.html" . }}
{{ partial "portfolio/certifications.html" . }}
{{ partial "portfolio/relevantCoursework.html" . }}
{{ partial "portfolio/keySkills.html" . }}
{{ partial "portfolio/experience.html" . }}
{{ partial "portfolio/activities.html" . }}
{{ end }
To use the JSON data, I first use the getJSON
function to grab specific JSON files from the data
folder and put it into a map
variable. For example, in the experience.html
file, I have the following:
{{ $json := getJSON "data/experience.json" }}
Later down in the template, I access data in my activities
list from the $json
variable I created.
You use dot notation in order to access data inside the $json
variable. Hugo includes a number of functions for use in templates, but for this particular use case, I only really need range
, which I’m using to iterate through lists of strings. Using range
can render as many entries in each section as needed without worrying about copying and maintaining a bunch of tags.
Later, I realized the JSON Resume project exists, standardized schema for storing resumes as structured data. It’s already similar to the schema I ended up using, so I might adapt my template to use it in the future.
Tabling the Issue
I wanted to add a div around the tables that Hugo generates from the markdown files that are converted into blog posts. Hugo offers Render Hooks, which allows one to override the HTML makeup of certain supported elements that have been rendered from the markdown source, but at this time tables are not one of those supported elements.
Instead, I’ve had to take the slightly less elegant approach: wrapping my tables in a custom shortcode.
# themes/resume-focus-theme/layouts/shortcodes/table.html
<div id="table">
{{ .Inner | markdownify }}
</div>
To use this shortcode, I put {{< table >}}
and {{< /table >}} tags around my markdown tables; the Markdown content from between the two tags gets dropped into the
.Inner` variable, converted into HTML, and then wrapped up into a div.
{{< table >}}
| App Name | Domain | Git Branch |
| ---------------------- | -------------------------------------------------------- | ---------- |
| elijahlove-xyz-prod | [elijahlove.xyz](https://elijahlove.xyz) | `master` |
| elijahlove-xyz-develop | [develop.elijahlove.xyz](https://develop.elijahlove.xyz) | `develop` |
{{< /table >}}
The downside to this approach is that I must wrap all of my tables in extra tags. It’s not a huge deal, but it’ll be nice to have expanded render hook options in the future.
Deployment
The Host
Up to now I had been using my email provider’s static site hosting functionality, which allows asset upload either through a web portal or FTPS. Every time I wanted to upload a new copy of my site, I’d run a tiny script that first ran Hugo, then use rclone to sync the public
directory with my provider’s storage over FTPS.
It’s simple and it works, but it doesn’t really give much opportunity to cut my teeth on more modern deployment strategies. Inspired by the article I mentioned above, I decided that I would host this using a cloud infrastructure provider, although I went a different route than using a Google Cloud Storage bucket as the author had.
I already utilize DigitalOcean for managing DNS records for my domain as well as hosting a small handful of Dockerized apps, so they were the first place I looked when browsing hosting options. Their Spaces object storage offering is more or less analogous to the GCP Storage offering mentioned above, but that particular service doesn’t support hosting static sites directly.
DigitalOcean does have an Apps platform, a Heroku-style PaaS which has a static site option that can compile sites using a multitude of tools such as Hugo. DigitalOcean currently lets one host 3 static sites for free, which works out very well for me as I can host both a production version and a work-in-progress version of the site. To use the Apps platform, one selects a branch from a git repository with the site’s source code. A container with the appropriate tooling, in my case Hugo, gets spun up to compile the source code from the repo into a finished static site, and then the result is served up under a semi-randomly-generated URL.
dev
and prod
The versions of my site I’ve deployed are as follows:
App Name | Domain | Git Branch |
---|---|---|
elijahlove-xyz-prod | elijahlove.xyz | master |
elijahlove-xyz-develop | develop.elijahlove.xyz | develop |
My deployment workflow is pretty simple; if I want to check that everything compiles right after a change in the DigitalOcean environment, I can merge changes to the develop
branch and push to my GitLab repo. My DigitalOcean App picks up the changes, recompiles the site with Hugo, and deploys the output to develop.elijahlove.xyz.
Once I’m happy with that, I merge the develop
branch into master
and the same thing happens, except this time the changes go live at elijahlove.xyz.
Doing it Declaratively
DigitalOcean makes it easy to set up apps with their wizard, but I didn’t want to deploy my website the easy way.
Terraform is a cross-platform cross-cloud tool that allows one to provision out infrastructure using declarative code written in HashiCorp Configuration Language (HCL). Using a tool like this allows for quick provisioning and deprovisioning of environments in a quick and repeatable manner.
$ tree -P "*.tf" -P "*.hcl" terraform
terraform
├── main.tf
├── modules
│ └── do_static_site
│ ├── main.tf
│ ├── outputs.tf
│ ├── provider.tf
│ └── variables.tf
├── outputs.tf
├── provider.tf
└── variables.tf
A multitude of files make up my Terraform configuration. When running a command such as terraform plan
or terraform apply
, Terraform takes into account all .tf
files in the working directory. Configuration in the modules
directory also grant the opportunity to reuse code.
# main.tf
module "static_site" {
count = 2
source = "./modules/do_static_site"
site_name = "${var.site_name}-${[var.prod_name, var.develop_name][count.index]}"
base_domain = [var.base_domain, "${var.develop_name}.${var.base_domain}"][count.index]
site_repo = var.site_repo
branch = [var.prod_branch, var.develop_branch][count.index]
build_command = var.build_command
hugo_version = var.hugo_version
do_token = var.do_token
}
Using the count
meta-argument, I can create two resources from one block and use [count.index]
to select the appropriate string from a list to use f
or the develop
and prod
sites.
Previously I had used two similar module blocks that had all the same inputs except for site_name
, base_domain
, and site_repo
, but that ended up creating unnecessary duplicate code. Using count
as above adheres better to DRY (Don’t Repeat Yourself) principles. Separating my configuration into modules gives me the opportunity to rapidly provision out other Hugo-generated static sites in the future.
# modules/do_static_site/main.tf
resource "digitalocean_app" "static_site" {
spec {
domains = [
var.base_domain,
]
name = var.site_name
region = "nyc"
alert {
disabled = false
rule = "DEPLOYMENT_FAILED"
}
static_site {
build_command = var.build_command
environment_slug = "hugo"
name = var.site_name
source_dir = "/"
error_document = "404.html"
env {
key = "HUGO_VERSION"
scope = "BUILD_TIME"
value = var.hugo_version
}
env {
key = "HUGO_EXTENDED"
scope = "BUILD_TIME"
value = 1
}
gitlab {
branch = var.branch
deploy_on_push = true
repo = var.site_repo
}
routes {
path = "/"
preserve_path_prefix = false
}
}
}
}
There are a couple of simple examples in the DigitalOcean Terraform documentation regarding setting up static sites, but did was create a deployment first through the DigitalOcean App dashboard and use terraform import
as a starting point for the template. Behind the scenes, the digitalocean_app
resource gets compiled into an app spec
that defines the app’s configuration.
From looking at my main.tf
at my projects root, you can see the do_static_site
module takes in a few inputs, which are defined in variables.tf
.
# modules/do_static_site/variables.tf
variable "do_token" {
description = "DigitalOcean Access Token"
}
variable "site_name" {
default = "elijahlove-xyz"
description = "The name of the website"
}
variable "base_domain" {
default = "elijahlove.xyz"
description = "Base domain of the website"
}
variable "site_repo" {
default = "https://gitlab.com/elijahdl/elijahlove-hugo"
description = "git repo of site source"
}
variable "branch" {
default = "master"
description = "Branch in repo to use"
}
variable "build_command" {
default = "hugo -d public"
description = "Command to build website"
}
variable "hugo_version" {
default = "0.92.2"
}
This is where I tell Terraform what inputs the module depends on in order to function. Each variable
block can have a default value and a description to make using the module a little easier. There are some other options that allow you to define things like the variable’s type or some validation rules.
Variables can be accessed using the var.variable_name
syntax.
You can use the following methods to feed Terraform input variables:
- Using the
-var
option to input variables as arguments toterraform
- Storing variables as a
.tfstate
file and specifying it with-var-file
(terraform.tfvars
automatically gets loaded if it’s present) - Setting environment variables in the format
TF_VAR_variable_name
- Using the
default
variable defined in the relevant block
Most of my environment variables are in my terraform.tfvars
file. It’s considered good practice to add this to .gitignore
to avoid committing sensitive information, but I’ve included mine below:
# terraform.tfvars
site_name = "elijahlove-xyz"
base_domain = "elijahlove.xyz"
site_repo = "elijahdl/elijahlove-hugo"
prod_branch = "master"
develop_branch = "develop"
build_command = "hugo -d public"
hugo_version = "0.102.3"
This post isn’t meant to be a comprehensive tutorial on Terraform so go into more detail, but the official Terraform documentation details the nuances of defining and using variables.
An Issue with DNS
As I write this, the domain
argument appears to be in a transition state. I’m currently using the now-deprecated syntax, which is what terraform show
returns after an import. I’ve been unable to get the new syntax described in the documentation to work; this is something I’ll want to revisit at a later time.
When I use this syntax to give the app a domain name, it will expect you to create a CNAME record for that domain name on your own instead of automatically adding records to my existing domain I have set up in DO’s DNS service.
To work around this, I added a digitalocean_record
resource to my do_static_site
module, which worked splendidly for develop.elijahlove.xyz, but I ended up finding out that domain roots cannot be CNAMEs. Some vendors offer nifty custom record types that can get around this limitation, but at the time of writing DO is not one of those vendors.
It also appears that DO doesn’t provide external IPs for Apps. They do create a couple of A and AAAA records when you specify a domain root in the Apps configuration and have DigitalOcean manage your DNS entries for you, but I have no way of reliably getting these IPs at the time of deployment, nor can I guarantee that these IPs won’t change during the lifecycle of the App.
Due to these limitations, I need to manually go into the web portal after initially deploying the app and re-add the domain so that DO will create the proper DNS records for me; it’s interesting that Terraform didn’t detect a change in state after doing this. Fortunately I don’t see myself destroying and recreating the websites very often, but I may revisit this problem in the future.
I also had to use terraform state rm
on the digitalocean_domain
resource I created during my testing so that terraform wouldn’t delete all of my other DNS records.
Smaller Hurdles
- Using the
git
argument, DigitalOcean’s build pack was unable to download theresume-focus-theme
submodule; apparently the App platform has some issues working with submodules. I ended up using thegitlab
argument which leverages the GitLab API and appears to better handle submodules. Usinggitlab
requires that I tie my GitLab account to DigitalOcean and locks me into using GitLab. At the very least, this allows DigitalOcean to set up a hook that triggers a redeploy whenever I push updates to one of the monitored branches. - Before I specified the
HUGO_VERSION
environment variable, the build environment was unable to download a working Hugo release; it’s probably best to set it to the version I’m using in my development environment anyway.
As long as I’m using DigitalOcean’s App platform for a small project like this, Terraform is an overkill solution. That said, I’ve grown familiar with these wonderful tools and will be able to utilize them as I move on to working with more complicated infrastructure.
Putting a Picture to It (With a Favicon)
I needed a favicon, but I didn’t want to put too much effort into making one, so I put too much effort into making a script to make one instead. The script makeFavicon.sh
, located in the root directory of my project, grabs the color values that I defined as variables from style.css
, uses ImageMagick to create a circle with a pretty gradient, then drops the image in a few different formats and sizes into my site’s static
directory. It’s a little janky, but it’ll do.
# makeFavicon.sh
[...]
get-css-variable () {
grep -e ".*$1: .*;" $CSS_FILE | \
sed "s/.*$1: \(.*\);/\1/p" | \
head -n 1
}
COLOR1=`get-css-variable "--accent-1-color"`
COLOR2=`get-css-variable "--accent-2-color"`
echo "Creating favicons with colors $COLOR1 and $COLOR2"
SIZES=("16" "32" "64" "128")
convert -size 256x256 gradient:$COLOR2-$COLOR1 -distort SRT 45 \( -size 256x256 \
xc:Black \
-fill White \
-draw 'circle 100 100 100 1' \
-alpha Copy \
\) -compose CopyOpacity -composite \
-trim ${STATIC_FILES_DIR}/favicon-256.png
for size in "${SIZES[@]}"; do
convert ${STATIC_FILES_DIR}/favicon-256.png \
-resize ${size}x${size} \
${STATIC_FILES_DIR}/favicon-${size}.png
done
convert \
${STATIC_FILES_DIR}/favicon-16.png \
[...]
${STATIC_FILES_DIR}/favicon-256.png \
${STATIC_FILES_DIR}/favicon.ico
What’s Still on my To-Do List
-
For the short term, the DigitalOcean Apps platform will work just fine; static sites are pretty low-maintenance, and the only tools I really need in order to make content updates are Git and a text editor. I’m not sure if I’ll stick with DigitalOcean or not; I could see myself moving the site back to my email provider after I get some more sophisticated CI/CD in place, speaking of which…
-
I’m considering how I’m going to flesh out the CI/CD. I’d certainly like a little more control over the build environment so I can easily add and edit tooling (outside of creating a Dockerfile). Jenkins is probably my top choice at the moment, but I admittedly don’t have too much experience with other tools of its like.
-
I’d also like to run the compiled site through some static analysis tools. While hosting a static HTML site on a cloud platform isn’t particularly risky, it’ll be nice to have linters to make sure I’m not generating sub-par HTML. I can also run a spell checker or grammar analysis tool and comb through my content directory so I’m not effortlessly pushing garish grammar and spelling errors to my site. This would also be useful for making sure I didn’t miss any straggling TODOs.
-
I’d like to set up my build environment using declarative configuration; using containers is a popular way to do this, but I’m leaning towards using
nix-shell
, which is part of the Nix package manager. -
As for the website itself, I feel like it’s OK where it is right now. In the near future I’ll add some more blogging features like tags and related posts; the former is already halfway implemented, I just need to flesh out the HTML templates in
resume-focus-theme
.
Things I Learned (For the Upteenth Time)
This blisteringly simple static website was originally just a static website– only advertising my presence to potential employers. This time around, however, it serves as a practice target as I continue to hone skills that will be fundamental to my career. Working on this project, I’ve reminded myself of some things that I feel are pretty important when it comes to adopting new technologies and expanding one’s skill set.
- Learning things quickly and learning things the right way are two very different concepts
- A rock-solid automated infrastructure base makes future upkeep extremely easy (who knew?)
- Writing about the process as you’re doing it gives you more opportunities to mull over what you did and confirm that the way you did something was a reasonable way to approach the problem
All the source code for building and deploying this website can be found on GitLab.