Terraform basics · Lesson 4 of 9 · about 25 minutes

Providers and resources

How to add a provider that isn't from HashiCorp, run a real web server in Docker from Terraform, and control the order things are built in, with references, depends_on, and terraform graph to see it all.

By the end you'll know

  • How to add a provider with required_providers, and why some need it
  • How to run a container in Docker from Terraform
  • How a reference between resources decides the order they're built in
  • When you need depends_on, and when you don't
  • How to see the order for yourself with terraform graph

Where you are

At the end of Lesson 3, you knew how Terraform keeps track of what it made. Everything so far has been one file, made by one provider. Real infrastructure is never that simple: a server needs a network, a website needs a server, a DNS record needs an address to point at. Things depend on other things, and they have to be built in the right order.

This lesson runs a real web server, nginx, in Docker, managed entirely by Terraform. It's still all on your own computer, so it's still free. You'll need Docker Desktop installed and running; if you haven't used it before, Lessons 1 and 2 of the Docker basics track set it up.

Adding a provider

So far you've used two providers without thinking about it: local, for files, and the one built into Terraform for terraform_data. Both come from HashiCorp, and Terraform assumes HashiCorp when you don't say otherwise. The Docker provider is different: it's written and looked after by a company called kreuzwerker, so you have to tell Terraform where to find it.

Think of ordering a part for a car. For the manufacturer's own parts, the shop knows where to look. For anything else, you need the maker's name, or they'll look in the wrong catalogue. In Terraform, that's a required_providers block.

Step 1. In your terraform-practice folder, create a new file called versions.tf:

HCL
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    local = {
      source = "hashicorp/local"
    }
  }
}
  • source is where the provider comes from, as owner/name on the Terraform Registry.
  • version = "~> 4.6" means "4.6 or anything newer in the 4.x series, but not 5". It stops a future version with breaking changes from arriving by surprise. Lesson 9 explains version rules properly.
  • local didn't need listing, because it's from HashiCorp, but listing every provider you use is a good habit: anyone reading the file can see them all in one place.

Notice this is a second .tf file. Terraform reads every .tf file in the folder as one configuration, so you can split things up however reads best. versions.tf for the terraform block is a common convention.

Running a web server

Step 2. Create another file called web.tf:

HCL
provider "docker" {}

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

resource "docker_container" "web" {
  name  = "terraform-web"
  image = docker_image.nginx.image_id

  ports {
    internal = 80
    external = 8080
  }
}
  • provider "docker" {} sets the provider up. The empty curly brackets mean "use the defaults", which connect to Docker Desktop on your computer.
  • docker_image makes sure the nginx:1.28 image is on your computer, pulling it if it isn't, exactly like docker pull in the Docker track.
  • docker_container runs a container called terraform-web, with port 80 inside the container published on port 8080 on your computer, like docker run -p 8080:80.
  • image = docker_image.nginx.image_id is the important line, explained in the next section.

You've added a new provider, so run init again, then plan:

BASH
terraform init
terraform plan

init downloads the Docker provider. The plan ends with Plan: 2 to add, 0 to change, 0 to destroy.: the image and the container. Your hello.txt is unchanged. Run terraform apply and type yes. Then open http://localhost:8080 in your browser: you'll see nginx's "Welcome to nginx!" page. Check it with Docker too:

BASH
docker ps

You'll see terraform-web in the list, running nginx:1.28. Same container you could have made with docker run, but now it's described in a file, and Terraform can rebuild it, change it or remove it.

References decide the order

A recipe that says "pour the sauce from step 2 over the pasta" tells you the order without saying so: you can't do this step until step 2 is done. Terraform reads your configuration the same way.

image = docker_image.nginx.image_id is a reference: the container uses a value from the image, its ID. Terraform can't know that ID until the image exists, so it creates the image first, then the container. You didn't write "do the image first"; the reference said it for you. That's called an implicit dependency, and it's how almost every dependency in Terraform works.

References look like this everywhere: the resource type, its name, and the value you want, separated by dots, like docker_image.nginx.image_id or local_file.hello.filename. And they work in reverse for destroy: the container goes first, then the image it depends on.

