Building a Mediawiki OCI Image for Nomad with Packer, Ansible, and Freeunit

Author's Note: The code used in this article can be found on Codeberg.

What's Freeunit?

Sometime in the hazy past, I discovered nginx-unit, "a lightweight and versatile open-source server that...runs application code in eight languages."

It used to be part of the Netbox stack, but they replaced it with Granian, probably because (as the link above shows), nginx-unit was abandoned by nginx's corporate sponsors in 2025.

Luckily for us, some generous community members created freeunit, a fork of the original nginx-unit which includes LTS support. They have a great slogan ("when corporations step back, community takes over") and awesome t-shirt designs. I'm sufficiently hyped; let's experience freeunit as an application server for Mediawiki, shall we?

Freeunit config for Mediawiki

Freeunit provides a sample Mediawiki config on their website. We'll use that same config (with slight modifications) in our OCI (Docker) image.

It's beyond the scope of this article, but Freeunit supports also dynamic, live configuration via its control socket.

Why Build OCI images with Packer and Ansible?

One of my previous jobs had a very large Hashicorp footprint. We (SREs) were already building VM images with Packer and Ansible, so it was natural to take our existing expertise and apply it to containers. It's also nice that Hashicorp publishes Mac binaries, so I can run Packer directly from my desktop.

Is it the best way to build containers in 2026? I'm not sure, but I'll share my workflow and perhaps you can decide that one for yourself ;) .

Ansible, Docker, and Packer: Getting the Pieces to Play Nice Together

There's lot of mentions of the Ansible/Docker/Packer workflow online, but I couldn't find any docs that specifically call out some of the non-default arguments you have to use to get all the pieces to work together. I'll try to remedy that here.

Customizing the run command

Below is an example from our container's Packer code, which uses the Packer Docker Builder. Note the run_command option. We have to tell Docker to run the container with a predictable name, so we can find it with Ansible later.

source "docker" "freeunit" {
  image  = var.base_image
  commit = true
  pull   = true
  # We customize `run_command` so the container has a hard-coded name.
  # Then we use the hard-coded name in our ansible inventory.
  run_command = ["--name=${var.build_ctr_name}", "-d", "-i", "-t", "--entrypoint=/bin/sh", "--", "{{.Image}}"]
  platform    = "linux/amd64"
  changes = [
    "LABEL org.opencontainers.image.version=\"${local.image_version}\"",

  ]
}

Note also the "changes" argument above; this allows you to use Dockerfile syntax a la docker commit --change. You can find more details in Hashicorp's Docker Builder docs.

Installing Python into the container

Ansible requires Python to be installed into the container, so we use Packer's shell provisioner to install it in the container using system packages:

provisioner "shell" {
  inline = ["apt-get update && apt-get -y install ca-certificates python3"]
}

Running the Ansible Playbook from Packer

Once we have Python, we can use the Packer Ansible Provisioner.

Below you can see a couple of ways (--extra-vars and ansible_env_vars) to make Packer variables available to Ansible, including secrets (the Gerrit private key, which is plucked from Hashicorp Vault-more about that in a future article).

provisioner "ansible" {
  playbook_file = "./playbook.yml"
  extra_arguments = [
    "-c=community.docker.docker",
    "-i=,${var.build_ctr_name}",
    "--extra-vars=mediawiki_version=${var.mediawiki_version}",
  ]
  ansible_env_vars = ["GERRIT_PRIVKEY=${local.gerrit_privkey}"]
}

The -c (connection) argument is important too, as it instructs Ansible to connect over the Docker socket instead of using SSH. The inventory argument (-i) uses a comma, which instructs ansible to use the value supplied instead of an inventory file. The inventory value resolves to the name of the Docker container we supplied in our run_command above, "freeunit".

Building the image

Within the repo directory, run packer validate . and packer build -on-error=ask . Assuming everything goes well, the build will complete and Packer will commit the newly-built image to your container registry:

==> big_unit.docker.freeunit (docker-push): 04301c4aa7dc: Layer already exists
==> big_unit.docker.freeunit (docker-push): 219a998c6050: Layer already exists
==> big_unit.docker.freeunit (docker-push): latest: digest: sha256:9d55590bb127485026c29cdbaba0daa4206c81d93715c9a834a172787c99abf8 size: 3251
==> big_unit.docker.freeunit (docker-push): Logging out...
==> big_unit.docker.freeunit (docker-push): Removing login credentials for gitea.service.rl:443
==> big_unit.docker.freeunit (docker-push): Removing temporary Docker configuration directory
Build 'big_unit.docker.freeunit' finished after 7 minutes 40 seconds.

==> Wait completed after 7 minutes 40 seconds

==> Builds finished. The artifacts of successful builds are:
--> big_unit.docker.freeunit: Imported Docker image: sha256:25836e9831e78e5bfa6b40aa3e284c60c8aacc260a1c54d9f5b6e51df65e22ca
--> big_unit.docker.freeunit: Imported Docker image: gitea.service.rl:443/artifact/mediawiki:latest with tags gitea.service.rl:443/artifact/mediawiki:1.46.0-riichi.25 gitea.service.rl:443/artifact/mediawiki:1.46.0 gitea.service.rl:443/artifact/mediawiki:latest
--> big_unit.docker.freeunit: Imported Docker image: gitea.service.rl:443/artifact/mediawiki:latest with tags gitea.service.rl:443/artifact/mediawiki:1.46.0-riichi.25 gitea.service.rl:443/artifact/mediawiki:1.46.0 gitea.service.rl:443/artifact/mediawiki:latest

Last Words

While this approach has a number of advantages, it's not efficient with image sizes. This image is approximately 3x the size of its base:

docker image ls
REPOSITORY                                TAG                   IMAGE ID       CREATED          SIZE
gitea.service.rl:443/artifact/mediawiki   1.46.0                0dff8e8ddeaa   24 minutes ago   1.84GB
ghcr.io/freeunitorg/freeunit              latest-php8.5         bf7fe4ffc530   4 months ago     631MB

Still, if you can tolerate some bloat in your images, the combination of Ansible, Docker, and Packer is a pretty big improvement over Dockerfile, at least in my opinion. Give it a try, won't you?