Infrastructure as Code on Gozunga Cloud with OpenTofu

Technical Guide
Published: August 6, 2026

Infrastructure as Code on Gozunga Cloud with OpenTofu

Declare your infrastructure in code, attach any cloud-init config, and provision a fully configured cloud server in three commands — then tear it all down just as fast.

Click-ops is fine until you need to reproduce it. OpenTofu lets you declare exactly what you want — instance size, OS, security groups, startup config — commit it to git, and spin it up or tear it all down with a single command. This guide walks through a complete working example that provisions a Gozunga Cloud server and installs a fully configured application stack automatically, no portal required.

  1. Overview
  2. How It Works
  3. Prerequisites
  4. Get the Example Repo
  5. Configure Your Credentials
  6. versions.tf — Wiring the OpenStack Provider
  7. main.tf — Declaring the Instance
  8. cloud-init — Bring Your Own Stack
  9. outputs.tf — Capturing the Results
  10. Deploy in Three Commands
  11. Tear It Down
  12. Swap the Stack
  13. What's Next

1. Overview

Infrastructure as Code (IaC) means describing your cloud resources — servers, networks, security groups — in plain text files that you can version, review, and run repeatedly. Instead of clicking through a portal each time you need a new environment, you write what you want once and a tool builds it.

OpenTofu is an open-source IaC tool and the community-maintained fork of Terraform. It uses the same HCL (HashiCorp Configuration Language) syntax, and it works with all the same providers — including the OpenStack provider that connects directly to the Gozunga Cloud API.

The example repo this guide walks through provisions a Gozunga Cloud VM and installs a PHP + Laravel application stack at first boot. The stack is just an example — the same workflow works for Docker, Next.js, Django, Go, a database node, a bare dev box, or anything else you can describe in a cloud-init config. Swapping stacks means changing one line in main.tf.

Here's what the example repo gives you:

  • Declares a Gozunga Cloud VM in a handful of HCL lines (main.tf)
  • Pins the provider version for reproducible builds (versions.tf)
  • Installs a full application stack automatically at first boot via cloud-init — no SSH required
  • Outputs the instance IP and ID the moment the VM is created (outputs.tf)

Note for Terraform users: The HCL syntax is identical. Every command shown as tofu can be run as terraform instead. Just install Terraform and follow along.


2. How It Works

OpenTofu and cloud-init solve two different problems that work cleanly together:

ToolResponsibility
OpenTofuCreates the VM on Gozunga Cloud — size, image, network, security groups
cloud-initCustomizes the OS at first boot — installs packages, writes config files, starts services

OpenTofu's job ends the moment the instance reaches ACTIVE. At that point cloud-init takes over — running once at first boot to install your stack and start your services. By the time you SSH in, everything is running.

This two-layer approach keeps things clean: your infrastructure declaration (main.tf) stays short and readable, while OS-level setup lives in the cloud-init file where it belongs. The two layers are also independently swappable — change the flavor in main.tf to resize the server, or point user_data at a different cloud-init file to change the stack entirely, without touching anything else.


3. Prerequisites

Before you begin, you'll need:

  • A Gozunga Cloud accountsign up here if you don't have one; you'll get $100 in free credits
  • OpenTofu installedbrew install opentofu on macOS, or see the OpenTofu install docs for Linux and Windows
  • An SSH key pair uploaded to your Gozunga Cloud project (Portal → Compute → Key Pairs)
  • Security groups configured in your project — the example uses management and SSH; create these under Networking → Security Groups with at minimum TCP/22 for SSH and TCP/80 for HTTP
  • Application Credentials from the portal (covered in the next step)

💡 Pro Tip: Application Credentials are the recommended way to authenticate programmatic access to Gozunga Cloud. They scope access to a single project, can be revoked independently of your main password, and are exactly what tools like OpenTofu expect. Create them under Portal → Access → Application Credentials.


4. Get the Example Repo

Clone the example directly from GitHub:

