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:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
local = {
source = "hashicorp/local"
}
}
}
sourceis where the provider comes from, asowner/nameon 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.localdidn'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:
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_imagemakes sure thenginx:1.28image is on your computer, pulling it if it isn't, exactly likedocker pullin the Docker track.docker_containerruns a container calledterraform-web, with port 80 inside the container published on port 8080 on your computer, likedocker run -p 8080:80.image = docker_image.nginx.image_idis the important line, explained in the next section.
You've added a new provider, so run init again, then plan:
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:
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:
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:
volumes {
host_path = abspath("site")
container_path = "/usr/share/nginx/html"
read_only = true
}
depends_on = [local_file.page]
volumesshares thesitefolder with the container, at the place nginx looks for web pages, like the bind mounts in Docker Lesson 5.read_onlymeans the container can read your page but not change it.abspath("site")is a Terraform function. It turns the short namesiteinto 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, butabspath("site")is just text: nothing in it refers tolocal_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:
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:
"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:
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:
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.
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:
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:
docker run -d --name squatter -p 8080:80 nginx:1.28
Now run terraform apply and type yes. Among the 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:
docker rm -f squatter
terraform apply
Stop Docker. Quit Docker Desktop, and run terraform plan:
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
Why does the Docker provider need an entry in required_providers?
The container has image = docker_image.nginx.image_id. What does that reference do?
When is depends_on the right tool?
Why is the volume written as host_path = abspath("site")?
What does terraform graph show?
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, likekreuzwerker/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
- The Docker provider (opens in a new tab) documentation on the Terraform Registry.
- The depends_on meta-argument (opens in a new tab) in the Terraform documentation.
- Built-in functions (opens in a new tab), including
abspathanddirname.