A Terraform module is a directory of .tf files with a defined set of inputs and outputs, which you can call from other configurations instead of copying resource blocks around. In this tutorial you will build a small module that runs an Nginx container through the Docker provider, add input validation and outputs, call it several times with for_each, test it with terraform test, and publish it with a Git version tag. Everything runs on a single Ubuntu 24.04 server, so you can follow along without a cloud account.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
  • A non-root user with sudo privileges.
  • Docker Engine installed from Docker's official repository, with your user in the docker group (log out and back in after adding it).
  • Git installed (sudo apt install git).
  • Basic familiarity with terraform init, plan and apply.

Confirm that Docker works without sudo:

docker run --rm hello-world

The output should include Hello from Docker!.

Step 1 - Installing Terraform

If Terraform is already installed, check that it is version 1.6 or newer (needed for terraform test) and skip to Step 2.

Add HashiCorp's signing key and APT repository:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(. /etc/os-release && echo "$VERSION_CODENAME") main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

Install Terraform and check the version:

sudo apt update
sudo apt install terraform
terraform -version
Terraform v1.13.3
on linux_amd64

Any 1.6 or later release works for this tutorial.

Step 2 - Creating the project layout

A module is just a directory, but the convention of splitting it into main.tf, variables.tf, outputs.tf and versions.tf makes every module easy to read at a glance. The configuration that calls modules is called the root module.

Create a project with one child module under modules/:

mkdir -p ~/tf-modules-demo/modules/web_container/tests
cd ~/tf-modules-demo
touch main.tf versions.tf outputs.tf
touch modules/web_container/{main.tf,variables.tf,outputs.tf,versions.tf,README.md}

The resulting layout is:

tf-modules-demo/
  main.tf                  root module: calls the child module
  versions.tf              root: provider requirements and configuration
  outputs.tf               root: values printed after apply
  modules/web_container/
    main.tf                resources
    variables.tf           inputs
    outputs.tf             outputs
    versions.tf            provider requirements for the module
    README.md              usage documentation
    tests/                 terraform test files

Step 3 - Declaring the module inputs

Inputs are the module's public interface. Give every variable an explicit type and a description, give optional ones a default, and use validation blocks to reject bad values at plan time instead of failing halfway through an apply.

Open the variables file:

nano modules/web_container/variables.tf

Add the following inputs:

variable "name" {
  type        = string
  description = "Short name of the site. Used in the container name web-<name>."
  nullable    = false

  validation {
    condition     = can(regex("^[a-z0-9-]+$", var.name))
    error_message = "name may only contain lowercase letters, digits and hyphens."
  }
}

variable "external_port" {
  type        = number
  description = "Host port that forwards to port 80 in the container."

  validation {
    condition     = var.external_port >= 1024 && var.external_port <= 65535 && floor(var.external_port) == var.external_port
    error_message = "external_port must be an integer between 1024 and 65535."
  }
}

variable "image" {
  type        = string
  description = "Container image to run."
  default     = "nginx:stable-alpine"
}

variable "env" {
  type        = map(string)
  description = "Environment variables passed to the container."
  default     = {}
}

name and external_port have no default, so callers must set them. image and env are optional.

Step 4 - Writing the module resources and provider requirements

The module needs to tell Terraform which provider it uses. This matters here because the Docker provider is published as kreuzwerker/docker; without a required_providers entry, Terraform would look for a non-existent hashicorp/docker.

Open the module's versions.tf:

nano modules/web_container/versions.tf

Declare a minimum version rather than an exact pin, so that callers can choose the exact version in their own lock file:

terraform {
  required_version = ">= 1.6"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = ">= 3.0"
    }
  }
}

Note that the module has no provider "docker" block. Provider configuration belongs in the root module; a module that configures its own provider cannot be used with for_each or count.

Now open the module's main.tf:

nano modules/web_container/main.tf

Add the image and the container:

resource "docker_image" "this" {
  name         = var.image
  keep_locally = true
}

resource "docker_container" "this" {
  name    = "web-${var.name}"
  image   = docker_image.this.image_id
  restart = "unless-stopped"
  env     = [for key, value in var.env : "${key}=${value}"]

  ports {
    internal = 80
    external = var.external_port
  }
}

