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.
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.
Operators who already understand basic VPS and DNS concepts and want reproducible infrastructure without jumping straight into a large module ecosystem.
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, orstatus.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.
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.
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 +shortto 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.