Use A Makefile To Build Container Images

I recently replatformed the site from the Digital Ocean app engine to a Hetzner VPS. I wrote about why I migrated off of Digital Ocean here. I decided to use Caddy as the web server because it made getting up and running with TLS nice and easy. During the migration, my domain’s A and AAAA records were still pointed at the live Digital Ocean site so Caddy was not able to do the normal ACME style challenge with Let’s Encrypt when creating my TLS certificates. Instead, I had to do a DNS challenge so I would not disturb my A and AAAA records. The DNS challenge with Caddy requires that you build a custom container image with the needed functionality specific to your registrar.

I used a Makefile with some well-structured targets and “stamp files” to generate a tar of the Caddy container image, and to deploy and install that image to my remote server. I was able to ensure that if the underlying containerfile was changed, then a new container image would be created, exported to a tar file, uploaded to my server, and then installed on my remote server. Make is able to navigate the dependency graph of your targets, and only perform the minimally required actions needed to get to a goal. Had I written a script to do all of this, I’d be building and deploying a container image every time - or even worse - I could miss a step. Now, I just type ‘make’ in the terminal and I don’t have to think about what step requires what. Fantastic.

I’ll explain how I strucutred my Makefile to get this done.

A Quick Note About Let’s Encrypt Challenges

First it might be nice to have a little context on why I needed to make a custom image for Caddy, and what the heck these challenges even are.

To get a certificate required to enable TLS on a domain, you need to prove you have control of it. Let’s Encrypt offers a couple of ways to do this. The first is something called an ACME challenge, and the second is called a DNS challenge.

An ACME challenge is performed by hosting a special file at a specific location on your web server for the duration of the challenge. Your domain’s A and AAAA records will need to point at location of the web server hosting this file. Let’s Encrypt will attempt to fetch the file from the server your DNS is pointing to in order to verify that you’re in control of the domain. Because my site was still live during the migration and I could not change my DNS records to point at my new server without breaking things (temporarily, but still) I had to do an alternative challenge called a DNS challenge. A DNS challenge works by having your domain registrar create a temporary DNS TXT record attacked to your domain with a value that Let’s Encrypt is expecting to seee. A DNS challenge is a little more involved, requiring an API key with your domain registrar etc, but it is much less disruptive.

Creating The Container Image

As I mentioned earlier Caddy requires a custom container image to perform DNS challenges. They provide a base container image for people to use for this purpose. I am using Porkbun, so I had to create a containerfile with that dependency added:

Containerfile

FROM docker.io/library/caddy:2.11.4-builder-alpine as builder

RUN xcaddy build \
    --with github.com/caddy-dns/porkbun

FROM docker.io/library/caddy:2.11.4-alpine

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

This uses a mutli stage container build with Caddy’s special builder base image to construct our Caddy image with the porkbun dependency. In my Makefile, I can then define a target called caddy-2.11.4-porkbun.tar, and instruct Make what is required to build that target.

I’ll assume for brevity that we have a folder called build in the same directory as our Makefile and will skip a target for creating that.

Makefile

CADDY_IMAGE := build/caddy-2.11.4-porkbun

${CADDY_IMAGE}.tar: Containerfile
	podman build -t ${CADDY_IMAGE}:latest .
	podman save -o $@ ${CADDY_IMAGE}:latest

I define the rule build/caddy-2.11.4-porkbun.tar (interpolated) with the Containerfile as a dependency. With this single target, I can modify the Containerfile then run make and automatically get a new tar file with the newly constructed image. Make will check to see the modified stamps on both of these files and only act to recreate the tar when the Containerfile is newer than the resulting target tar file. That’s powerful.

Deploying The Up To Date Container Image To The Remote

Knowing that Make is looking at the modified times of files in order to determine what targets should be executed, how can we define a rule to deploy the tar file we just generated to a remote server? Well, we can leverage something called a “touch file” or a “stamp file” to act as a sort of stand-in file for that action. The body of our rule will send our file to the remote server, then create a file in the build/ directory that we will associate with that deploy action.