Naming the main resource of each type this is a common convention in modules that create one of each: it keeps addresses short, such as module.site["blog"].docker_container.this. keep_locally = true stops Terraform from deleting the image on destroy, which would fail while another container still uses it.

Step 5 - Exposing outputs

Outputs are the only values a caller can read from the module. Expose what other code actually needs, not every attribute.

nano modules/web_container/outputs.tf
output "container_name" {
  description = "Name of the Docker container."
  value       = docker_container.this.name
}

output "container_id" {
  description = "ID of the Docker container."
  value       = docker_container.this.id
}

output "url" {
  description = "Local URL where the site answers."
  value       = "http://localhost:${var.external_port}"
}

Check the module on its own. terraform init downloads the provider into the module directory, and validate checks syntax and references:

cd ~/tf-modules-demo/modules/web_container
terraform init
terraform fmt
terraform validate
Success! The configuration is valid.

Step 6 - Calling the module with for_each

Now use the module from the root configuration to run two sites. Go back to the project root and open versions.tf:

cd ~/tf-modules-demo
nano versions.tf

The root module pins the provider to the 3.x series and configures it. With no arguments, the Docker provider connects to the local socket /var/run/docker.sock:

terraform {
  required_version = ">= 1.6"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

provider "docker" {}

Open main.tf:

nano main.tf

Call the module once per entry in a map. each.key becomes the site name and each.value the port:

locals {
  sites = {
    blog = 8081
    shop = 8082
  }
}

module "site" {
  source   = "./modules/web_container"
  for_each = local.sites

  name          = each.key
  external_port = each.value
  env = {
    SITE_NAME = each.key
  }
}

Then expose the URLs of all instances from the root:

nano outputs.tf
output "site_urls" {
  description = "URL of every site, keyed by site name."
  value       = { for key, site in module.site : key => site.url }
}

Initialize the root, review the plan and apply it:

terraform init
terraform plan
terraform apply

Type yes when prompted. Terraform creates one image and one container per site:

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

site_urls = {
  "blog" = "http://localhost:8081"
  "shop" = "http://localhost:8082"
}

Verify that both containers are running and answering:

docker ps --format '{{.Names}}\t{{.Ports}}'
curl -sI http://localhost:8081 | head -n 1
web-shop	0.0.0.0:8082->80/tcp
web-blog	0.0.0.0:8081->80/tcp
HTTP/1.1 200 OK

Adding a third site is now a one-line change to local.sites, and terraform plan will show only the two new resources.

Seeing validation in action

Change shop = 8082 to shop = 80 in main.tf and run terraform plan. Terraform stops before touching anything, with an error similar to this:

Error: Invalid value for variable

  on main.tf line 13, in module "site":
  13:   external_port = each.value

external_port must be an integer between 1024 and 65535.

Set the port back to 8082 before continuing.

Step 7 - Testing the module with terraform test

terraform test runs .tftest.hcl files against a module. With command = plan, tests only compute a plan, so they are fast and create nothing.

Create a test file inside the module:

nano ~/tf-modules-demo/modules/web_container/tests/web_container.tftest.hcl
variables {
  name          = "test"
  external_port = 8090
}

run "container_name_has_prefix" {
  command = plan

  assert {
    condition     = docker_container.this.name == "web-test"
    error_message = "The container name must be web-<name>."
  }
}

run "rejects_privileged_port" {
  command = plan

  variables {
    external_port = 80
  }

  expect_failures = [
    var.external_port,
  ]
}

The first run checks the naming logic, and the second confirms that the validation rule really rejects port 80. Run the tests from the module directory:

cd ~/tf-modules-demo/modules/web_container
terraform test
tests/web_container.tftest.hcl... in progress
  run "container_name_has_prefix"... pass
  run "rejects_privileged_port"... pass
tests/web_container.tftest.hcl... tearing down
tests/web_container.tftest.hcl... pass

Success! 2 passed, 0 failed.

The tests use the provider that terraform init installed in the module directory in Step 5.

Step 8 - Versioning and sharing the module

Local paths such as ./modules/web_container are fine inside one repository. To reuse the module across projects, put it in its own Git repository and reference tagged releases, so that a change to the module never reaches a project until that project asks for the new version.

Create a repository from the module directory, ignoring the files Terraform generates:

cd ~/tf-modules-demo/modules/web_container
printf '.terraform/\n.terraform.lock.hcl\n' > .gitignore
git init
git add .
git commit -m "Initial web_container module"
git tag v1.0.0

The lock file is ignored in a module repository because the root module owns provider versions. In a root configuration you do the opposite and commit .terraform.lock.hcl.

After you push the repository to your Git server, callers reference a tag with the ref query parameter:

module "site" {
  source   = "git::https://github.com/your_org/terraform-docker-web-container.git?ref=v1.0.0"
  for_each = local.sites

  name          = each.key
  external_port = each.value
}

Replace your_org with your GitHub organization or user. If the module lives in a subdirectory of a larger repository, add the path after a double slash: git::https://github.com/your_org/infra-modules.git//web_container?ref=v1.0.0. Run terraform init -upgrade whenever you change a module's source or ref.

Follow semantic versioning for tags:

ChangeExampleNew version
Bug fix, no interface changefix a default valuev1.0.1
New optional input or new outputadd a labels variable with a defaultv1.1.0
Removed or renamed input, changed behaviorrename external_portv2.0.0

Never move or delete a tag that has been published; release a new one instead.

To publish on the public Terraform Registry, the GitHub repository must be public, be named terraform-<PROVIDER>-<NAME> (here terraform-docker-web-container) and have semantic version tags. Registry modules are then called with a short source and a version constraint, which only works for registry sources:

module "site" {
  source  = "your_org/web-container/docker"
  version = "~> 1.0"

  name          = "blog"
  external_port = 8081
}

~> 1.0 accepts any 1.x release from 1.0 upward but never 2.0.

Step 9 - Refactoring safely with moved blocks

Renaming a resource or turning a single module call into a for_each changes resource addresses. Without extra information, Terraform would destroy the old objects and create new ones. A moved block records the rename so that Terraform updates the state instead.

For example, if an older version of your root configuration called the module as module "blog" and you have switched to module "site" with for_each, add this to the root main.tf:

moved {
  from = module.blog
  to   = module.site["blog"]
}

terraform plan should then report the move and no destroy or create for that site:

  # module.blog.docker_container.this has moved to module.site["blog"].docker_container.this

Inside a module, the same block lets you rename a resource (for example from docker_container.web to docker_container.this) without forcing every caller to recreate it. Keep moved blocks in the module for at least one major version so that all callers pick them up.

Module design guidelines

These rules are the ones that save the most trouble in practice:

  • No provider blocks inside modules. Declare required_providers with a minimum version, and configure providers in the root.
  • Small, focused modules. A module should represent one concept (a site, a network, a database). A module that wraps a single resource with the same arguments adds nothing.
  • Typed, validated inputs. Use object() and map() types instead of loosely typed any, and validate values that would otherwise fail late.
  • Minimal outputs. Every output is part of the interface you have to keep stable.
  • Document usage. Put an example module block and a table of inputs and outputs in README.md. The terraform-docs tool can generate the tables from the code.
  • Pin in the root, not in the module. Commit .terraform.lock.hcl in root configurations, and pin module versions with ref or version.

Cleaning up

Remove the demo containers when you are done:

cd ~/tf-modules-demo
terraform destroy

Type yes to confirm. The Nginx image stays on disk because of keep_locally = true; remove it with docker image rm nginx:stable-alpine if you no longer need it.

Troubleshooting

Failed to query available provider packages ... hashicorp/docker: a module uses the Docker provider without declaring source = "kreuzwerker/docker" in its required_providers. Add the versions.tf block from Step 4 to every module that uses the provider, then run terraform init again.

Module not installed: you added or changed a module block, or changed its source. Run terraform init (or terraform init -upgrade for a new ref or version).

Bind for 0.0.0.0:8081 failed: port is already allocated: another process or container already uses that port. Pick a free port in local.sites or stop the other service.

Conclusion

You built a Terraform module with a typed and validated interface, called it several times with for_each, tested it with terraform test, and learned how to version it with Git tags and refactor it with moved blocks. The same structure scales from a Docker container to full server, network and DNS modules.

As next steps, store the root module's state remotely so a team can share it, add a CI job that runs terraform fmt -check, terraform validate and terraform test on every change to your modules, and replace the Docker resources with your cloud provider's resources while keeping the same interface.