When there's nothing to refer to: depends_on

You can't paint a wall until the plaster's dry, but nothing on the paint tin mentions plaster. Some dependencies are like that: one thing really does need another first, but nothing in its code refers to it, so Terraform can't see the connection.

Give the web server a page of your own to see one. Step 3. Add this resource to web.tf:

HCL
resource "local_file" "page" {
  filename = "site/index.html"
  content  = "<h1>Served by Terraform</h1>"
}

It writes your page into a folder called site, creating the folder if it needs to. Now make the container show that folder instead of nginx's own pages. Add this volumes block inside docker_container "web", below ports, along with the depends_on line:

HCL
  volumes {
    host_path      = abspath("site")
    container_path = "/usr/share/nginx/html"
    read_only      = true
  }

  depends_on = [local_file.page]
  • volumes shares the site folder with the container, at the place nginx looks for web pages, like the bind mounts in Docker Lesson 5. read_only means the container can read your page but not change it.
  • abspath("site") is a Terraform function. It turns the short name site into the folder's full path, like /Users/you/terraform-practice/site, because Docker needs the full path.
  • depends_on = [local_file.page] is the plaster and the paint. The container needs the page to exist before it starts, but abspath("site") is just text: nothing in it refers to local_file.page. So you tell Terraform directly.

Run terraform plan. A container's volumes can't be changed while it exists, so you should see docker_container.web must be replaced, as in Lesson 2, with the page being added too: Plan: 2 to add, 0 to change, 1 to destroy. Run terraform apply, type yes, and refresh http://localhost:8080: it now says Served by Terraform.

When not to use depends_on. Where a reference already exists, depends_on adds nothing, and it can make plans more cautious than they need to be. Here, you could also have written host_path = abspath(dirname(local_file.page.filename)), which builds the path from the file resource itself. That's a reference, so Terraform would see the link with no depends_on at all. Both work; the rule of thumb is to prefer a reference when one is natural, and reach for depends_on only when there's genuinely nothing to refer to.

Seeing it: terraform graph

You can ask Terraform to draw the dependencies it sees:

BASH
terraform graph

It prints the graph in a text format called DOT, which drawing programs understand. Among its lines, you'll find arrows like these:

OUTPUT
  "docker_container.web" -> "docker_image.nginx";
  "docker_container.web" -> "local_file.page";

Each arrow means "depends on". The first is the reference; the second is your depends_on. To turn it into a picture, install Graphviz, a free drawing tool, and draw it as an image file:

BASH
brew install graphviz
terraform graph | dot -Tsvg > graph.svg
open graph.svg

The | sends Terraform's output straight into dot, Graphviz's drawing command, and > saves the picture to graph.svg, which open shows in your browser or image viewer. (Without Homebrew, you can paste the output of terraform graph into any free online DOT viewer instead.) It's a learning aid more than a daily tool, but on a big, unfamiliar project, it's the quickest way to see what depends on what.

Checkpoint: your configuration so far

Your folder has three .tf files: main.tf, unchanged from Lesson 3, versions.tf from step 1, and web.tf, which should now look like this:

HCL
provider "docker" {}

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

resource "local_file" "page" {
  filename = "site/index.html"
  content  = "<h1>Served by Terraform</h1>"
}

resource "docker_container" "web" {
  name  = "terraform-web"
  image = docker_image.nginx.image_id

  ports {
    internal = 80
    external = 8080
  }

  volumes {
    host_path      = abspath("site")
    container_path = "/usr/share/nginx/html"
    read_only      = true
  }

  depends_on = [local_file.page]
}