Makefile

CADDY_IMAGE := caddy-2.11.4-porkbun

build/${CADDY_IMAGE}.tar: Containerfile
	podman build -t ${CADDY_IMAGE}:latest .
	podman save -o $@ ${CADDY_IMAGE}:latest


CADDY_TAR_DEPLOY := build/caddy-tar-deploy

${CADDY_TAR_DEPLOY}: ${CADDY_IMAGE}.tar
    rsync -avzP $^ user@remote:
    touch $@

This rule will result in a file being created at build/caddy-tar-deploy after the rsync command completes. This target will only be run again when the ${CADDY_IMAGE}.tar file is newer than the file we created with the touch $@ command1. You’re probably starting to see where we are going to end up now.

Loading The New Image On The Remote

A final rule to install the image on the remote will be constructed in a similar way to the previous one to deploy it.

Makefile

CADDY_IMAGE := caddy-2.11.4-porkbun

build/${CADDY_IMAGE}.tar: Containerfile
	podman build -t ${CADDY_IMAGE}:latest .
	podman save -o $@ ${CADDY_IMAGE}:latest


CADDY_TAR_DEPLOY := build/caddy-tar-deploy

${CADDY_TAR_DEPLOY}: ${CADDY_IMAGE}.tar
    rsync -avzP $^ user@remote:
    touch $@

CADDY_TAR_INSTALL := build/caddy-tar-install

${CADDY_TAR_INSTALL}: ${CADDY_TAR_DEPLOY}
    ssh user@remote 'podman load -i ${CADDY_IMAGE}'
    touch $@

We execute podman’s load command through an ssh connection, then create another “stamp file” / “touch file” indicating that operation was completed. This install step will only be repeated if a new image tar is deployed by the previous step.

The Full Makefile

When creating a clean target for your makefile, you will want to make sure to remove all the items in the build directory, including the touch files.

# Build the container image and then save it to a tar file
CADDY_IMAGE := caddy-2.11.4-porkbun
build/${CADDY_IMAGE}.tar: Containerfile
	podman build -t ${CADDY_IMAGE}:latest .
	podman save -o $@ ${CADDY_IMAGE}:latest

# Copy the constructed image tar to the remote server
CADDY_TAR_DEPLOY := build/caddy-tar-deploy
${CADDY_TAR_DEPLOY}: ${CADDY_IMAGE}.tar
    rsync -avzP $^ user@remote:
    touch $@

# Load the deployed image tar file on the remote server
CADDY_TAR_INSTALL := build/caddy-tar-install
${CADDY_TAR_INSTALL}: ${CADDY_TAR_DEPLOY}
    ssh user@remote 'podman load -i ${CADDY_IMAGE}'
    touch $@

# Optionally, remove stuff on your remote server.
# I'll omit that here.
clean:
    rm -rf ./build

That’s it! You’re a Makefile wizard now. I use this kind of pattern very often because it lets me do the minimal required operations to reach a desired goal each time. You can imagine the above makefile being expanded to also include the construction of a tarball for the Caddy config files, a podman-compose file, and additonal files like a systemd unit file for the podman stack. If I wanted to install a systemd unit file for my podman-compose stack, I could write a rule that would depend on all my things being deployed where the body of the rule would bounce the systemd unit.

Careful construction of rules lets you create your own deployment dependency graph. I think that is really neat. There are probably better ways to do this sort of thing, but this works for me and the scale I am working at. No third party CI/CD required, just make.

Footnotes

  1. I used this $@ variable in the previous section as well. This is an automatic-variable in Make. There are several of these, but $@ refers to the name of the target. In this case, that is build/caddy-tar-deploy. Another automatic variable is $^. This variable refers to all the dependencies for a rule separated by spaces. Useful if you are passing all of these dependencies as arguments to something (which one usually is with a Makefile)

* * *