Why OpenTofu instead of continuing with manual SSH
Up to five or six clients, manual management holds: you remember what runs where, and the setup checklist is memorized. Beyond that, memory becomes an operational risk. A manually provisioned VPS has no readable state: without connecting, you do not know whether the firewall is configured, whether Docker has the expected version, or whether this server is still part of the rotation.
OpenTofu solves a different problem than Ansible. Ansible manages the *configuration* of a server that already exists — what runs on it, which files are present, which services are started. OpenTofu manages the *lifecycle* of the server itself: creation, attribute updates, destruction. It maintains a state file (terraform.tfstate) that knows exactly what is provisioned and what is not. The two tools are complementary: OpenTofu provisions, Ansible configures.
In August 2023, HashiCorp changed the Terraform license from MPL-2.0 to BUSL-1.1, a non-free license that restricts certain commercial uses. The community responded by creating OpenTofu, an HCL-compatible fork for versions prior to Terraform 1.6, now hosted under the CNCF (Cloud Native Computing Foundation) and the Linux Foundation. The command is tofu, not terraform, but .tf files and the logic are identical.
What you gain with a declarative state
- Reliable inventory — the
terraform.tfstatefile tells you exactly which servers exist, with which IPs and attributes, without connecting to each one. - Reproducibility — a new client or project gets the same VPS configured the same way, from the same
.tffile versioned in Git. - Clean destruction —
tofu destroyremoves resources in the right order, without leaving orphaned servers that keep being billed. - Readable diff —
tofu planshows exactly what will be created, modified or destroyed before acting, like agit diffof your infrastructure. - Ansible complementarity — OpenTofu creates the VPS and sets metadata (SSH key, name, network); Ansible takes over to deploy Docker, Nginx and your applications.
- Versionable and auditable — your infrastructure becomes a Git repository with commits, code reviews and a history of changes.
- Sustainable open source license — OpenTofu is under MPL-2.0, without commercial restrictions, with community governance under the CNCF.
Prerequisites before you start
This guide assumes you have a VPS with root access and a dedicated IPv4 to run your workloads, and a development workstation (macOS, Linux or Windows with WSL2) from which you run tofu. OpenTofu itself does not run on the target VPS: it executes locally and talks to the provider API or the server's Docker daemon.
To follow the examples, you need: OpenTofu installed locally (see opentofu.org/docs/intro/install), API access or SSH credentials to the target VPS, and Git to version your .tf files. No additional dependencies are required — OpenTofu downloads providers itself at tofu init.
To control Docker on a remote VPS via OpenTofu, the server's Docker daemon must listen on its Unix socket (default) or on a secured TCP port. The kreuzwerker/docker provider connects to this socket via SSH or TCP.
Setting up OpenTofu for a VPS fleet
Install OpenTofu locally
Visit opentofu.org/docs/intro/install for instructions based on your OS. On macOS with Homebrew: brew install opentofu. On Debian/Ubuntu: the official OpenTofu repository provides the opentofu package. Verify the installation with tofu version — the command should return the installed version.
Structure your Infrastructure as Code project
Create a dedicated directory and four standard files.
# main.tf — main resources
# variables.tf — variable declarations
# outputs.tf — values exposed after apply
# terraform.tfvars — concrete values (gitignore if secrets)This structure is not required by OpenTofu, but it is the widely adopted convention: main.tf holds the resources, variables.tf declares types and default values, outputs.tf exposes what Ansible or another tool needs to read after provisioning (server IP, hostname…), and terraform.tfvars contains the concrete values you do not want to hard-code in main.tf.
Declare the provider and generate an SSH key
OpenTofu installs required providers at tofu init. The hashicorp/tls provider generates an SSH key pair locally, eliminating manual key management.
terraform {
required_providers {
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
}
resource "tls_private_key" "vps_key" {
algorithm = "ED25519"
}
output "private_key_pem" {
value = tls_private_key.vps_key.private_key_pem
sensitive = true
}Run tofu init to download the provider, then tofu apply to generate the key. Retrieve it with tofu output -raw private_key_pem > ~/.ssh/vps_key && chmod 600 ~/.ssh/vps_key.
Provision the VPS with null and local-exec
If your VPS provider does not have an official OpenTofu provider, the hashicorp/null provider with a local-exec allows you to run a local command (a curl API call, a shell script) and model the resource in the state.
resource "null_resource" "vps_provision" {
triggers = {
server_name = var.server_name
}
provisioner "local-exec" {
command = <<EOT
curl -s -X POST https://api.your-provider.com/v1/servers \
-H "Authorization: Bearer ${var.api_token}" \
-d '{"name": "${var.server_name}", "image": "ubuntu-22.04"}'
EOT
}
}This pattern suits a transitional phase. If your provider exposes a REST API, a shell script called via local-exec is sufficient to create the server and write its IP to a file that outputs.tf will then expose.
Control Docker on the VPS with the kreuzwerker provider
Once the VPS is provisioned and Docker installed (by Ansible, typically), the kreuzwerker/docker provider lets you declare containers, networks and volumes in OpenTofu.
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {
host = "ssh://root@${var.server_ip}:22"
}
resource "docker_container" "app" {
name = "my-app"
image = docker_image.app.image_id
}
resource "docker_image" "app" {
name = "nginx:alpine"
}The provider connects to the VPS Docker daemon via SSH — no additional TCP port to open. The stable provider version is available on the Terraform Registry.
Version the state for a team
For solo development, the local state (terraform.tfstate) is sufficient. In a team or CI/CD context, two developers running tofu apply simultaneously corrupt the state. The solution is a remote backend with locking.
OpenTofu natively supports S3 (AWS, self-hosted MinIO) and GitLab Managed Terraform State. With a MinIO bucket on a VPS:
terraform {
backend "s3" {
bucket = "tofu-state"
key = "production/terraform.tfstate"
region = "eu-west-1"
endpoint = "https://minio.your-domain.com"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
force_path_style = true
}
}Locking is automatic: if a tofu apply is running, a second one is rejected until the first completes.
Integrate OpenTofu into your CI/CD pipeline
A typical Woodpecker CI or Forgejo Actions pipeline runs tofu plan on every pull request (for human review of the infrastructure diff) and tofu apply on merge to the main branch.
steps:
- name: tofu-plan
image: ghcr.io/opentofu/opentofu:latest
commands:
- tofu init
- tofu plan -out=tfplan
- name: tofu-apply
image: ghcr.io/opentofu/opentofu:latest
commands:
- tofu apply tfplan
when:
branch: main
event: pushThe remote state (previous step) is essential here: the CI runner does not have access to the local state on your workstation.
Separate workspaces per environment
OpenTofu offers workspaces to isolate multiple states in the same backend: tofu workspace new staging creates an isolated space, tofu workspace select production switches to production. This is lighter than duplicating .tf directories. In practice, one workspace per client or environment (staging, prod) prevents a tofu destroy in staging from touching production. Name your resources with ${terraform.workspace} to keep them distinct in the state.
Ansible and OpenTofu: the boundary between the two
The question comes up often: Ansible already does the job, why add a tool? The boundary is clear once you draw it explicitly.
OpenTofu answers "what exists?": it creates the server, assigns an IP, sets an SSH key, records its state. If you remove it from your .tf file and run tofu apply, the server disappears — OpenTofu owns the lifecycle.
Ansible answers "what state is what exists in?": it installs Docker, configures Nginx, drops an .env file, restarts a service. If the server is already there, Ansible makes it what you ask — but if it is not there, Ansible cannot create it.
The natural flow for an agency: OpenTofu provisions the VPS and exposes its IP as output, an Ansible playbook consumes that output via a dynamic inventory, configures the server and deploys applications. The article Ansible: automate the configuration of your VPS servers covers the configuration side in detail.
Troubleshooting: common errors
Three situations come up regularly when adopting OpenTofu on an existing fleet.
Common errors and their fix
Error acquiring the state lock— a crash duringapplyleaves a.terraform.tfstate.lock.infofile on the backend. OpenTofu refuses to proceed while the lock exists. After verifying no otherapplyis running, remove the lock withtofu force-unlock <LOCK_ID>(the ID appears in the error message). On an S3/MinIO backend, the file is visible in the bucket.tofu planshows unexpecteddestroy + create— some resource attributes force a recreation (force new resource) when changed: server name, OS image type, region. If you modify one of these attributes, OpenTofu cannot do an in-place update — it destroys and recreates. Read the plan carefully before applying and usetofu plan -target=resource.nameto narrow the scope.- Lost or desynchronized state — if the state file is lost and the servers still exist,
tofu state listlists what OpenTofu believes is provisioned, andtofu import <resource.type.name> <external-id>imports an existing resource into the state without recreating it. This is the recovery tool when reality and state have diverged. - Provider not found after
tofu init— verify that the providersourceis exact (e.g.kreuzwerker/dockernotdocker/docker) and that you have internet access from the machine runningtofu init. In an air-gapped environment, pre-download providers and useplugin_cache_dir. Error: No valid credential sources found— OpenTofu cannot find credentials for the provider. Check the environment variables expected by the provider (oftenTF_VAR_api_tokenor a provider-specific credentials file) and thatterraform.tfvarsis being read (it must be in the same directory asmain.tf).
Your infrastructure becomes versioned code
OpenTofu does not replace SSH, it makes SSH exceptional. The daily flow becomes: modify a .tf file, run tofu plan to review the diff, validate, run tofu apply. Servers you no longer need to manage are destroyed by tofu destroy and disappear from billing.
For an agency managing more than a dozen clients, this is the lever that turns infrastructure management from a memory exercise into a code discipline. A ServOrbit Cloud VPS with root access, dedicated IPv4 and choice of OS is the base unit that your .tf files provision and destroy on demand. Your infrastructure becomes versioned code.