git clone https://github.com/gozunga/opentofu-example-php-laravel.git
cd opentofu-example-php-laravel

The repo structure is intentionally minimal:

.
├── versions.tf                        # OpenStack provider version pin
├── main.tf                            # VM resource declaration
├── outputs.tf                         # Values printed after apply
├── example-openrc                     # Credentials template
├── cloud-init/
│   └── ubuntu-laravel-nginx.yaml      # First-boot OS setup (swappable)
└── README.md

Five files. That's the entire infrastructure definition for a live cloud server with a fully running application stack. Use it as a starting point and adapt it to whatever you're deploying.


5. Configure Your Credentials

OpenTofu connects to Gozunga Cloud through the OpenStack API. Credentials are passed via environment variables — never hardcoded in your .tf files.

Create Application Credentials:

  1. Log in to the Gozunga Cloud Portal
  2. Go to Access → Application Credentials
  3. Click Create Application Credential, give it a name, and save the credential ID and secret
  4. Copy the provided template and fill in your values:
cp example-openrc my-project.openrc

The example-openrc template looks like this:

#!/usr/bin/env bash
export OS_AUTH_TYPE=v3applicationcredential
export OS_AUTH_URL=https://cloud.fsd1.gozunga.com:5000/v3
export OS_IDENTITY_API_VERSION=3
export OS_REGION_NAME="SiouxFalls"
export OS_INTERFACE=public
export OS_APPLICATION_CREDENTIAL_ID=YOUR_CREDENTIAL_ID
export OS_APPLICATION_CREDENTIAL_SECRET=YOUR_C…CRET
  1. Source the file in your terminal before running any tofu commands:
source my-project.openrc

This sets the OS_* environment variables that the OpenStack provider reads automatically — no credentials in code, no extra config blocks needed.

⚠️ Security note: Never commit real credentials to git. Keep your .openrc file outside the repo, or add *.openrc to .gitignore.


6. versions.tf — Wiring the OpenStack Provider

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 3.0"
    }
  }
}

This file does one thing: it pins the OpenStack provider to the 3.x release series. The ~> constraint means "any 3.x version" — you'll get patch updates automatically, but not unexpected breaking changes from a future major version bump.

When you run tofu init, OpenTofu reads this file and downloads the correct provider binary from the registry. That binary is what translates your HCL declarations into actual OpenStack API calls against Gozunga Cloud.

Why pin the version?

Without a version pin, tofu init pulls the latest available provider release. For one-off experiments that's fine, but for anything you plan to reproduce — staging environments, CI pipelines, team setups — pinning means everyone gets identical provider behavior regardless of when they initialize.


7. main.tf — Declaring the Instance

provider "openstack" {
  # Uses the OS_* environment variables from your sourced .openrc file
}

resource "openstack_compute_instance_v2" "test_web" {
  name            = "test-web"
  image_name      = "ubuntu-26.04"
  flavor_name     = "gp.nano1"
  key_pair        = "test-ssh-key"
  security_groups = ["management", "SSH"]

  user_data = file("${path.module}/cloud-init/ubuntu-laravel-nginx.yaml")

  network {
    name = "Internet"
  }
}

This is the infrastructure declaration — the whole thing. Let's look at each field:

FieldWhat it does
nameDisplay name of the VM in your Gozunga project
image_nameOS image — ubuntu-26.04 is the current Ubuntu LTS on Gozunga Cloud
flavor_nameInstance size — gp.nano1 is the entry-level General Purpose tier
key_pairSSH key pair already uploaded to your Gozunga project
security_groupsSecurity groups to apply — controls which inbound ports are reachable
user_datacloud-init config loaded from disk and sent to the instance at creation time
network { name }Network to attach to — Internet gives the instance a public IP

The provider "openstack" block is empty by design. The OpenStack provider reads its configuration from the OS_* environment variables you set when you sourced your .openrc file.

