Hugo's There - Flying with Hugo and Caddy

There I was wondering what to do about a website for a new community venture I was running where I thought, yes, let’s generate the site with Hugo, serve it with Caddy and run it all on Fly. Why Hugo and Caddy? Well, they both have good reputations as Go-based tooling thats compact and powerful, so let’s go make and host a site…

Fly First

I’ll need to be able to reference what the app name, and consequently site name, will be. Because of that, I’m going to start by initializing my Fly deployment:

$ flyctl init makeronicc --dockerfile -p 80

Selected App Name: makeronicc

? Select organization: Dj (dj)

New app created
  Name     = makeronicc
  Owner    = dj
  Version  = 0
  Status   =
  Hostname = <empty>

Wrote config file fly.toml


Select your own app name or let the system generate one for you; it won’t matter as we’re going to front this set-up to a custom domain. We’re going to be using a Dockerfile (hence --dockerfile) and our server will operate on port 80 (-p 80).

The Hugo Configuration

The site doesn’t have much content and a stroll through the Hugo Quickstart will get us a front page and a blog article built in no time at all. It boils down to installing hugo, then installing a hugo theme and finally creating a config.toml file. Here’s ours:

baseURL = ""
languageCode = "en-us"
title = "Makeroni"
theme = "ananke"
site_logo = "/images/makeroni-logo-small.webp"

That’s enough for our basic site to build running hugo which generates all the static files. Running hugo server -D lets us browse it locally on localhost:1313. The one thing to note? We’ll be serving this site up on the domain for now.

Docker And Hugo

Next we want to get Docker to do the build work. Time to make a Dockerfile.

There are various Hugo docker images out there but the one I like is klakegg/hugo on Docker Hub. As well as having Docker images that can be used for running Hugo as a Docker container, there’s an ONBUILD image. This is designed to be a stage in a multi-stage Docker build. It’s run as part of the pipeline, but then results can be copied from its image to a new, cleaner image, less all the build tools.

So our Dockerfile starts like this:

FROM klakegg/hugo:0.74.0-onbuild AS hugo

That, when built with Docker, will load up the image and the current working directory contents, run hugo over it and deposit the results in /target in the hugo image. Hugo build, done. Now, let’s talk about serving it up.

Caddy Hack

Now we come to Caddy, which is a great web server “built for now” - It has integrated handling of obtaining and managing Let’s Encrypt certificates so running an HTTPS site becomes super-simple. There’s only one issue - Fly already does all that certificate management for us, so although we want Caddy because it’s compact and easy to work with, we’re going to want to turn off Caddy’s own certificate system.

Caddy is configured in a number of ways, JSON, API or the Caddyfile. I use the Caddyfile for this as its more human-readable. But now a public service announcement:

When you search for Caddy and, well, anything at all, when you get to a result, scroll to the top of the page to make sure you aren’t on the Caddy 1 documentation. Caddy 2 is the current version but the google-juice for Caddy 1 documentation is still super high and the two are so similar yet different, it can be terribly frustrating to keep landing on the wrong docs.

Right, back to creating our Caddyfile. Most of what I just talked about can be summed up in one opening block.

    auto_https off

That turns off all the certificate management. Now we can tell Caddy to serve files for our domain. {
    root * /usr/share/caddy

Notice that this is just for the http protocol connections. That’s because, once the TLS connection has passed through the Fly edge, it travels on the encrypted Fly network as a normal HTTP request.

That’s the Caddyfile created. Now to pull the two parts together in the Dockerfile.

Adding Caddy

At the moment, our Dockerfile simply brings in and runs the Hugo static generator at build time. We need to take the results of that and put it into a Caddy docker image. There are official Caddy images, so I’ll use one of them:

FROM klakegg/hugo:0.74.0-onbuild AS hugo

FROM caddy:2.1.1

Docker will now start with this Caddy image. Our Caddyfile says it will serve files out of /usr/share/caddy so we’ll want to copy the files from our Hugo build over to there by adding:

COPY --from=hugo /target/ /usr/share/caddy/

The --from points to the named image we created at the start with AS hugo. Now all we need is to put the Caddyfile in place.

COPY ./Caddyfile /etc/caddy/Caddyfile

Ready To Fly

We’re ready to publish the site. Run flyctl deploy and watch as the Hugo site is built, copied into a Caddy image, that image is then flattened and despatched to a Fly firecracker node. You don’t have to worry about that though, just run flyctl open and your browser will open on your application.

One thing worth noticing is that, although we only configured, it’s being automatically upgraded to https: to secure the connection.

But we aren’t done yet. Remember we wanted the site to be provisioned on

Fly Domain

The first step is to get the DNS system to point to the IP address of I can get the IP Address by running flyctl ips list.

$ flyctl ips list

TYPE ADDRESS                              CREATED AT
v4                         2020-07-07T21:26:36Z
v6   2a09:8280:1:9f04:7aa6:a706:a3d7:ccba 2020-07-07T21:26:36Z

We want the V4 address. Now, I need to go to the registrar of the DNS entry, in my case NameCheap, and get to the DNS management pages, specifically the Advanced Management page of that.

It’s there I can add an A record. A @ (The @ goes in the host column on Namecheap).

Save that and let it propagate and then go to and you should see your site. If you follow any links though, you’ll notice you are back on That’s because Hugo generated all the links with that address.

That’s easy enough to fix (we’ll get back to it in a moment), but there’s another more important thing to look at.

If I try to go to and I get an error saying the connection is not secure. I haven’t created a TLS certificate for the domain.

The quickest way to do this is to add an AAAA record in the same way I added an A record. The AAAA record in a DNS record should point to the IP V6 address for the host; it’s up in the flyctl ips list output too. So I add AAAA @ 2a09:8280:1:9f04:7aa6:a706:a3d7:ccba to the DNS. With that in place and propagated I can now go and request a certificate.

$ flyctl certs create
Hostname =
Configured = true
Issued =
Certificate Authority = lets_encrypt
DNS Provider = enom
DNS Validation Instructions = CNAME =>
DNS Validation Hostname =
DNS Validation Target =
Source = fly
Created At = just now
Status = Ready

And the traffic could flow securely…. Except there’s one last change we need to make to the Caddyfile.

Caddy Changes

Remember I set up the Caddyfile with {
    root * /usr/share/caddy

Well, I’m not serving the files on that URL now so I’ll need to change that to match our custom domain: {
    root * /usr/share/caddy

Now it’ll respond to requests for files from the domain… but what if I want to make sure that people who accidentally access the old site end up in the right place. For that, another rule in the Caddyfile is needed: {

Now those old requests will head to our new server. Redeploy the image to Fly and everything is ready to roll.

Wrapping Up

We’ve gone through configuring a multistage image build which generated a static Hugo site, then loaded it into an image with Caddy. We’ve configured Caddy for development deployments on Fly and then we’ve got ourselves a custom domain set up, and made that work with TLS certificates from Let’s Encrypt and a small modification to Caddy’s setup.