Terraform is an infrastructure as code tool: you describe resources such as servers, networks, DNS records or containers in configuration files, and Terraform works out what to create, change or delete to make reality match. In this tutorial you will install Terraform on Ubuntu 24.04 from HashiCorp's official repository and learn the core workflow (init, plan, apply, destroy) by managing an Nginx container through the Docker provider. Everything runs on one server, so you can learn the concepts without a cloud account or any cost.

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 added to the docker group so it can run docker without sudo. Log out and back in after adding the group.

Confirm that Docker works for your user before continuing:

docker run --rm hello-world

The output should include Hello from Docker!.

Step 1 - Installing Terraform from the HashiCorp repository

HashiCorp publishes Terraform in its own APT repository, which is the supported way to install it on Ubuntu and keeps it updated with apt upgrade.

Install the tools needed to add the repository:

sudo apt update
sudo apt install gnupg curl lsb-release

Download HashiCorp's signing key into /etc/apt/keyrings:

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

Add the repository, restricted to that key. The release codename (noble on Ubuntu 24.04) is filled in automatically:

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

Install Terraform:

sudo apt update
sudo apt install terraform

Verify the installation:

terraform -version
Terraform v1.13.3
on linux_amd64

Your version will be newer or older depending on when you install it. Optionally, enable tab completion for Bash, then open a new shell for it to take effect:

terraform -install-autocomplete

Step 2 - Understanding how Terraform works

Before writing configuration, it helps to know the four building blocks you will use:

ConceptWhat it is
ProviderA plugin that talks to one API (AWS, Cloudflare, Docker, Kubernetes...). Downloaded by terraform init.
ResourceOne object managed by a provider, such as a container or a DNS record.
StateA file (terraform.tfstate) where Terraform records which real objects it manages and their attributes.
PlanThe list of actions Terraform calculates by comparing your configuration with the state and the real objects.

Configuration is written in HCL (HashiCorp Configuration Language) in files ending in .tf. Terraform reads every .tf file in the current directory, so how you split them is up to you. A common convention is main.tf for resources, variables.tf for inputs and outputs.tf for values to print.

Step 3 - Writing your first configuration

Create a project directory:

mkdir ~/terraform-docker
cd ~/terraform-docker

Create main.tf:

nano main.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

provider "docker" {}

resource "docker_image" "nginx" {
  name         = "nginx:stable"
  keep_locally = false
}

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

  ports {
    internal = 80
    external = 8000
  }
}

Here is what each block does:

  • The terraform block declares which providers the project needs. kreuzwerker/docker is the community Docker provider from the Terraform Registry, and ~> 3.0 accepts any 3.x release but never 4.0, which could contain breaking changes.
  • provider "docker" {} configures the provider. With no arguments, it connects to the local Docker socket.
  • docker_image.nginx pulls the nginx:stable image. keep_locally = false removes the image when you destroy the resource.
  • docker_container.nginx runs a container from that image and publishes container port 80 on host port 8000.

docker_image.nginx.image_id is a reference to an attribute of another resource. Terraform uses these references to build a dependency graph, so it knows it must pull the image before creating the container, without you specifying an order.

Step 4 - Initializing the project

terraform init downloads the providers listed in the configuration and prepares the working directory. You must run it once in every new project and again whenever you add a provider.

terraform init
Initializing the backend...
Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 3.0"...
- Installing kreuzwerker/docker v3.6.2...
- Installed kreuzwerker/docker v3.6.2 (self-signed, key ID BD080C4571C6104C)
...
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above.
...
Terraform has been successfully initialized!

init creates two things:

  • .terraform/, which holds the downloaded provider binaries. Do not commit it.
  • .terraform.lock.hcl, which pins the exact provider versions and checksums. Commit it, so everyone on the team gets the same versions.

Format and validate the configuration:

terraform fmt
terraform validate
Success! The configuration is valid.

fmt rewrites files in the canonical style and prints the names of any files it changed. validate checks syntax and references without contacting any API.

Step 5 - Previewing and applying changes

terraform plan shows what Terraform would do, without doing it. Always read the plan before applying.

terraform plan
Terraform will perform the following actions:

  # docker_container.nginx will be created
  + resource "docker_container" "nginx" {
      + name  = "tf-nginx"
      ...
    }

  # docker_image.nginx will be created
  + resource "docker_image" "nginx" {
      + name         = "nginx:stable"
      ...
    }

Plan: 2 to add, 0 to change, 0 to destroy.

The symbols describe each action: + create, ~ update in place, - destroy, and -/+ destroy and recreate.

Apply the configuration. Terraform shows the plan again and asks for confirmation:

terraform apply

Type yes at the prompt:

docker_image.nginx: Creating...
docker_image.nginx: Creation complete after 8s [id=sha256:...nginx:stable]
docker_container.nginx: Creating...
docker_container.nginx: Creation complete after 1s [id=4f1c...]

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

Verify that the container is running and serving requests:

docker ps --filter name=tf-nginx
curl -I http://localhost:8000
CONTAINER ID   IMAGE          COMMAND                  CREATED          STATUS          PORTS                  NAMES
4f1c2a9b7e10   3b25b682ea82   "/docker-entrypoint.…"   30 seconds ago   Up 29 seconds   0.0.0.0:8000->80/tcp   tf-nginx
HTTP/1.1 200 OK
Server: nginx/1.28.0
...

Run terraform plan again. Because reality now matches the configuration, Terraform reports No changes. Your infrastructure matches the configuration.

Step 6 - Adding variables and outputs

Hardcoded values make a configuration hard to reuse. Input variables let you change them without editing resources, and outputs print useful values after each apply.

Create variables.tf:

nano variables.tf
variable "container_name" {
  description = "Name of the Nginx container"
  type        = string
  default     = "tf-nginx"
}

variable "external_port" {
  description = "Host port that exposes Nginx"
  type        = number
  default     = 8000

  validation {
    condition     = var.external_port > 1024 && var.external_port < 65536
    error_message = "The external port must be between 1025 and 65535."
  }
}

Create outputs.tf:

nano outputs.tf
output "container_id" {
  description = "ID of the Nginx container"
  value       = docker_container.nginx.id
}

output "url" {
  description = "URL where Nginx is reachable"
  value       = "http://localhost:${var.external_port}"
}

Now update the container resource in main.tf to use the variables:

nano main.tf
resource "docker_container" "nginx" {
  name  = var.container_name
  image = docker_image.nginx.image_id

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

Variables are referenced as var.<name>, and ${...} inserts an expression inside a string.

Apply with a different port passed on the command line:

terraform apply -var="external_port=8080"

Changing the published port cannot be done on a running container, so the plan shows a replacement:

  # docker_container.nginx must be replaced
-/+ resource "docker_container" "nginx" {
      ...
      ~ ports { # forces replacement
          ~ external = 8000 -> 8080 # forces replacement
            ...
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

Type yes. When the apply finishes, Terraform prints the outputs:

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

Outputs:

container_id = "9d2e..."
url = "http://localhost:8080"

Instead of passing -var every time, you can put values in a terraform.tfvars file, which Terraform loads automatically:

nano terraform.tfvars
external_port = 8080

Read outputs at any time with:

terraform output url
"http://localhost:8080"

Step 7 - Inspecting the state

Terraform's knowledge of your infrastructure lives in terraform.tfstate in the project directory. Never edit it by hand; use the state commands instead.

List the resources Terraform manages:

terraform state list
docker_container.nginx
docker_image.nginx

Show all attributes of one resource:

terraform state show docker_container.nginx

The state can contain sensitive values such as passwords or keys returned by providers. For team projects, store it in a remote backend (for example an S3-compatible bucket) with locking, instead of on one person's disk, and keep it out of Git.

Create a .gitignore for the project now, so you do not commit the wrong files later:

nano .gitignore
.terraform/
*.tfstate
*.tfstate.*
crash.log
*.tfvars

Keep .terraform.lock.hcl and all .tf files under version control. Leave *.tfvars out if they contain secrets.

Step 8 - Destroying the infrastructure

terraform destroy removes every resource in the state. It shows a plan with - entries and asks for confirmation:

terraform destroy

Type yes:

docker_container.nginx: Destroying... [id=9d2e...]
docker_container.nginx: Destruction complete after 1s
docker_image.nginx: Destroying... [id=sha256:...nginx:stable]
docker_image.nginx: Destruction complete after 0s

Destroy complete! Resources: 2 destroyed.

Terraform removed the container before the image, the reverse of the creation order. Confirm that nothing is left:

docker ps -a --filter name=tf-nginx
terraform state list

Both commands return no resources.

Troubleshooting

permission denied while trying to connect to the Docker daemon socket: your user is not in the docker group, or you have not logged in again since adding it. Run groups to check, then log out and back in.

Error: Inconsistent dependency lock file or provider ... is not available: you added or changed a provider after the last init. Run terraform init -upgrade.

Bind for 0.0.0.0:8000 failed: port is already allocated: another container or process uses the port. Pick a different external_port or stop the other process (sudo ss -tlnp | grep 8000 shows which one).

Error acquiring the state lock: another Terraform command is running in the same directory, or a previous run crashed. Wait for the other run to finish; only if you are sure no run is active, remove the lock with terraform force-unlock LOCK_ID, using the ID from the error message.

For more detail on any error, run the command with TF_LOG=DEBUG, for example TF_LOG=DEBUG terraform plan.

Conclusion

You installed Terraform from HashiCorp's repository, wrote a configuration with a provider and two dependent resources, and went through the full lifecycle: init, plan, apply, changes through variables, state inspection and destroy. The same workflow applies to every provider, whether it manages containers, DNS records or cloud servers.

As next steps, try a provider for a service you already use (for example Cloudflare DNS records), move your state to a remote backend with locking, and group related resources into reusable modules.