The user_data line is where the stack is defined. It's a file path — nothing more. Change it to point at a different cloud-init YAML and you're deploying a completely different application, with zero changes to the rest of this file. The Gozunga cloud-init collection has configs for Docker, Next.js, Django, FastAPI, Go, Rust, databases, k3s, and more — all ready to drop in.

💡 Pro Tip: Use image_name and flavor_name for readability. If you need stability against image name changes over time, switch to image_id and flavor_id using the UUIDs from openstack image list and openstack flavor list.


8. cloud-init — Bring Your Own Stack

The user_data field in main.tf points at a cloud-init YAML file. That file runs once, automatically, at first boot — before you SSH in. It's where your entire OS setup lives: package installs, config files, services, whatever your stack needs.

The example repo ships with a PHP + Laravel config to give you something concrete to look at. You don't need to use it. It's a placeholder — a working demonstration of what cloud-init can do. Swap it for any config that fits what you're actually building.

💡 The real resource: The Gozunga cloud-init collection is an open-source library of 50+ ready-to-use configs for Ubuntu and Rocky Linux. Docker, Next.js, Django, FastAPI, Go, Rust, PostgreSQL, Redis, WireGuard, Tailscale, k3s, GitLab Runner, Coolify, Netdata, Prometheus + Grafana, desktop environments with remote access — pick one, drop it in the cloud-init/ directory, update the path in main.tf, and you're done. All configs are pre-wired to use Gozunga's on-net mirrors for fast installs.

To swap the stack:

# main.tf — change this one line
user_data = file("${path.module}/cloud-init/your-config.yaml")

That's it. One line change, completely different server.

If you want to see what the example config looks like, it's in the repo at cloud-init/ubuntu-laravel-nginx.yaml. The structure is the same for any stack:

SectionWhat it does
bootcmdRewrites apt sources to Gozunga's on-net mirror before anything else runs — faster package downloads
packagesInstalls whatever your stack needs
write_filesDrops config files onto the filesystem before services start
runcmdRuns setup commands — anything you'd normally do over SSH
final_messageLogged to /var/log/cloud-init-output.log when setup finishes

Watch cloud-init progress any time over SSH:

ssh ubuntu@$(tofu output -raw fixed_ip) "tail -f /var/log/cloud-init-output.log"

9. outputs.tf — Capturing the Results

output "instance_id" {
  value = openstack_compute_instance_v2.test_web.id
}

output "instance_name" {
  value = openstack_compute_instance_v2.test_web.name
}

output "fixed_ip" {
  value = openstack_compute_instance_v2.test_web.access_ip_v4
}

output "all_network_info" {
  value = openstack_compute_instance_v2.test_web.network
}

After tofu apply finishes, these values are printed directly in your terminal:

Outputs:

all_network_info = tolist([
  {
    "fixed_ip_v4" = "185.1.2.3"
    "fixed_ip_v6" = ""
    "mac"         = "fa:16:3e:xx:xx:xx"
    "name"        = "Internet"
    "port"        = ""
    "uuid"        = "..."
  },
])
fixed_ip      = "185.1.2.3"
instance_id   = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
instance_name = "test-web"

fixed_ip is what you'll reach for most — it's the public IPv4 address you SSH to or hit with curl. instance_id is useful when referencing the instance in OpenStack CLI commands or automation scripts.

Query outputs at any time without re-running apply:

tofu output fixed_ip

Or pull a value directly into a shell variable:

IP=$(tofu output -raw fixed_ip)
ssh ubuntu@$IP

10. Deploy in Three Commands

With your .openrc sourced and the repo cloned:

# 1. Download the provider and set up the working directory
tofu init

# 2. Preview what will be created — no changes made yet
tofu plan

# 3. Create the resources
tofu apply

tofu init reads versions.tf, downloads the OpenStack provider binary, and sets up the .terraform/ directory. Run this once when you first clone the repo, and again any time provider versions change.

tofu plan compares your HCL declarations against live infrastructure and prints exactly what it intends to create, modify, or destroy. It's a dry run — safe to run as many times as you like before committing.

