{"id":1813,"date":"2025-10-21T00:11:24","date_gmt":"2025-10-20T21:11:24","guid":{"rendered":"https:\/\/upcloud.com\/global\/us\/resources\/tutorials\/opentofu-on-upcloud-hands-on-tutorial\/"},"modified":"2025-10-21T00:11:24","modified_gmt":"2025-10-20T21:11:24","slug":"opentofu-on-upcloud-hands-on-tutorial","status":"publish","type":"tutorial","link":"https:\/\/upcloud.com\/global\/resources\/tutorials\/opentofu-on-upcloud-hands-on-tutorial\/","title":{"rendered":"OpenTofu on UpCloud, Hands-on Tutorial"},"content":{"rendered":"\n<p>Use this tutorial if you\u2019re ready to build. You\u2019ll provision an UpCloud VM with the OpenTofu UpCloud provider, configure remote state on an S3-compatible bucket (with DynamoDB locking), enable client-side AES-GCM encryption for state and plans (OpenTofu \u22651.7), add guardrails with Conftest + OPA, and optionally verify Terraform compatibility via import. If you want the concepts, trade-offs, and state patterns first, read <a href=\"https:\/\/upcloud.com\/global\/blog\/developers-guide-to-opentofu-setup-policy-state\/\"><strong>OpenTofu 101<\/strong> <strong>guide<\/strong><\/a>. Otherwise, continue to <strong>Prerequisites<\/strong>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Prerequisites<\/h2>\n\n\n\n<p>Make sure you have the following set up before proceeding.<\/p>\n\n\n\n<p><strong>1. Install OpenTofu (v 1.7 or later)<\/strong><\/p>\n\n\n\n<p>You\u2019ll need OpenTofu 1.7 or newer for client-side encryption support.<\/p>\n\n\n\n<p>&nbsp;Install via <a href=\"https:\/\/opentofu.org\/docs\/intro\/install\/\" target=\"_blank\" rel=\"noopener\">OpenTofu installation docs<\/a> if you don\u2019t have it already.<\/p>\n\n\n\n<p>Verify your installation:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">tofu version<\/code><\/pre>\n\n\n\n<p><strong>2. Install Terraform<\/strong><\/p>\n\n\n\n<p>You will use Terraform later to check backward compatibility.<\/p>\n\n\n\n<p>Install via <a href=\"https:\/\/developer.hashicorp.com\/terraform\/install\" target=\"_blank\" rel=\"noopener\">Terraform installation docs<\/a> if you don\u2019t have it already.<\/p>\n\n\n\n<p>Verify your installation<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">terraform version<\/code><\/pre>\n\n\n\n<p><strong>3. Set up your UpCloud account and Object Storage<\/strong><\/p>\n\n\n\n<p>You\u2019ll need:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/signup.upcloud.com\/\">An active UpCloud account<\/a>.<\/li>\n\n\n\n<li>An&nbsp; Object Storage instance (for remote state).\n<ul class=\"wp-block-list\">\n<li>Access the <a href=\"https:\/\/upcloud.com\/global\/docs\/guides\/get-started-managed-object-storage\/\">official guide<\/a> to create the object storage instance.<\/li>\n\n\n\n<li>Within the instance:\n<ul class=\"wp-block-list\">\n<li>Create a bucket for the state.<\/li>\n\n\n\n<li>Create a user scoped to Object Storage.<\/li>\n\n\n\n<li>Grant the user an S3 full access policy.<\/li>\n\n\n\n<li>Generate an AWS access key and secret for that user.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li><strong>&nbsp;<\/strong><a href=\"https:\/\/upcloud.com\/global\/docs\/guides\/getting-started-upcloud-api\/\"><strong>A subaccount with API access (username and password)<\/strong><\/a><strong>. <\/strong>This lets OpenToFu authenticate into UpCloud.<\/li>\n<\/ul>\n\n\n\n<p>Once these are ready, you can begin building and deploying your infrastructure with OpenTofu.<\/p>\n\n\n\n<p><strong>STEP 1: Create the project structure and populate it with code<\/strong><\/p>\n\n\n\n<p>Before you start running commands, set up a working directory for your OpenTofu project (<a href=\"https:\/\/github.com\/Anita-ihuman\/OpenTofu-Upcloud.git\" target=\"_blank\" rel=\"noopener\">Use this sample repository as reference).<\/a> This directory will hold your configuration files, policy rules, and environment variables.<\/p>\n\n\n\n<p>Create the project folder and run the following commands in your terminal:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">mkdir -p opentofu-upcloud-starter\/policy\ncd opentofu-upcloud-starter\ntouch main.tf variables.tf outputs.tf backend.tf .env\ntouch policy\/max-vm-size.rego policy\/required-labels.rego policy\/zone-restriction.rego<\/code><\/pre>\n\n\n\n<p>After creating the files, your structure should look like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">opentofu-upcloud-starter\/\n\u251c\u2500 .env\n\u251c\u2500 main.tf\n\u251c\u2500 variables.tf\n\u251c\u2500 outputs.tf\n\u251c\u2500 backend.tf\n\u2514\u2500 policy\/\n   \u251c\u2500 max-vm-size.rego\n   \u251c\u2500 required-labels.rego\n   \u2514\u2500 zone-restriction.rego<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\"># .gitignore\n.env\n.tofu\/\n.terraform\/\n*.tfstate\n*.tfstate.*\nterraform.tfstate.backup\ncrash.log\noverride.tf\noverride.tf.json\n*.tfvars\n*.tfvars.json\ntfplan\nplan.json<\/code><\/pre>\n\n\n\n<p>Populate the files with code<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">.env<\/code><\/pre>\n\n\n\n<p>Holds the credentials you need to run OpenTofu on UpCloud properly<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">export UPCLOUD_USERNAME=\"&lt;upcloud_api_username&gt;\"\nexport UPCLOUD_PASSWORD=\"&lt;upcloud_api_password&gt;\"\n\n# OpenTofu client-side encryption\nexport TF_VAR_encryption_passphrase=\"&lt;create a strong-passphrase&gt;\"\n\n# UpCloud Object Storage S3-compatible credentials (not AWS)\nexport AWS_ACCESS_KEY_ID=\"&lt;object-storage-access-key&gt;\"\nexport AWS_SECRET_ACCESS_KEY=\"&lt;object-storage-secret-key&gt;\"<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">main.tf<\/code><\/pre>\n\n\n\n<p>Declares the UpCloud provider and creates a minimal VM with cloud-init and public networking.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">terraform {\n required_version = \"&gt;= 1.7.0\" # OpenTofu 1.7+ for encryption\n required_providers {\n   upcloud = {\n     source  = \"upcloudltd\/upcloud\"\n     version = \"~&gt; 5.0\"\n   }\n }\n\n}\n\nprovider \"upcloud\" {\n # Uses UPCLOUD_USERNAME and UPCLOUD_PASSWORD from environment\n}\n\nresource \"upcloud_server\" \"my_server\" {\n hostname = var.hostname\n zone     = var.zone\n plan     = var.vm_type\n\n template {\n   storage = \"Ubuntu Server 22.04 LTS (Jammy Jellyfish)\"\n  size    = var.storage_size\n }\n\n # Required by your policy pack\n labels = var.labels\n\n network_interface {\n   type = \"public\"\n }\n\n # Required for cloud-init images\n metadata  = true\n user_data = &lt;&lt;-EOT\n   #cloud-config\n   runcmd:\n     - [ bash, -lc, \"echo upcloud ok\" ]\n EOT\n\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">variables.tf <\/code><\/pre>\n\n\n\n<p>Inputs with sensible defaults and validation aligned with policy rules.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">variable \"hostname\" {\n description = \"Server hostname\"\n type        = string\n default     = \"my-server\"\n}\n\nvariable \"zone\" {\n description = \"UpCloud zone\"\n type        = string\n default     = \"us-nyc1\"\n}\n\nvariable \"vm_type\" {\n description = \"VM plan\/size\"\n type        = string\n default     = \"2xCPU-4GB\"\n}\n\nvariable \"storage_size\" {\n description = \"Storage size in GB\"\n type        = number\n default     = 50\n}\n\n# Required by your Rego policies\nvariable \"labels\" {\n description = \"Key\/value labels applied to the server\"\n type        = map(string)\n default = {\n   environment = \"dev\"\n   managed_by  = \"opentofu\"\n }\n validation {\n   condition     = contains(keys(var.labels), \"environment\") &amp;&amp; contains(keys(var.labels), \"managed_by\")\n   error_message = \"labels must include keys 'environment' and 'managed_by'.\"\n }\n}\n\n#Encryption\nvariable \"encryption_passphrase\" {\n description = \"Passphrase for state encryption\"\n type        = string\n sensitive   = true\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">outputs.tf<\/code><\/pre>\n\n\n\n<p>Exposes the server\u2019s public IPv4 address after apply.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">output \"server_ipv4\" {\n description = \"IPv4 address of the server\"\n value       = upcloud_server.my_server.network_interface[0].ip_address\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">backend.tf<\/code><\/pre>\n\n\n\n<p>Configures encrypted remote state on <a href=\"https:\/\/upcloud.com\/global\/products\/object-storage\/\">UpCloud Object Storage<\/a> and OpenTofu client-side encryption.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">terraform {\n  # Remote state on UpCloud Object Storage (S3-compatible)\n  backend \"s3\" {\n    bucket                      = \"Your_bucket_name\" # change to your bucket\n    key                         = \"${terraform.workspace}\/terraform.tfstate\"\n    endpoint                    = \"Your_bucket_endpoint\" # change to your endpoint\n    region                      = \"Your_region\"                          # required by S3 API, any string\n    skip_credentials_validation = true\n    skip_metadata_api_check     = true\n    skip_region_validation      = true\n   force_path_style = true      = true\n    # Uses AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from env\n  }\n\n  # Client-side encryption for state and plan (OpenTofu 1.7+)\n  encryption {\n    key_provider \"pbkdf2\" \"main\" {\n      passphrase = var.encryption_passphrase\n    }\n\n    method \"aes_gcm\" \"default\" {\n      keys = key_provider.pbkdf2.main\n    }\n\n    state { method = method.aes_gcm.default }\n    plan { method = method.aes_gcm.default }\n  }\n\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">policy\/max-vm-size.rego<\/code><\/pre>\n\n\n\n<p>Fails plans creating servers larger than an allowed size set.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\"># Check VM not too big\n# policy\/max-vm-size.rego\npackage main\n\nallowed_plans := {\"1xCPU-1GB\",\"1xCPU-2GB\",\"2xCPU-4GB\",\"4xCPU-8GB\"}\n\ndeny[msg] {\n  r := input.resource_changes[_]\n  r.type == \"upcloud_server\"\n  r.change.actions[_] == \"create\"\n  plan := r.change.after.plan\n  not allowed_plans[plan]\n  msg := sprintf(\"VM plan '%s' exceeds maximum allowed size of 4xCPU-8GB\", [plan])\n}\n<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">policy\/required-labels.rego<\/code><\/pre>\n\n\n\n<p>Enforces the presence of required labels for governance and tracking.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\"> # Check labels exist\npackage main\nimport future.keywords.if\nimport future.keywords.contains\n\n# Use a list (or bind from the set with [_])\nrequired_labels := [\"environment\", \"managed_by\"]\n\ndeny contains msg if {\n  some i\n  r := input.resource_changes[i]\n  r.type == \"upcloud_server\"\n  r.change.actions[_] == \"create\"\n\n  labels := r.change.after.labels\n\n  req := required_labels[_]\n\n  not labels[req]\n\n  msg := sprintf(\"Server is missing required label: %s\", [req])\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">policy\/zone-restriction.rego<\/code><\/pre>\n\n\n\n<p>Restricts deployment to approved zones only.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\"># Check zone is allowed\npackage main\nimport future.keywords.if\nimport future.keywords.in\nimport future.keywords.contains\n\nallowed_zones := {\n  \"us-nyc1\",\n  \"us-chi1\",\n  \"us-sjo1\",\n  \"de-fra1\",\n}\n\ndeny contains msg if {\n  some i\n  r := input.resource_changes[i]\n  r.type == \"upcloud_server\"\n  r.change.actions[_] == \"create\"\n\n  zone := r.change.after.zone\n  not (zone in allowed_zones)\n\n  msg := sprintf(\"Server is in unauthorized zone: %s\", [zone])\n}<\/code><\/pre>\n\n\n\n<p><strong>Step 2: Initialize, validate policies, and deploy<\/strong><\/p>\n\n\n\n<p>With your files in place, it\u2019s time to initialize OpenTofu, authenticate using your .env credentials, validate the configuration against policy rules, and finally apply the changes.<\/p>\n\n\n\n<p>Load your environment variables<\/p>\n\n\n\n<p>Before doing anything, export your credentials and encryption passphrase by sourcing the <strong>.env file<\/strong> you created earlier.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">source .env<\/code><\/pre>\n\n\n\n<p>Initialize OpenTofu<\/p>\n\n\n\n<p>Initialize the working directory, download the UpCloud provider, and configure the remote backend.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">tofu init -upgrade -reconfigure<\/code><\/pre>\n\n\n\n<p>You should see confirmation that the backend was successfully initialized and the UpCloud provider plugin was installed.<\/p>\n\n\n\n<p>Create a plan and export it to JSON<\/p>\n\n\n\n<p>Generate a plan file and convert it to JSON for policy checks.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">tofu plan -out=tfplan\ntofu show -json tfplan &gt; plan.json<\/code><\/pre>\n\n\n\n<p>This produces:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>tfplan<\/strong> \u2014 a binary plan used by OpenTofu during apply.<\/li>\n\n\n\n<li><strong>plan.json<\/strong> \u2014 a readable JSON version used by Conftest.<\/li>\n<\/ul>\n\n\n\n<p>Run policy validation<\/p>\n\n\n\n<p>Use Conftest to evaluate your plan against the Rego rules in the policy\/ directory.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">conftest test plan.json -p policy\/<\/code><\/pre>\n\n\n\n<p>If any policy fails, Conftest will print failure messages explaining what to fix (e.g., missing labels or an unauthorized zone). Once all checks pass, you\u2019re ready to deploy.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image-273.png\" alt=\"-\" class=\"wp-image-66862\" \/><\/figure>\n\n\n\n<p>Apply the configuration<\/p>\n\n\n\n<p>Apply the plan to create your UpCloud VM.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">tofu apply -auto-approve tfplan<\/code><\/pre>\n\n\n\n<p>When the process completes, OpenTofu will output the server\u2019s public IPv4 address.<\/p>\n\n\n\n<p>&nbsp;Verify deployment<\/p>\n\n\n\n<p>List the resource and extract its unique server ID for later migration testing.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">tofu state show upcloud_server.my_server | grep -i id<\/code><\/pre>\n\n\n\n<p>Multiple IDs could show; pick the first one, and also cross-check with the server ID created via your UpCloud user interface. Note it down somewhere. You\u2019ll use it later.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image-274.png\" alt=\"-\" class=\"wp-image-66863\" \/><\/figure>\n\n\n\n<p>You now have a running UpCloud instance managed by OpenTofu, verified by policy checks, and stored in encrypted remote state.<\/p>\n\n\n\n<p><strong>Optional: GitHub Actions<\/strong><\/p>\n\n\n\n<p>Add one line above it: \u201cGate apply on main; PRs run plan only.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\"># .github\/workflows\/opentofu.yml\nname: OpenTofu CI\non:\n  pull_request: { paths: [\"**.tf\"] }\n  push: { branches: [\"main\"] }\njobs:\n  plan:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: opentofu\/setup-opentofu@v1\n      - run: tofu init -input=false\n      - run: tofu plan -input=false -no-color | tee plan.txt\n  apply:\n    if: github.ref == 'refs\/heads\/main' &amp;&amp; github.event_name == 'push'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: opentofu\/setup-opentofu@v1\n      - run: tofu init -input=false\n      - env: { TOFU_CONFIRM: \"true\" }\n        run: tofu apply -input=false -auto-approve<\/code><\/pre>\n\n\n\n<p><strong>Step 3: Verify encryption at rest<\/strong><\/p>\n\n\n\n<p>OpenTofu 1.7+ supports client-side state encryption, which means your state file is encrypted before it\u2019s uploaded to UpCloud Object Storage.<\/p>\n\n\n\n<p>In this step, you\u2019ll confirm that the stored file is unreadable and only OpenTofu can decrypt it locally.<\/p>\n\n\n\n<p><strong>Locate your remote state path<\/strong><\/p>\n\n\n\n<p>The backend configuration in <strong>backend.tf <\/strong>stores state under:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">s3:\/\/&lt;your-bucket&gt;\/&lt;workspace&gt;\/terraform.tfstate<\/code><\/pre>\n\n\n\n<p>By default, the workspace is <strong>default.<\/strong> Updated with your appropriate bucket name.<\/p>\n\n\n\n<p><strong>&nbsp;Download and inspect the encrypted file<\/strong><\/p>\n\n\n\n<p>Run the following command to copy the state file from UpCloud Object Storage and display its first few encrypted bytes:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">aws s3 cp s3:\/\/&lt;your-bucket&gt;\/&lt;workspace&gt;\/terraform.tfstate \/tmp\/state.enc --endpoint-url &lt;your-bucket-endpoint&gt; &amp;&amp; xxd \/tmp\/state.enc | head<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image-275.png\" alt=\"-\" class=\"wp-image-66866\" \/><\/figure>\n\n\n\n<p>You should see binary-looking data, which confirms that the file is encrypted before upload.&nbsp;<\/p>\n\n\n\n<p><strong>Decrypt locally using OpenTofu<\/strong><\/p>\n\n\n\n<p>Now confirm that OpenTofu can read and decrypt the file with your passphrase:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">tofu show -json \/tmp\/state.enc | head<\/code><\/pre>\n\n\n\n<p>If decryption succeeds, you\u2019ll see JSON output containing your resource definitions (e.g., <strong>upcloud_server.my_server)<\/strong>.<\/p>\n\n\n\n<p><strong>Clean up temporary file<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">rm \/tmp\/state.enc<\/code><\/pre>\n\n\n\n<p>At this point, you\u2019ve verified that the state file stored in Object Storage is encrypted at rest and only OpenTofu with the correct passphrase can decrypt and read it.<\/p>\n\n\n\n<p><strong>Step 4: Migration test to verify Terraform compatibility<\/strong><\/p>\n\n\n\n<p>Create a throwaway copy<\/p>\n\n\n\n<p>Keep the real project untouched while you test. Switch to another terminal and make sure you are still in your project root directory <strong>opentofu-upcloud-starter<\/strong>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\"># from your repo root\nmkdir -p \/tmp\/tf-migrate &amp;&amp; cp -R . \/tmp\/tf-migrate\ncd \/tmp\/tf-migrate<\/code><\/pre>\n\n\n\n<p>This copies all files from your original project into a temp workspace.<\/p>\n\n\n\n<p>Switch this copy to a local backend<\/p>\n\n\n\n<p>Terraform cannot read OpenTofu\u2019s encryption block or encrypted remote state. We will migrate the encrypted remote state to a plain local file.<\/p>\n\n\n\n<p>Edit <strong>backend.tf <\/strong>&nbsp;in <strong>\/tmp\/tf-migrate<\/strong> to only contain:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">terraform {\n  backend \"local\" {\n    path = \"terraform.tfstate\"\n  }\n}<\/code><\/pre>\n\n\n\n<p><strong>Warning:<\/strong> Do <strong>not<\/strong> point Terraform at the <strong>encrypted remote state<\/strong> created by OpenTofu. Terraform cannot read OpenTofu\u2019s client-side\u2013encrypted state. Migrate to a local state (as shown) or to an unencrypted backend first.<\/p>\n\n\n\n<p><strong>Update the resource with lifecycle ignore rules<\/strong><\/p>\n\n\n\n<p>In the <strong>\/tmp\/tf-migrate<\/strong> copy, edit main.tf and replace the upcloud_server block with this version.<\/p>\n\n\n\n<p>These ignore_changes entries prevent false drift during import by ignoring write-only or provider-normalized fields.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">resource \"upcloud_server\" \"my_server\" {\n  hostname = var.hostname\n  zone     = var.zone\n  plan     = var.vm_type\n\n  # Keep template so the schema is satisfied (creation-time detail)\n  template {\n    storage = \"Ubuntu Server 22.04 LTS (Jammy Jellyfish)\"\n    size    = var.storage_size\n  }\n\n  labels = var.labels\n\n  network_interface {\n    type = \"public\"\n  }\n\n  metadata = true\n\n  user_data = &lt;&lt;-EOT\n    #cloud-config\n    runcmd:\n      - [ bash, -lc, \"echo upcloud ok\" ]\n  EOT\n\n  lifecycle {\n    ignore_changes = [\n      user_data,         # write-only; would force replace\n      template,          # API returns normalized ids\/tiers\n      title,             # provider may append text\n      tags,              # may differ from desired set\n      network_interface, # live NIC\/IP differs after creation\n      storage_devices,   # read-only attached disk view\n      metadata,          # may normalize at read time\n    ]\n  }\n}<\/code><\/pre>\n\n\n\n<p><strong>Import into Terraform and verify no changes<\/strong><\/p>\n\n\n\n<p>Run these from the \/tmp\/tf-migrate directory.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\"># use the same creds you used with OpenTofu\nsource .env\n\n# sanity check Terraform\nterraform -version\n\n# init against the local backend\nterraform init -reconfigure\n\n# import the existing server using the real ID you captured earlier\nterraform import upcloud_server.my_server &lt;Your_UpCloud_server_Id_from_step2&gt;\n\n# plan should report no changes\nterraform plan<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image-276.png\" alt=\"-\" class=\"wp-image-66868\" \/><\/figure>\n\n\n\n<p>This imports the server created with OpenTofu into Terraform. When you run terraform plan, it reports no changes, proving you can move between Terraform and OpenTofu with only minor configuration tweaks and no impact on the underlying infrastructure.<\/p>\n\n\n\n<p><strong>Step 5: Clean up resources<\/strong><\/p>\n\n\n\n<p>Once you\u2019ve confirmed that Terraform and OpenTofu see the same infrastructure, you can clean up both the temporary workspace and your deployed server.<\/p>\n\n\n\n<p><strong>&nbsp;Delete the temporary migration folder<\/strong><\/p>\n\n\n\n<p>In your \/tf-migrate directory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">cd ..\nrm -rf \/tmp\/tf-migrate<\/code><\/pre>\n\n\n\n<p>This removes the throwaway copy used for migration testing.<\/p>\n\n\n\n<p>Destroy the real infrastructure with OpenTofu<\/p>\n\n\n\n<p>Switch back to your original terminal where you were inside the main project directory (opentofu-upcloud-starter) and run:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">tofu state list\ntofu destroy -auto-approve<\/code><\/pre>\n\n\n\n<p>OpenTofu will decrypt the remote state, remove all provisioned resources on UpCloud, and leave your Object Storage bucket intact.<\/p>\n\n\n\n<p>(Optional) Delete the Object Storage instance<\/p>\n\n\n\n<p>If you\u2019re done experimenting, you can safely delete the Object Storage instance and its associated bucket from your UpCloud dashboard.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Log in to your UpCloud control panel<\/li>\n\n\n\n<li>Navigate to <strong>Storage \u2192 Object Storage<\/strong><\/li>\n\n\n\n<li>Select your instance and choose <strong>Delete<\/strong><\/li>\n<\/ul>\n\n\n\n<p>This removes the encrypted state bucket entirely and closes the tutorial environment.<\/p>\n\n\n\n<p>With that, you\u2019ve completed the practical section of this guide, from configuring encrypted remote state to validating policies and confirming Terraform compatibility. Before moving on, it\u2019s worth looking at a few common pitfalls you might encounter when working with OpenTofu and how to avoid them.<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">Common pitfalls and how to avoid them<\/h1>\n\n\n\n<p>Keeping these pitfalls in check early on will help you avoid most of the frustrations teams face when working with OpenTofu.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Mixing Terraform and OpenTofu binaries:<\/strong> Running both tools(e.g., tofu apply and terraform apply) in the same repository or pipeline can cause provider or state inconsistencies. Use a version manager like <a href=\"https:\/\/asdf-vm.com\/manage\/configuration.html#tool-versions\" target=\"_blank\" rel=\"noopener\">asdf<\/a> or a .tool-versions file to isolate the OpenTofu binary. Ensure your pipelines use only one tool per project.<\/li>\n\n\n\n<li><strong>Ignoring provider version pinning:<\/strong> Providers frequently release updates, and not pinning versions, whether locally or in CI\/CD, can cause builds to break due to feature differences. Always specify versions in your required_providers block and lock them with .tofu.lock.hcl.<\/li>\n\n\n\n<li><strong>Not encrypting the backend:<\/strong> Storing state files in unencrypted forms exposes sensitive data, including IP addresses, credentials, and metadata. Enable OpenTofu\u2019s built-in encryption or use a backend that supports encryption at rest.<\/li>\n\n\n\n<li><strong>Storing state locally:<\/strong> Keeping local state prevents collaboration and increases the risk of overwrites. Use remote object storage for shared access and reliable recovery.<\/li>\n\n\n\n<li><strong>Not cleaning up unused state:<\/strong> Old resources can linger in your state file even after deletion, adding unnecessary complexity. Run tofu state rm or rebuild cleanly when making significant structural changes.<\/li>\n\n\n\n<li><strong>Poor naming or inconsistent paths:<\/strong> Inconsistently naming environments (for example, one developer uses <strong>production<\/strong> while another uses <strong>prod<\/strong>) can lead to accidental overwrites. Set standard naming conventions and folder structures across your team.<\/li>\n<\/ul>\n\n\n\n<h1 class=\"wp-block-heading\">Next steps<\/h1>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Team safety and locking.<\/strong> UpCloud Object Storage doesn\u2019t provide state locking. Serialize <code>tofu apply<\/code> in CI (one job per workspace via a concurrency group), or run a small Consul cluster on UpCloud and use the <code>consul<\/code> backend for locks.<\/li>\n\n\n\n<li><strong>Structure environments.<\/strong> Split repo into <code>\/modules\/*<\/code> and <code>\/envs\/{dev,staging,prod}<\/code>. Scope remote-state keys per env and pass module outputs between stacks.<\/li>\n\n\n\n<li><strong>Harden encryption.<\/strong> Keep client-side AES-GCM enabled. Store the passphrase in CI secrets. Rotate keys on a schedule and document recovery steps.<\/li>\n\n\n\n<li><strong>Enforce policy.<\/strong> Run Conftest on every PR and fail on any <code>deny<\/code>. If you rely on <code>future.keywords<\/code> in Rego, require OPA \u2265 0.58 or remove those imports for broader compatibility.<\/li>\n\n\n\n<li><strong>Secrets hygiene.<\/strong> Never commit <code>.env<\/code>, state files, or plans. Use a <code>.gitignore<\/code> and inject <code>UPCLOUD_*<\/code> and <code>TF_VAR_encryption_passphrase<\/code> via CI secrets.<\/li>\n\n\n\n<li><strong>Migration safety.<\/strong> When testing Terraform \u2194 OpenTofu, do not point Terraform at OpenTofu\u2019s client-side-encrypted remote state. Migrate to local state first, then <code>terraform import<\/code>.<\/li>\n<\/ul>\n\n\n\n<p>Want background first? Read <strong><a href=\"https:\/\/upcloud.com\/global\/blog\/developers-guide-to-opentofu-setup-policy-state\/\">OpenTofu 101: Concepts and Best Practices<\/a><\/strong>.<\/p>\n\n\n\n<p><\/p>\n","protected":false},"author":19,"featured_media":0,"comment_status":"open","ping_status":"closed","template":"","community-category":[223,232,235],"class_list":["post-1813","tutorial","type-tutorial","status-publish","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/1813","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial"}],"about":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/types\/tutorial"}],"author":[{"embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/users\/19"}],"replies":[{"embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/comments?post=1813"}],"version-history":[{"count":0,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/1813\/revisions"}],"wp:attachment":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/media?parent=1813"}],"wp:term":[{"taxonomy":"community-category","embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/community-category?post=1813"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}