schneiderbox


Docker Compose and Terraform

November 22, 2021

Since I set up Kelvandor with Docker Compose, I was curious how a similar configuration with Terraform would compare.

Both Docker Compose and Terraform are infrastructure as code (IaC) tools. With them, you define an environment (servers, networking, storage, etc.) in a structured configuration, and the tool automatically creates and manages it for you.

A useful way to think of this is to compare IaC tools to normal written documentation. To set up Kelvandor, I could write docs telling you that you need to compile the kelvandor binary, install Flask, run make httpapi, and serve the html/ directory with a suitable web server. Or, using IaC tools, I could write a Dockerfile and a docker-compose.yaml file that describes all that, and all you need to do is run docker-compose up. Docker Compose will then automatically execute all the steps to set up a Kelvandor environment.

It functions as documentation as well, because if you’re curious about the process, you can always check the docker-compose.yaml file to see for yourself (although I should still provide real documentation as well).

(Although, strictly speaking, both Docker Compose and Terraform use declarative configuration—instead of a list of steps, like my documentation example, the configuration describes what the final state of the environment should look like, and Compose and Terraform figuress out how to get there.)

According to its docs, Compose has historically focused on development and testing use cases over production deployments. On the other hand, Terraform is clearly intended for production use, and has a large number of systems it can interact with (these are termed providers).

In reality, Terraform is likely overkill for this use case. My goal was to make it easier for an interested user to set up Kelvandor’s stack and play a game. Docker is an integral part of that goal—it provides the environment to build the executable and run the API. If we’re asking the user to set up Docker, it makes sense to also have them use Compose. They may even have Compose already installed and be familiar with it.

Terraform would be an additional dependency, and its (substantial) capabilities aren’t immediately relevant to the goal. We’re helping the user set up a personal instance on their own machine, not deploy a production cluster.

But that doesn’t mean we can’t try it because we want to!

For context, here is the docker-compose.yaml:

services:
  api:
    build: .
    ports:
      - "5000:5000"

  ui:
    image: nginx
    volumes:
      - ./html:/usr/share/nginx/html:ro
    ports:
      - "8000:80"
    depends_on:
      - api

I explain it in Kelvandor on Docker. It is pretty a bare-bones file, but it works for its purpose.

And here is a corresponding simple Terraform configuration that attempts to replicate the docker-compose.yaml:

terraform {
  required_providers {
    docker = {
      source = "kreuzwerker/docker"
    }
  }
}

provider "docker" {}

resource "docker_image" "api" {
  name = "api"
  build {
    path = "."
  }
}

resource "docker_container" "api" {
  name  = "api"
  image = docker_image.api.latest
  ports {
    internal = 5000
    external = 5000
  }
}

resource "docker_image" "nginx" {
  name = "nginx"
}

resource "docker_container" "nginx" {
  name  = "nginx"
  image = docker_image.nginx.latest
  ports {
    internal = 80
    external = 8000
  }
  volumes {
    host_path      = "${abspath(path.root)}/html"
    container_path = "/usr/share/nginx/html"
    read_only      = true
  }
}

The first thing you’ll likely notice is that the Terraform configuration a bit longer. Part of that is just that is uses a more verbose syntax, but another part is that it has more to define and configure. Terraform has greater capabilities, but that also means more complexity—although I would argue the Terraform file is only marginally more complex than the Compose file, and mostly in the first two blocks.

For the first block, we define a terraform block. This is where the behavior of Terraform itself is configured. In it we specify the providers we’ll be using. In this case, we only are using one, the kreuzwerker/docker provider, which lets us control Docker with Terraform. As mentioned above, Terraform has many other providers available, such as for interfacing with cloud services, hypervisors, and even Spotify.

The second block is a blank provider configuration for the Docker provider. The kreuzwerker/docker provider docs tells us what configuration options we could specify here. For our purposes the default options work, so we leave the contents of the block blank.

The remaining four blocks define docker_image and docker_container resources. Resources define particular objects in the infrastructure. The syntax takes the form of resource "resource_type" "resource_name". Here the resources are Docker images and containers. Just like in docker-compose.yaml, we have two images—one for the API, one for the UI’s web server—and two corresponding containers that instantiate the images.

This configuration is saved in main.tf in the Kelvandor project root. However, Terraform evaluates all .tf files in a directory, so the name is just convention.

To use it (assuming you have installed Terraform), cd to the Kelvandor root directory and run terraform init. This initializes the working directory for Terraform’s use.

Next, run terraform apply. This will read the configuration in main.tf and describe what it intends to do. Since this is a fresh setup, it needs to create all four resources. If the changes are amicable to you, type yes and Terraform will create the Docker images and containers.

If Terraform had previously created resources, it would instead change the existing resources to match the configuration. For example, if after, after the initial terraform apply, you change the nginx external_port configuration to 8001 and run terraform apply, Terraform will make the necessary changes without redoing all the work—in this case, it will delete and re-create the nginx container with the new port mapping.

Once terraform apply finishes, you can browser to localhost:8000 to access the Kelvandor UI. Changing a player drop-down to Kelvandor will set the UI to query the API running at localhost:5000 for moves.

To bring down the environment, run terraform destroy. It will once again describe what it intends to do and ask for confirmation.

Again, this is a pretty trivial use case for Terraform. Using Terraform only to configure Docker means we’re not going much beyond what Docker Compose can do. However, Terraform can be used to configure Docker, and VMware, and AWS, and whatever else your environment might have. And by examining such a simple configuration and comparing it to a docker-compose.yaml, we can see the differences between each tool’s intentions and scope, and learn about both.