Provision a Small Self-Hosting VPS and DNS Records with OpenTofu

Provision one small server and the DNS records that point at it from reviewable OpenTofu code so your base infrastructure can be recreated, compared, and changed with less guesswork than manual dashboard clicks.

OpenTofu Hetzner Cloud Cloudflare DNS
Illustrated guide cover for Provision a Small Self-Hosting VPS and DNS Records with OpenTofu
OpenTofu • Hetzner Cloud • Cloudflare DNS
What you learn

How to model one small VPS, a firewall baseline, and a few DNS records in OpenTofu with a narrow provider scope that stays understandable for beginners.

Best for

Operators who already understand basic VPS and DNS concepts and want reproducible infrastructure without jumping straight into a large module ecosystem.

Risk to watch

IaC can destroy as well as create. A careless apply against the wrong state or zone can replace live resources faster than a hand-edited dashboard ever would.

Before you begin

  • An OpenTofu installation on your workstation.
  • A Hetzner Cloud API token and a Cloudflare API token with zone-scoped DNS edit access.
  • A DNS zone you control, such as example.com.
  • A clear hostname plan like app.example.com, www.example.com, or status.example.com.

This guide stays deliberately narrow: one VPS provider and one DNS provider. That is not because other providers are invalid. It is because infrastructure-as-code gets sloppy fast when a beginner guide tries to abstract too much.

Expected outcome: You will finish with one reviewable OpenTofu project that can create a small VPS, expose its public IP as an output, and manage the matching Cloudflare DNS records for that host.

Step 1: Lock the provider scope first

This draft uses:

  • Compute: Hetzner Cloud
  • DNS: Cloudflare
  • Guest configuration handoff: cloud-init or Ansible after the server exists

That separation matters. OpenTofu should create the infrastructure shape. It should not become a giant shell script that also tries to configure every package inside the guest on day one.

Warning: Keep the OpenTofu state file somewhere intentional. Losing state or pointing at the wrong state backend is how "harmless cleanup" turns into recreated servers and confusing drift.

Step 2: Create a small project layout

mkdir -p ~/infra/selfhosting-vps
cd ~/infra/selfhosting-vps
touch versions.tf providers.tf variables.tf main.tf outputs.tf terraform.tfvars.example .gitignore

Start with a simple versions file:

terraform {
  required_version = ">= 1.8.0"

  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.66"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5.10"
    }
  }
}

Provider configuration can stay minimal:

provider "hcloud" {
  token = var.hcloud_token
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

Add tokens to a local-only tfvars file or environment variables, not to tracked source.

Step 3: Define one small VPS cleanly

Use variables that make the project easy to read:

variable "hcloud_token" {
  type      = string
  sensitive = true
}

variable "cloudflare_api_token" {
  type      = string
  sensitive = true
}

variable "cloudflare_zone_id" {
  type = string
}

variable "hostname" {
  type = string
}

variable "ssh_key_ids" {
  type = list(string)
}

Then define the server:

resource "hcloud_server" "app" {
  name        = var.hostname
  server_type = "cx22"
  image       = "ubuntu-24.04"
  location    = "ash"
  ssh_keys    = var.ssh_key_ids

  public_net {
    ipv4_enabled = true
    ipv6_enabled = true
  }
}

Keep the first version boring on purpose. You can add volumes, placement groups, and floating IPs later after the basic path is working.

Step 4: Define the DNS records safely

Pass the Cloudflare zone ID in directly instead of depending on a zone lookup. That keeps the example narrower and avoids provider-version drift around lookup attributes.

Create only the specific records you intend to manage:

resource "cloudflare_dns_record" "app_a" {
  zone_id = var.cloudflare_zone_id
  name    = var.hostname
  type    = "A"
  content = hcloud_server.app.ipv4_address
  ttl     = 300
  proxied = false
}

Start with the A record first. Add AAAA only after you have confirmed how you want to route IPv6 on this host and you have verified that the guest is reachable over IPv6 in your environment.

Step 5: Plan, apply, and verify

Initialize the project and review the plan carefully:

tofu init
tofu fmt
tofu validate
tofu plan

Only apply once the resource names, region, and DNS targets look right:

tofu apply

Expose outputs that help the handoff to the next layer:

output "server_ipv4" {
  value = hcloud_server.app.ipv4_address
}

Then verify from outside OpenTofu:

  • Check the new server in the provider dashboard.
  • Confirm the DNS records exist in Cloudflare.
  • Use dig +short to verify the new A answer before you build more on top of it.
  • SSH to the new host before you build more on top of it.

What to do after provisioning

OpenTofu gets the box and the DNS into place. The next layer is guest configuration and service deployment. That is where cloud-init, Ansible, reverse proxies, and Compose guides take over.

Plan to roll back.
If you need to destroy the lab later, run tofu destroy only after confirming you are pointing at the right state and the records are really disposable.

Keep secrets out of the repo.
Use a local tfvars file ignored by Git, environment variables, or a separate secret-management workflow.