Terraform is usually associated with cloud APIs, but the community kreuzwerker/docker provider lets it drive a Docker Engine directly. Instead of a sequence of docker run commands, you describe images, networks, volumes and containers in HCL files, review the changes with terraform plan, and let Terraform bring the host to that state. In this tutorial you will install Terraform on Ubuntu 24.04 and deploy a small stack (an Nginx web container and a PostgreSQL database on a private network with a persistent volume) entirely from Terraform code.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, such as a CubePath VPS, with a non-root user that has
sudoprivileges. - Docker Engine installed from Docker's official repository, with your user in the
dockergroup so it can use/var/run/docker.sockwithoutsudo. Rundocker run --rm hello-worldto confirm it works. - Basic familiarity with Docker concepts (images, containers, volumes).
Step 1 - Installing Terraform
HashiCorp publishes an official APT repository. Install the tools needed to add it:
sudo apt update
sudo apt install -y gnupg curl lsb-release
Download the HashiCorp signing key into a dedicated keyring:
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, restricted to that key:
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp.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 -y terraform
Check the installed version:
terraform version
Terraform v1.13.3
on linux_amd64
Your version number will likely be newer. Anything from 1.5 onward works with this tutorial.
Step 2 - Configuring the Docker provider
Create a directory for the project. Each Terraform project lives in its own directory and all .tf files in it are loaded together:
mkdir -p ~/docker-stack
cd ~/docker-stack
Create versions.tf, which pins the provider and tells it how to reach the Docker daemon:
nano versions.tf
terraform {
required_version = ">= 1.5"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {
host = "unix:///var/run/docker.sock"
}
The ~> 3.0 constraint accepts any 3.x release but not a future 4.0 with breaking changes. The host value points at the local Docker socket. To manage a remote host instead, you can use an SSH URL such as ssh://your_user@your_server_ip, which reuses your SSH keys and avoids exposing the Docker API over TCP.
Initialize the project so Terraform downloads the provider:
terraform init
Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 3.0"...
- Installing kreuzwerker/docker v3.9.0...
- Installed kreuzwerker/docker v3.9.0 (self-signed, key ID BD080C4571C6104C)
...
Terraform has been successfully initialized!
Terraform also creates .terraform.lock.hcl, which records the exact provider version. Commit it to version control so everyone uses the same build.
Step 3 - Declaring variables and outputs
The database password should not be hardcoded in the configuration. Declare it as a sensitive variable, together with the host port for the web container:
nano variables.tf
variable "db_password" {
description = "Password for the PostgreSQL appuser account"
type = string
sensitive = true
}
variable "web_port" {
description = "Host port that publishes the web container"
type = number
default = 8080
}
Marking the variable sensitive hides it in plan and apply output. It is still stored in plain text in the state file, which is why terraform.tfstate must never be committed to Git.
Now create outputs.tf so Terraform prints useful values after each apply:
nano outputs.tf
output "web_url" {
value = "http://127.0.0.1:${var.web_port}"
}
output "db_container" {
value = docker_container.db.name
}
Step 4 - Defining the network, volume and images
Create main.tf with the shared resources first. A user-defined network gives the containers DNS resolution by container name, and a named volume keeps the database files when the container is replaced:
nano main.tf
resource "docker_network" "app" {
name = "app-network"
}
resource "docker_volume" "db_data" {
name = "app-db-data"
}
resource "docker_image" "postgres" {
name = "postgres:17"
keep_locally = true
}
resource "docker_image" "nginx" {
name = "nginx:1.28"
keep_locally = true
}
The docker_image resources pull the images. Pin specific tags instead of latest so that a plan never changes silently because upstream pushed a new image. With keep_locally = true, terraform destroy leaves the images in the local cache.
Step 5 - Defining the containers
Append the two containers to main.tf:
resource "docker_container" "db" {
name = "app-db"
image = docker_image.postgres.image_id
restart = "unless-stopped"
env = [
"POSTGRES_USER=appuser",
"POSTGRES_PASSWORD=${var.db_password}",
"POSTGRES_DB=appdb",
]
networks_advanced {
name = docker_network.app.name
}
volumes {
volume_name = docker_volume.db_data.name
container_path = "/var/lib/postgresql/data"
}
healthcheck {
test = ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval = "10s"
timeout = "5s"
retries = 5
}
}
resource "docker_container" "web" {
name = "app-web"
image = docker_image.nginx.image_id
restart = "unless-stopped"
ports {
internal = 80
external = var.web_port
ip = "127.0.0.1"
}
networks_advanced {
name = docker_network.app.name
}
upload {
content = "<h1>Deployed with Terraform</h1>\n"
file = "/usr/share/nginx/html/index.html"
}
}
A few details worth noting:
image = docker_image.postgres.image_idcreates an implicit dependency, so Terraform pulls the image before creating the container. Referencingdocker_network.app.nameanddocker_volume.db_data.nameworks the same way, so you do not needdepends_on.- The database publishes no ports. Only containers on
app-networkcan reach it, at the hostnameapp-db. - The web container is bound to
127.0.0.1. Docker publishes ports by inserting its own iptables rules, which bypass UFW, so binding to localhost keeps the port private until you put a reverse proxy in front of it. - The
uploadblock writes a file into the container at creation time, which is enough for a quick test page.
Check the syntax and formatting before going further:
terraform fmt
terraform validate
Success! The configuration is valid.
Step 6 - Planning and applying the stack
Pass the password through an environment variable. Terraform reads any variable named TF_VAR_<name>. The leading space keeps the command out of your shell history in the default Ubuntu Bash configuration:
export TF_VAR_db_password='your_strong_password'
Replace your_strong_password with a long random value. Then preview the changes:
terraform plan
...
Plan: 6 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ db_container = "app-db"
+ web_url = "http://127.0.0.1:8080"
Read the plan: it lists every resource Terraform will create. When it matches what you expect, apply it:
terraform apply
Type yes at the prompt. Terraform pulls the images and creates the network, volume and containers:
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Outputs:
db_container = "app-db"
web_url = "http://127.0.0.1:8080"
Step 7 - Verifying the deployment
Confirm that the containers are running with Docker's own tools:
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
NAMES IMAGE STATUS
app-web nginx:1.28 Up 20 seconds
app-db postgres:17 Up 21 seconds (healthy)
Request the test page:
curl http://127.0.0.1:8080
<h1>Deployed with Terraform</h1>
Check that the database accepts connections:
docker exec app-db pg_isready -U appuser -d appdb
/var/run/postgresql:5432 - accepting connections
Step 8 - Changing and detecting drift
Terraform compares the state file with the real containers on every plan. To see this, remove the web container behind Terraform's back:
docker rm -f app-web
Run a plan again:
terraform plan
# docker_container.web will be created
+ resource "docker_container" "web" {
...
Plan: 1 to add, 0 to change, 0 to destroy.
Terraform noticed that the container is missing and proposes to recreate it. Run terraform apply to restore it. Configuration changes work the same way: edit a value such as the nginx:1.28 tag, run terraform plan to see which resources will be replaced, and apply. Most container arguments cannot be changed in place, so expect the container to be destroyed and recreated. Data in the app-db-data volume survives because the volume is a separate resource.
Step 9 - Destroying the stack
When you no longer need the environment, remove everything Terraform created:
terraform destroy
Destroy complete! Resources: 6 destroyed.
The pulled images stay in Docker's cache because of keep_locally = true. Note that destroying also deletes the app-db-data volume and the data in it. If the database matters, back it up first or add a lifecycle { prevent_destroy = true } block to the volume resource.
Troubleshooting
permission denied while trying to connect to the Docker daemon socket: your user is not in thedockergroup, or the group change has not taken effect. Runsudo usermod -aG docker $USER, then log out and back in.Bind for 127.0.0.1:8080 failed: port is already allocated: another process uses the port. Pick a free one withterraform apply -var web_port=8081.- The database container restarts in a loop: read its log with
docker logs app-db. A volume that was initialized by a different PostgreSQL major version will refuse to start; use a new volume name or migrate the data. - State lost or out of sync: if someone deleted
terraform.tfstate, Terraform no longer knows about the containers. Bring existing ones back under management withterraform importor animportblock instead of creating duplicates.
Conclusion
You installed Terraform from HashiCorp's repository, configured the Docker provider and deployed a web container and a PostgreSQL database with a private network and a persistent volume, all from version-controlled code. Terraform's plan now shows every change, including manual changes made outside of it.
As next steps, store the state in a remote backend so a team can share it, split the stack into a reusable module with input variables, and add automated tests for that module with Terratest.
