Once you move past a single main.tf, Terraform projects need three things to stay manageable: reusable modules, state that lives somewhere safer than your laptop, and a clean way to run the same code for several environments. In this tutorial you will install Terraform on Ubuntu 24.04, build a small module that provisions a group of web containers, store its state in PostgreSQL with locking, and deploy separate dev, staging and prod environments with workspaces.

The examples use the Docker provider so that everything runs on a single server at no extra cost. The techniques (modules, for_each/count, dynamic blocks, remote state, workspaces and moved blocks) are the same ones you use with any cloud provider.

Prerequisites

To follow this tutorial 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 Terraform can reach /var/run/docker.sock without sudo.
  • Basic familiarity with Terraform concepts: providers, resources, plan and apply.

Confirm that your user can talk to Docker:

docker info --format '{{.ServerVersion}}'

If this prints a version number, you are ready. If you get a permission error, run sudo usermod -aG docker $USER, then log out and back in.

Step 1 - Installing Terraform from the HashiCorp repository

HashiCorp publishes an official APT repository, which keeps Terraform up to date with the rest of your packages. Install the tools needed to add it:

sudo apt update
sudo apt install -y gnupg curl

Download the HashiCorp 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.gpg

Add the repository for your Ubuntu release (noble on 24.04):

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

Install Terraform:

sudo apt update
sudo apt install -y terraform

Verify the installation:

terraform -version
Terraform v1.13.3
on linux_amd64

Your version number will likely be newer. Anything from 1.5 onward supports every feature used in this guide.

Step 2 - Preparing a PostgreSQL backend for remote state

By default Terraform writes terraform.tfstate next to your code. That file maps your configuration to real resources, may contain secrets, and gets corrupted if two people run apply at the same time. A remote backend with locking solves all three problems.

Terraform ships with a pg backend that stores state in PostgreSQL and uses advisory locks to prevent concurrent runs. It also supports workspaces, which you will use in Step 6. Install PostgreSQL:

sudo apt install -y postgresql

Create a dedicated role. You will be prompted for a password; choose a strong one and keep it for the next command:

sudo -u postgres createuser --pwprompt terraform

Create a database owned by that role:

sudo -u postgres createdb -O terraform terraform_backend

Rather than writing credentials into your code, the pg backend reads the connection string from the PG_CONN_STR environment variable. Export it in your shell, replacing your_strong_password with the password you just set:

export PG_CONN_STR="postgres://terraform:your_strong_password@localhost/terraform_backend?sslmode=disable"

sslmode=disable is acceptable here because the database only listens on localhost. If your state database lives on another host, use TLS and sslmode=verify-full.

Check that the credentials work:

psql "$PG_CONN_STR" -c 'SELECT current_user;'
 current_user
--------------
 terraform
(1 row)

Step 3 - Creating the project layout

Create a project directory with a modules/ folder for reusable code and a root module that calls it:

mkdir -p ~/tf-webstack/modules/web_app
cd ~/tf-webstack

The finished project will look like this:

PathPurpose
versions.tfTerraform and provider versions, backend configuration
main.tfProvider, per-environment settings and module call
outputs.tfValues printed after apply
modules/web_app/variables.tfModule inputs with validation
modules/web_app/main.tfNetwork, image and containers
modules/web_app/outputs.tfModule outputs

Step 4 - Writing a reusable module

A module is just a directory of .tf files with inputs (variables) and outputs. Keep modules focused: this one provisions one web application as a network, an image and a configurable number of replica containers.

Start with the inputs. Validation blocks catch bad values at plan time instead of halfway through an apply:

nano modules/web_app/variables.tf
variable "name" {
  description = "Prefix for every resource created by this module"
  type        = string

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

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

variable "replicas" {
  description = "Number of containers to run"
  type        = number
  default     = 1

  validation {
    condition     = var.replicas >= 1 && var.replicas <= 5
    error_message = "replicas must be between 1 and 5."
  }
}

variable "base_port" {
  description = "Host port for the first replica; each extra replica uses the next port"
  type        = number
}

variable "labels" {
  description = "Extra Docker labels to add to every container"
  type        = map(string)
  default     = {}
}

Now the resources. Two techniques are worth noting here. count creates one container per replica, and a dynamic block generates one labels block for every entry in a map, so callers can add labels without you editing the module:

nano modules/web_app/main.tf
terraform {
  required_providers {
    docker = {
      source = "kreuzwerker/docker"
    }
  }
}

locals {
  labels = merge(var.labels, {
    "managed-by" = "terraform"
    "app"        = var.name
  })
}

resource "docker_network" "this" {
  name = "${var.name}-net"
}

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

resource "docker_container" "replica" {
  count   = var.replicas
  name    = "${var.name}-${count.index + 1}"
  image   = docker_image.this.image_id
  restart = "unless-stopped"

  networks_advanced {
    name = docker_network.this.name
  }

  ports {
    internal = 80
    external = var.base_port + count.index
  }

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }
}

The required_providers block inside the module is not optional: without it, Terraform assumes a provider called hashicorp/docker, which does not exist, and init fails.

Expose what callers need through outputs:

nano modules/web_app/outputs.tf
output "container_names" {
  description = "Names of the replica containers"
  value       = docker_container.replica[*].name
}

output "urls" {
  description = "Local URL of each replica"
  value       = [for i in range(var.replicas) : "http://localhost:${var.base_port + i}"]
}

Step 5 - Wiring the root module and the backend

The root module pins versions, configures the backend and calls the module. Create versions.tf:

nano versions.tf
terraform {
  required_version = ">= 1.5.0"

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

  backend "pg" {}
}

The empty backend "pg" {} block is intentional. Terraform takes the connection string from PG_CONN_STR, so no credentials end up in version control.

Next, main.tf. Instead of copying the module call into one directory per environment, you keep one configuration and pick settings from a map keyed by the current workspace name:

nano main.tf
provider "docker" {
  host = "unix:///var/run/docker.sock"
}

locals {
  environments = {
    dev = {
      replicas  = 1
      base_port = 8080
    }
    staging = {
      replicas  = 2
      base_port = 8180
    }
    prod = {
      replicas  = 3
      base_port = 8280
    }
  }

  env      = terraform.workspace
  settings = local.environments[local.env]
}

module "web" {
  source = "./modules/web_app"

  name      = "web-${local.env}"
  replicas  = local.settings.replicas
  base_port = local.settings.base_port

  labels = {
    environment = local.env
  }
}

Finally, forward the module outputs:

nano outputs.tf
output "environment" {
  value = local.env
}

output "urls" {
  value = module.web.urls
}

Initialize the project. This downloads the Docker provider, writes .terraform.lock.hcl and connects to PostgreSQL:

terraform init
Initializing the backend...

Successfully configured the backend "pg"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing modules...
- web in modules/web_app
Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 3.0"...
...
Terraform has been successfully initialized!

Format and validate the code before going further:

terraform fmt -recursive
terraform validate
Success! The configuration is valid.

Commit .terraform.lock.hcl to version control so everyone uses the same provider build. Do not commit the .terraform/ directory.

Step 6 - Deploying environments with workspaces

Each workspace has its own state, stored as a separate row in PostgreSQL. Create the three environments:

terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

workspace new also switches to the workspace it creates. Go back to dev and review the plan, saving it to a file so that apply executes exactly what you reviewed:

terraform workspace select dev
terraform plan -out=dev.tfplan
Plan: 3 to add, 0 to change, 0 to destroy.

Apply it:

terraform apply dev.tfplan
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

environment = "dev"
urls = [
  "http://localhost:8080",
]

Repeat for prod, which gets three replicas from the same code:

terraform workspace select prod
terraform plan -out=prod.tfplan
terraform apply prod.tfplan

Verify that the containers are running and carry the labels generated by the dynamic block:

docker ps --filter label=managed-by=terraform --format 'table {{.Names}}\t{{.Label "environment"}}'
NAMES        ENVIRONMENT
web-prod-3   prod
web-prod-2   prod
web-prod-1   prod
web-dev-1    dev

Request one of the replicas:

curl -sI http://localhost:8281 | head -n 1
HTTP/1.1 200 OK

Step 7 - Inspecting and verifying remote state

List the resources Terraform tracks in the current workspace:

terraform state list
module.web.docker_container.replica[0]
module.web.docker_container.replica[1]
module.web.docker_container.replica[2]
module.web.docker_image.this
module.web.docker_network.this

Confirm that no local state file was written and that each workspace lives in PostgreSQL. The pg backend creates a terraform_remote_state schema with a states table:

ls terraform.tfstate 2>/dev/null || echo "no local state file"
psql "$PG_CONN_STR" -c 'SELECT name FROM terraform_remote_state.states ORDER BY name;'
no local state file
  name
---------
 dev
 prod
 staging
(3 rows)

Depending on your Terraform version, a default row may also appear.

To see locking in action, open a second terminal, export PG_CONN_STR again, and run terraform plan in the same workspace while an apply is waiting for confirmation in the first one. The second command fails with Error acquiring the state lock instead of corrupting state.

Step 8 - Refactoring safely with moved blocks

Renaming a resource or module address normally makes Terraform plan to destroy the old object and create a new one. A moved block tells Terraform that the object only changed its address.

Suppose you want to rename the module from web to frontend. In main.tf, change module "web" to module "frontend", update outputs.tf to use module.frontend.urls, and add this block to main.tf:

moved {
  from = module.web
  to   = module.frontend
}

Plan in the prod workspace:

terraform plan
  # module.web.docker_container.replica[0] has moved to module.frontend.docker_container.replica[0]
...
Plan: 0 to add, 0 to change, 0 to destroy.

Zero changes means the rename is safe. Apply it in every workspace, and keep the moved block in the code until all environments have been updated.

Troubleshooting

  • Invalid index on local.environments[local.env]: you are in the default workspace, which has no entry in the map. Run terraform workspace select dev (or add a default entry).
  • Error acquiring the state lock when nobody else is running Terraform: a previous run was killed. Confirm no process is still running, then release the lock with terraform force-unlock LOCK_ID using the ID shown in the error.
  • permission denied while trying to connect to the Docker daemon socket: your user is not in the docker group, or you have not started a new login session since adding it.
  • Failed to query available provider packages for hashicorp/docker: a module is missing its required_providers block with source = "kreuzwerker/docker".
  • backend configuration changed after editing the backend block: run terraform init -reconfigure, or terraform init -migrate-state if you intend to move existing state.

Conclusion

You now have a Terraform project that keeps logic in a validated, reusable module, stores state in PostgreSQL with locking, and deploys three environments from a single configuration using workspaces. You also used moved blocks to refactor without recreating resources.

As next steps, move the module into its own Git repository and reference a tagged version (source = "git::https://example.com/modules.git//web_app?ref=v1.0.0"), run terraform plan automatically on every pull request in your CI pipeline, and point the same patterns at your cloud provider's Terraform provider.