tofu apply executes the plan. OpenTofu prompts Do you want to perform these actions? — type yes to confirm. It then creates the instance and polls until the VM is ACTIVE.

Note on timing: OpenTofu may appear to hang after the instance reaches ACTIVE. The provider continues polling for full network information after the VM is up. You can safely press Ctrl+C once you see ACTIVE in the output — the instance is running and cloud-init has already started.

Once apply completes, cloud-init runs in the background on the instance. Watch progress over SSH:

ssh ubuntu@$(tofu output -raw fixed_ip) "tail -f /var/log/cloud-init-output.log"

When you see the final_message line, your application is live.


11. Tear It Down

tofu destroy

OpenTofu reads its state file, shows you exactly what it intends to remove, and prompts for confirmation. Type yes and everything is gone.

To remove just the instance while leaving other resources in place:

tofu destroy -target=openstack_compute_instance_v2.test_web

This is one of the biggest advantages of IaC. Without it, tearing down means remembering every resource you created, tracking each one down in the portal, and deleting them individually. OpenTofu's state file tracks everything it created — destroy handles the rest.

💡 Pro Tip: Use tofu destroy to kill dev and test environments at the end of the day, and tofu apply to recreate them the next morning. You pay only for the time the instance is actually running.


12. Swap the Stack

The cloud-init file in this repo is one example. The real flexibility is that user_data is just a file path — point it at any cloud-init YAML and you're deploying a completely different stack with zero changes to your infrastructure code.

# In main.tf — change this one line to change everything that runs on the server
user_data = file("${path.module}/cloud-init/your-config.yaml")

You can also change the underlying machine by adjusting a few fields:

What to changeFieldExample values
VM namename"web-prod-01", "staging-api"
Operating systemimage_name"ubuntu-24.04", "rocky-10"
Instance sizeflavor_name"gp.small1", "gp.medium1"
SSH keykey_pairName of an existing key pair in your Gozunga project
Networknetwork { name = "..." }"Internet", or a private network name

The Gozunga cloud-init collection has over 50 ready-to-use configs for Ubuntu and Rocky Linux — covering everything from Docker and databases to AI coding environments, reverse proxies, monitoring stacks, and desktop environments with remote access. All configs are pre-wired to use Gozunga's on-net mirrors for fast package installs.

Drop any config into the cloud-init/ directory, update the user_data path, and run tofu apply. That's the full workflow for any stack.

Useful state commands while you're working:

tofu state list        # Show all resources managed by this config
tofu show              # Full state dump with current attribute values
tofu refresh           # Sync state file with live infrastructure
openstack server list  # Cross-check directly via the OpenStack CLI

13. What's Next

The example in this guide is intentionally minimal — one VM, one cloud-init file, a handful of HCL lines. Once you're comfortable with the basics, there's a lot more you can add:

  • Floating IPs — allocate a stable IP that survives instance rebuilds
  • Block storage — attach a persistent volume for database data, separate from the instance lifecycle
  • Variables — replace hardcoded values (gp.nano1, ubuntu-26.04) with variable blocks and a terraform.tfvars file, making the config reusable across environments
  • Remote state — store your state file in object storage so teams share the same state without conflicts
  • Multiple environments — use OpenTofu workspaces or separate state files for staging and production

The OpenStack Terraform provider covers the full Gozunga Cloud API — instances, block volumes, object storage, networks, floating IPs, load balancers, routers, and more. Every resource you'd normally create through the portal can be declared in code, version-controlled, and automated.

Ready to get started? Create a Gozunga Cloud account and get $100 in free credits — more than enough to run this example, experiment with different stacks from the cloud-init collection, and build out your own IaC workflow.

Example repo: github.com/gozunga/opentofu-example-php-laravel — clone it, swap in any cloud-init config from the Gozunga cloud-init collection, and use it as the starting point for your own infrastructure-as-code setup on Gozunga Cloud.

Share:

Want to Learn More?

Have questions about our services or want to discuss how we can help your business? We'd love to hear from you.

Contact Us