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
sudoprivileges. - Docker Engine installed from Docker's official repository, with your user added to the
dockergroup so it can rundockerwithoutsudo. 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
NoteSince version 1.6, Terraform is released under the Business Source License. If you need an open source license, OpenTofu is a community fork that accepts the same configuration language and commands (
tofuinstead ofterraform).
Step 2 - Understanding how Terraform works
Before writing configuration, it helps to know the four building blocks you will use:
| Concept | What it is |
|---|---|
| Provider | A plugin that talks to one API (AWS, Cloudflare, Docker, Kubernetes...). Downloaded by terraform init. |
| Resource | One object managed by a provider, such as a container or a DNS record. |
| State | A file (terraform.tfstate) where Terraform records which real objects it manages and their attributes. |
| Plan | The 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
terraformblock declares which providers the project needs.kreuzwerker/dockeris the community Docker provider from the Terraform Registry, and~> 3.0accepts 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.nginxpulls thenginx:stableimage.keep_locally = falseremoves the image when you destroy the resource.docker_container.nginxruns 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.
WarningPorts published by Docker bypass UFW rules, so port 8000 is reachable from the internet on a public server. Destroy the container at the end of this tutorial, or publish it only on localhost when you experiment on a public server.
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.