(It doesn't matter where in the file local_file.page goes; Terraform works out the order from the dependencies, not from the order you write things in.) Run terraform plan to check: No changes.

How Terraform knows the orderdocker_container.web depends on two things. On docker_image.nginx, through a reference: image equals docker_image.nginx.image_id, which Terraform sees by itself. On local_file.page, through depends_on, which you have to state, because the volume's path is plain text. So Terraform creates the image and the page first, then the container, and destroys them in the reverse order.docker_container.webbuilt lastreferenceTerraform sees itdocker_image.nginxbuilt firstdepends_onyou have to say itlocal_file.pagebuilt firstArrows point at what's needed first. destroy runs them the other way round.Most dependencies come from references. depends_on is for the ones Terraform can't see.
The container needs both the image and the page. Terraform finds the first link by itself, from the reference; you supply the second with depends_on.

In my real setup

CivicGrid's Terraform uses four providers: DigitalOcean for the cluster, Cloudflare for DNS, and Kubernetes and Helm for what goes inside the cluster. Most of the order comes from references: the DNS record and the Kubernetes provider both use values from the cluster, so the cluster is always built first. But a couple of things genuinely need depends_on. The app's Kubernetes namespaces need the cluster to exist, and the link runs through the Kubernetes provider's settings, not through anything in the namespace itself, so Terraform can't see it. Writing this lesson, I also spotted one depends_on that a reference already covers: harmless, but a good example of why it's worth checking.

Break it on purpose

Try each one, then put it back.

Forget where the provider comes from. In versions.tf, delete the whole docker = { ... } entry, leaving just local, and run terraform init. Among the error lines, you'll find these:

OUTPUT
Error: Failed to query available provider packages

Could not retrieve the list of available versions for provider
hashicorp/docker: provider registry registry.terraform.io does not have a
provider named registry.terraform.io/hashicorp/docker

Without being told, Terraform assumed HashiCorp, looked for hashicorp/docker, and found nothing, because the Docker provider is kreuzwerker's. The message usually goes on to suggest the right one: "Did you intend to use kreuzwerker/docker?" Put the entry back, and run terraform init again.

Take the port. Free the port, let something else grab it, then ask Terraform for it back. Run terraform destroy and type yes, then start a container of your own on port 8080, with plain Docker:

BASH
docker run -d --name squatter -p 8080:80 nginx:1.28

Now run terraform apply and type yes. Among the output:

OUTPUT
Bind for 0.0.0.0:8080 failed: port is already allocated

Terraform asked Docker for port 8080, and Docker refused, because squatter already had it. Only one thing can use a port at a time, the same lesson as Python Lesson 10. Remove the squatter, and apply again:

BASH
docker rm -f squatter
terraform apply

Stop Docker. Quit Docker Desktop, and run terraform plan:

OUTPUT
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?

The Docker provider talks to Docker Desktop, so with Docker stopped, it has nobody to talk to. Every provider is like this: it's a translator between Terraform and a real service, and if the service can't be reached, neither can the provider. (If you see this error while Docker Desktop is running, open its Settings, go to Advanced, and turn on "Allow the default Docker socket to be used".) Start Docker Desktop again, and terraform plan works.

When you've finished, run terraform destroy and type yes: it removes the container, the image and the page, and frees port 8080. Lesson 5 builds them again, with settings you can change without editing the code.

Where you are now

You've added a provider that isn't HashiCorp's, run a real web server from Terraform, and controlled the order things are built in: references for the links Terraform can see, depends_on for the ones it can't. But the port, 8080, and the image version, 1.28, are written straight into the code. Lesson 5 turns them into variables, so the same configuration can be used with different settings.

Check yourself

  1. Why does the Docker provider need an entry in required_providers?

  2. The container has image = docker_image.nginx.image_id. What does that reference do?

  3. When is depends_on the right tool?

  4. Why is the volume written as host_path = abspath("site")?

  5. What does terraform graph show?

  6. terraform apply fails with port is already allocated. Why?

Key terms

  • Provider: a plugin that translates between Terraform and one service, like Docker or a cloud.
  • required_providers: the block that says where each provider comes from, and which versions are allowed.
  • source: a provider's address on the Terraform Registry, as owner/name, like kreuzwerker/docker.
  • Reference: using a value from another resource, like docker_image.nginx.image_id.
  • Implicit dependency: the order Terraform works out by itself from references.
  • depends_on: tells Terraform about a dependency it can't see from references.
  • Function: a built-in helper, like abspath(), that works out a value for you.
  • terraform graph: prints the dependency graph in DOT format, for drawing with Graphviz.

Go deeper