UpCloud Part 2: OpenTofu and more

Published: Nov 11, 2025 by Isaac Johnson

Continuing from our last post about UpCloud, I really want to get into Terraform / OpenTofu next.

Last time we explored some features with UpCloud and their Hub and CLI. Today I want to setup a full OpenTofu (terraform) stack and show how to import created items as well as move to remote state management in Object Storage.

Terraform / OpenTofu

Let’s start by creating a provider.tf and doing an init. So that I do not have to store my login vars in a file (which could be errantly committed to source), we can set the username and password as env vars

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ export UPCLOUD_USERNAME=idjohnson
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ export UPCLOUD_PASSWORD='xxxxxxxxxxxxxxx'
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat provider.tf
terraform {
  required_providers {
    upcloud = {
      source  = "UpCloudLtd/upcloud"
      version = "~> 5.0"
    }
  }
}

provider "upcloud" {
  # username and password configuration arguments can be omitted
  # if environment variables UPCLOUD_USERNAME and UPCLOUD_PASSWORD are set
  # username = ""
  # password = ""
}

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ tofu init

Initializing the backend...

Initializing provider plugins...
- Finding upcloudltd/upcloud versions matching "~> 5.0"...
- Installing upcloudltd/upcloud v5.29.1...
- Installed upcloudltd/upcloud v5.29.1 (signed, key ID 6182C780EB46767E)

Providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://opentofu.org/docs/cli/plugins/signing/

OpenTofu has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that OpenTofu can guarantee to make the same selections by default when
you run "tofu init" in the future.

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

I then imported into the state file

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat network.tf
import {
  to = upcloud_network.my_network
  id = "037517fb-a70e-43b2-862e-ba41f81e5f2c"
}

resource "upcloud_network" "my_network" {
  # ...
}

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ tofu import upcloud_network.my_network 037517fb-a70e-43b2-862e-ba41f81e5f2c
upcloud_network.my_network: Importing from ID "037517fb-a70e-43b2-862e-ba41f81e5f2c"...
upcloud_network.my_network: Import prepared!
  Prepared upcloud_network for import
upcloud_network.my_network: Refreshing state... [id=037517fb-a70e-43b2-862e-ba41f81e5f2c]

Import successful!

The resources that were imported are shown above. These resources are now in
your OpenTofu state and will henceforth be managed by OpenTofu.

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat network.tf
import {
  to = upcloud_network.my_network
  id = "037517fb-a70e-43b2-862e-ba41f81e5f2c"
}

resource "upcloud_network" "my_network" {
  # ...
}

I can then export the state to HCL and append to my network.tf

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ tofu state show upcloud_network.my_network >> network.tf
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat network.tf
import {
  to = upcloud_network.my_network
  id = "037517fb-a70e-43b2-862e-ba41f81e5f2c"
}

resource "upcloud_network" "my_network" {
  # ...
}

# upcloud_network.my_network:
resource "upcloud_network" "my_network" {
    id     = "037517fb-a70e-43b2-862e-ba41f81e5f2c"
    labels = {}
    name   = "My Network"
    router = "043cd66a-bc49-487d-8ab9-72fe50476164"
    type   = "private"
    zone   = "de-fra1"

    ip_network {
        address            = "10.0.0.0/24"
        dhcp               = true
        dhcp_default_route = false
        dhcp_dns           = []
        dhcp_routes        = []
        family             = "IPv4"
        gateway            = "10.0.0.1"
    }
}

Some items are set to Read-Only so Tofu won’t like that we have them set in the HCL

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat network.tf
# upcloud_network.my_network:
resource "upcloud_network" "my_network" {
    id     = "037517fb-a70e-43b2-862e-ba41f81e5f2c"
    labels = {}
    name   = "My Network"
    router = "043cd66a-bc49-487d-8ab9-72fe50476164"
    type   = "private"
    zone   = "de-fra1"

    ip_network {
        address            = "10.0.0.0/24"
        dhcp               = true
        dhcp_default_route = false
        dhcp_dns           = []
        dhcp_routes        = []
        family             = "IPv4"
        gateway            = "10.0.0.1"
    }
}
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ tofu plan
╷
│ Error: Invalid Configuration for Read-Only Attribute
│
│   with upcloud_network.my_network,
│   on network.tf line 3, in resource "upcloud_network" "my_network":
│    3:     id     = "037517fb-a70e-43b2-862e-ba41f81e5f2c"
│
│ Cannot set value for this attribute as the provider has marked it as read-only. Remove the configuration line
│ setting the value.
│
│ Refer to the provider documentation or contact the provider developers for additional information about
│ configurable and read-only attributes that are supported.
╵
╷
│ Error: Invalid Configuration for Read-Only Attribute
│
│   with upcloud_network.my_network,
│   on network.tf line 7, in resource "upcloud_network" "my_network":
│    7:     type   = "private"
│
│ Cannot set value for this attribute as the provider has marked it as read-only. Remove the configuration line
│ setting the value.
│
│ Refer to the provider documentation or contact the provider developers for additional information about
│ configurable and read-only attributes that are supported.

We can just comment them out to see that Tofu is happy again

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat network.tf
# upcloud_network.my_network:
resource "upcloud_network" "my_network" {
    # id     = "037517fb-a70e-43b2-862e-ba41f81e5f2c"
    labels = {}
    name   = "My Network"
    router = "043cd66a-bc49-487d-8ab9-72fe50476164"
    # type   = "private"
    zone   = "de-fra1"

    ip_network {
        address            = "10.0.0.0/24"
        dhcp               = true
        dhcp_default_route = false
        dhcp_dns           = []
        dhcp_routes        = []
        family             = "IPv4"
        gateway            = "10.0.0.1"
    }
}
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ tofu plan
upcloud_network.my_network: Refreshing state... [id=037517fb-a70e-43b2-862e-ba41f81e5f2c]

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and found no differences, so no changes
are needed.

We can so similar with our database. Let’s look at the Tofu docs for UpCloud PostgreSQL managed databases

I’ll create a basic db.tf

$ cat ./db.tf
resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1" {
  # (resource arguments)
}

I tend to name the resource after the instance ID. I just then need to import into my state file

/content/images/2025/11/upcloud-55.png

Then dump it into the TF file

$ tofu state show upcloud_managed_database_postgresql.pg-1x1xcpu-1gb-1
0gb-de-fra1 >> db.tf

This created quite a large block of HCL. I’ll use tofu plan to see if it is happy with it

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat db.tf
# upcloud_managed_database_postgresql.pg-1x1xcpu-1gb-10gb-de-fra1:
resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1" {
    additional_disk_space_gib = 0
    components                = [
        {
            component = "pg"
            host      = "idjtest-yaiqdimddmyd.db.upclouddatabases.com"
            port      = 11569
            route     = "dynamic"
            usage     = "primary"
        },
        {
            component = "pg"
            host      = "public-idjtest-yaiqdimddmyd.db.upclouddatabases.com"
            port      = 11569
            route     = "public"
            usage     = "primary"
        },
        {
            component = "pgbouncer"
            host      = "idjtest-yaiqdimddmyd.db.upclouddatabases.com"
            port      = 11570
            route     = "dynamic"
            usage     = "primary"
        },
        {
            component = "pgbouncer"
            host      = "public-idjtest-yaiqdimddmyd.db.upclouddatabases.com"
            port      = 11570
            route     = "public"
            usage     = "primary"
        },
    ]
    id                        = "09984449-3b63-4a89-83ef-ffaa93cc89ca"
    labels                    = {}
    maintenance_window_dow    = "sunday"
    maintenance_window_time   = "05:00:00"
    name                      = "idjtest"
    node_states               = [
        {
            name  = "idjtest-1"
            role  = "master"
            state = "running"
        },
    ]
    plan                      = "1x1xCPU-1GB-10GB"
    powered                   = true
    primary_database          = "defaultdb"
    service_host              = "idjtest-yaiqdimddmyd.db.upclouddatabases.com"
    service_password          = (sensitive value)
    service_port              = "11569"
    service_uri               = (sensitive value)
    service_username          = "avnadmin"
    sslmode                   = "require"
    state                     = "running"
    termination_protection    = false
    title                     = "pg-1x1xcpu-1gb-10gb-de-fra1"
    type                      = "pg"
    zone                      = "de-fra1"

    properties {
        automatic_utility_network_ip_filter    = true
        autovacuum_analyze_scale_factor        = 0
        autovacuum_analyze_threshold           = 0
        autovacuum_freeze_max_age              = 0
        autovacuum_max_workers                 = 0
        autovacuum_naptime                     = 0
        autovacuum_vacuum_cost_delay           = 0
        autovacuum_vacuum_cost_limit           = 0
        autovacuum_vacuum_scale_factor         = 0
        autovacuum_vacuum_threshold            = 0
        backup_hour                            = 0
        backup_minute                          = 22
        bgwriter_delay                         = 0
        bgwriter_flush_after                   = 0
        bgwriter_lru_maxpages                  = 0
        bgwriter_lru_multiplier                = 0
        deadlock_timeout                       = 0
        idle_in_transaction_session_timeout    = 0
        ip_filter                              = [
            "75.72.233.202/32",
        ]
        jit                                    = false
        log_autovacuum_min_duration            = 0
        log_min_duration_statement             = 0
        log_temp_files                         = 0
        max_connections                        = 0
        max_files_per_process                  = 0
        max_locks_per_transaction              = 0
        max_logical_replication_workers        = 0
        max_parallel_workers                   = 0
        max_parallel_workers_per_gather        = 0
        max_pred_locks_per_transaction         = 0
        max_prepared_transactions              = 0
        max_replication_slots                  = 0
        max_slot_wal_keep_size                 = 0
        max_stack_depth                        = 0
        max_standby_archive_delay              = 0
        max_standby_streaming_delay            = 0
        max_sync_workers_per_subscription      = 0
        max_wal_senders                        = 0
        max_worker_processes                   = 0
        password_encryption                    = (sensitive value)
        pg_partman_bgw_interval                = 0
        pg_stat_monitor_enable                 = false
        pg_stat_monitor_pgsm_enable_query_plan = false
        pg_stat_monitor_pgsm_max_buckets       = 0
        public_access                          = true
        service_log                            = false
        shared_buffers_percentage              = 0
        temp_file_limit                        = 0
        track_activity_query_size              = 0
        version                                = "18"
        wal_sender_timeout                     = 0
        wal_writer_delay                       = 0
        work_mem                               = 0

        pglookout {
            max_failover_replication_time_lag = 60
        }
    }
}
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ tofu plan
╷
│ Error: Unbalanced parentheses
│
│   on db.tf line 50, in resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1":
│   50:     service_password          = (sensitive value)
│
│ Expected a closing parenthesis to terminate the expression.
╵
╷
│ Error: Unbalanced parentheses
│
│   on db.tf line 52, in resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1":
│   52:     service_uri               = (sensitive value)
│
│ Expected a closing parenthesis to terminate the expression.
╵
╷
│ Error: Unbalanced parentheses
│
│   on db.tf line 103, in resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1":
│  103:         password_encryption                    = (sensitive value)
│
│ Expected a closing parenthesis to terminate the expression.
╵

Once I trimmed it down to the TF settable values:

$ cat db.tf
# upcloud_managed_database_postgresql.pg-1x1xcpu-1gb-10gb-de-fra1:
resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1" {
    additional_disk_space_gib = 0
    labels                    = {}
    maintenance_window_dow    = "sunday"
    maintenance_window_time   = "05:00:00"
    name                      = "idjtest"
    plan                      = "1x1xCPU-1GB-10GB"
    powered                   = true
    termination_protection    = false
    title                     = "pg-1x1xcpu-1gb-10gb-de-fra1"
    zone                      = "de-fra1"

    properties {
        automatic_utility_network_ip_filter    = true
        backup_hour                            = 0
        backup_minute                          = 22
        ip_filter                              = [
            "75.72.233.202/32",
        ]
        jit                                    = false
        pg_stat_monitor_enable                 = false
        pg_stat_monitor_pgsm_enable_query_plan = false
        public_access                          = true
        service_log                            = false

        pglookout {
            max_failover_replication_time_lag = 60
        }
    }
}

Then the plan worked just fine

$ tofu plan
upcloud_network.my_network: Refreshing state... [id=037517fb-a70e-43b2-862e-ba41f81e5f2c]
upcloud_managed_database_postgresql.pg-1x1xcpu-1gb-10gb-de-fra1: Refreshing state... [id=09984449-3b63-4a89-83ef-ffaa93cc89ca]

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and found no differences, so no changes are
needed.

Here we can see attempting to update with TF

However as we noted, the current plan did not let us add storage. But at least we see that confirmed in the Hub and with OpenTofu.

Another way we can see Tofu in action is to set some labels in the Hub

/content/images/2025/11/upcloud-57.png

Then when I try and plan, we can see Tofu would want to remove them

$ tofu plan
upcloud_network.my_network: Refreshing state... [id=037517fb-a70e-43b2-862e-ba41f81e5f2c]
upcloud_managed_database_postgresql.pg-1x1xcpu-1gb-10gb-de-fra1: Refreshing state... [id=09984449-3b63-4a89-83ef-ffaa93cc89ca]

OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with
the following symbols:
  ~ update in-place

OpenTofu will perform the following actions:

  # upcloud_managed_database_postgresql.pg-1x1xcpu-1gb-10gb-de-fra1 will be updated in-place
  ~ resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1" {
        id                        = "09984449-3b63-4a89-83ef-ffaa93cc89ca"
      ~ labels                    = {
          - "myinst" = "Dev" -> null
        }
        name                      = "idjtest"
        # (19 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so OpenTofu can't guarantee to take exactly these actions if
you run "tofu apply" now.

Let’s add a label and use tofu fmt to ensure we maintain good code formatting

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat db.tf
# upcloud_managed_database_postgresql.pg-1x1xcpu-1gb-10gb-de-fra1:
resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1" {
    additional_disk_space_gib = 0
    maintenance_window_dow    = "sunday"
    maintenance_window_time   = "05:00:00"
    labels                    = {
       "myinst" = "Dev"
       "usingtofu" = "true"
    }
    name                      = "idjtest"
    plan                      = "1x1xCPU-1GB-10GB"
    powered                   = true
    termination_protection    = false
    title                     = "pg-1x1xcpu-1gb-10gb-de-fra1"
    zone                      = "de-fra1"

    properties {
        automatic_utility_network_ip_filter    = true
        backup_hour                            = 0
        backup_minute                          = 22
        ip_filter                              = [
            "75.72.233.202/32",
        ]
        jit                                    = false
        pg_stat_monitor_enable                 = false
        pg_stat_monitor_pgsm_enable_query_plan = false
        public_access                          = true
        service_log                            = false

        pglookout {
            max_failover_replication_time_lag = 60
        }
    }
}
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ tofu fmt
db.tf
network.tf
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ cat db.tf
# upcloud_managed_database_postgresql.pg-1x1xcpu-1gb-10gb-de-fra1:
resource "upcloud_managed_database_postgresql" "pg-1x1xcpu-1gb-10gb-de-fra1" {
  additional_disk_space_gib = 0
  maintenance_window_dow    = "sunday"
  maintenance_window_time   = "05:00:00"
  labels = {
    "myinst"    = "Dev"
    "usingtofu" = "true"
  }
  name                   = "idjtest"
  plan                   = "1x1xCPU-1GB-10GB"
  powered                = true
  termination_protection = false
  title                  = "pg-1x1xcpu-1gb-10gb-de-fra1"
  zone                   = "de-fra1"

  properties {
    automatic_utility_network_ip_filter = true
    backup_hour                         = 0
    backup_minute                       = 22
    ip_filter = [
      "75.72.233.202/32",
    ]
    jit                                    = false
    pg_stat_monitor_enable                 = false
    pg_stat_monitor_pgsm_enable_query_plan = false
    public_access                          = true
    service_log                            = false

    pglookout {
      max_failover_replication_time_lag = 60
    }
  }
}

Here we can see the TF updating the labels:

If we are to do IaC properly, we do not store our state file locally or even checked into GIT because it can have secrets.

So let’s first create an Object Store we can use with the state file

$ cat objectstore.tf
resource "upcloud_managed_object_storage" "terraform_state_bucket" {
  region            = "europe-2"
  name              = "terraform-state-storage"
  configured_status = "started"
}

resource "upcloud_managed_object_storage_bucket" "tfbucket" {
  service_uuid = upcloud_managed_object_storage.terraform_state_bucket.id
  name         = "tfbucket"
}

The plan takes a while (at least for me)

/content/images/2025/11/upcloud-59.png

Seems it is taking time setting up the public endpoint

/content/images/2025/11/upcloud-60.png

I’m being patient. but at the 20m mark, Tofu times out and we still see it in the provisioning private endpoint state

/content/images/2025/11/upcloud-64.png

Trying to just create a bucket is blocked unless I pay them US$10

/content/images/2025/11/upcloud-61.png

However, when I did this again, removing the old and re-adding, it worked

... snip ...
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [16m11s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [16m21s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [16m31s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [16m41s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [16m51s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [17m1s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [17m11s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [17m21s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [17m31s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [17m41s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [17m51s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [18m1s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [18m11s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [18m21s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [18m31s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [18m41s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [18m51s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Still creating... [19m1s elapsed]
upcloud_managed_object_storage.terraform_state_bucket: Creation complete after 19m1s [id=120938cb-587c-4f83-b3c2-eae183556ec4]
upcloud_managed_object_storage_bucket.tfbucket: Creating...
upcloud_managed_object_storage_bucket.tfbucket: Creation complete after 1s [id=120938cb-587c-4f83-b3c2-eae183556ec4/tfbucket]

Apply complete! Resources: 2 added, 1 changed, 2 destroyed.

/content/images/2025/11/upcloud-67.png

Though, the bucket was not public so I needed to enable that

/content/images/2025/11/upcloud-70.png

This incurs another delay as we wait for certs to get created

/content/images/2025/11/upcloud-71.png

To use this as a backend, we can leverage the S3 compatible endpoint.

First, create a user with a policy - I used FullAccess as a demo

/content/images/2025/11/upcloud-72.png

When I did “+ Access Key” it created a new key I could use with the standard AWS Env vars

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ export AWS_SECRET_ACCESS_KEY='xxxx/xxxxxxxxxxxxxxxxx/xxxxxxxxxxxxxx/xxxx'
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ export AWS_ACCESS_KEY_ID='AKIA12F2143D3BC35116'

I changed my provider.tf to use the storage account and s3 as the type

terraform {
  required_providers {
    upcloud = {
      source  = "UpCloudLtd/upcloud"
      version = "~> 5.0"
    }
  }
  
  backend "s3" {
    # Obtain the UpCloud object storage URL from the created resource
    bucket = "tfbucket"
    key = "terraform.tfstate"
    endpoint = "https://14scm.upcloudobjects.com"
    region = "us-east-1"        
    skip_credentials_validation = true
    skip_region_validation      = true
    skip_s3_checksum            = true 
  }
}

provider "upcloud" {
  # username and password configuration arguments can be omitted  
  # if environment variables UPCLOUD_USERNAME and UPCLOUD_PASSWORD are set
  # username = ""
  # password = ""
}

The “Endpoint” above came from the details page of this Object Store

/content/images/2025/11/upcloud-73.png

Now I can do a migrated

builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ export AWS_SECRET_ACCESS_KEY='xxxx/xxxxxxxxxxxxxxxxx/xxxxxxxxxxxxxx/xxxx'
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ export AWS_ACCESS_KEY_ID='AKIA12F2143D3BC35116'
builder@DESKTOP-QADGF36:~/Workspaces/upcloudTF$ tofu init -migrate-state

Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes


Successfully configured the backend "s3"! OpenTofu will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Reusing previous version of upcloudltd/upcloud from the dependency lock file
- Using previously-installed upcloudltd/upcloud v5.29.1

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

I can see the file living now in the bucket:

/content/images/2025/11/upcloud-74.png

At some point, one would want to move this into a pipeline or service. Having the state file live in a durable cloud-based object store is the right way to store it and reduces dependence on a local machine. Additionally, any secrets created by Terraform will be protected in file (as opposed to checked in to a GIT repo as a .tfstate file).

The easy way

We did all this in the very off-the-shelf tofu/terraform way.

UpCloud has made this WAY easier by way of some developer tools:

/content/images/2025/11/upcloud-75.png

Where I can pick a type

/content/images/2025/11/upcloud-76.png

like my initial storage account

/content/images/2025/11/upcloud-77.png

And get the basic Terraform (HCL) output:

/content/images/2025/11/upcloud-78.png

Free Stuff

UpCloud has what they call “UpCloud Essentials” which are some zero cost offerings:

/content/images/2025/11/upcloud-83.png

I haven’t seen a free VPN gateway before (save for a GCP IAP tunnel which is temporary). Let’s create one.

First I need an SDN router (which is free, but needs to exist)

/content/images/2025/11/upcloud-84.png

I’ll then attach the SDN router to my existing “My Network”

/content/images/2025/11/upcloud-85.png

I can now select that and the region (DE-FRA1)

/content/images/2025/11/upcloud-86.png

I’ll select a plan (or leave it as the free one) and give my gateway a name, then click “Create VPN Gateway”

/content/images/2025/11/upcloud-87.png

While that creates

/content/images/2025/11/upcloud-88.png

I’ll create a new VM, but this time I will not give it a public IP and instead attach it to my Software Defined Network (SDN) Private Network

/content/images/2025/11/upcloud-89.png

About

Upcloud has been around since 2011, launching to the public in 2012. Joel Pihlajamaa, CTO and Founder had spun it off from his prior venture, Sigmatic which is a shared web hosting provider.

The About Page covers what has been new each year

/content/images/2025/11/upcloud-90.png

And has some of the most badass C-suite profile images I’ve seen on a company website

/content/images/2025/11/upcloud-91.png

However, once I created it, I realized this VPN setup is a peering tunnel, not something I can really use (I was hoping for an OpenVPN profile that I could instantiate).

Wishlist

I’m okay that the VPN isn’t really usable as my router just handles L2TP, PPTP and OpenVPN

/content/images/2025/11/upcloud-92.png

I consider that a me-problem.

Budgets

However, I really wish UpCloud had some kind of Billing alerts (most clouds call them Budgets).

I can view my usage just fine

/content/images/2025/11/upcloud-93.png

And I can setup automatic payments

/content/images/2025/11/upcloud-94.png

But there is nothing akin to Budgets in AWS

/content/images/2025/11/upcloud-97.png

And GCP

/content/images/2025/11/upcloud-96.png

Or Budgets/Cost Alerts in Azure

/content/images/2025/11/upcloud-95.png

Serverless

Most things seemed billed by the hour, but one feature I use in all the cloud are serverless containerized functions that should be per-second or per-minute at most. Namely, Cloud Functions (or Azure Logic Functions) in Azure, Lambdas in AWS and Cloud Run functions in GCP. They have utility and short of setting it up in a Kubernetes cluster (e.g. OpenFaaS, Kubeless, etc), I don’t see an equivalent in UpCloud

Databases

I’ll just go out and say it - if you offer Windows Server, you really aught to offer SQL Server, even if it’s SQL Server standard. This will capture your Windows Workloads in a basic way.

Today, we just have PostgreSQL and MySQL (MariaDB). I’ve said it before, and I’ll say it again, I don’t put Keystores in the same category (be it Valkey or Redis)

/content/images/2025/11/upcloud-98.png

Summary

Today we dug into OpenTofu Infrastructure as Code, creating terraform for our UpCloud instance. We imported the database and network then created the Object Storage using code. The Object Store was then used as a backend for remote state file management - offloading the important part of our state file to durable cloud storage.

After handling the standard import, I showed “the easy way” with their TF export feature before looking at the “free stuff”. I wrapped with a Wishlist (namely Budgets and Serverless options).

Overall, I really like UpCloud. To make the referral link I had to put real funds in and I noticed that night I got an email saying based on current usage my services would last roughly 18days

/content/images/2025/11/upcloud-99.png

That is kind of a budget - reverse of what I imagined. Namely, tell me when I run out versus tell me when I exceed spend. But that could be one way to cover the need to be aware of cloud spend.

I would recommend at least doing a trial for free.

UpCloud OpenTofu terraform Kubernetes Clouds

Have something to add? Feedback? You can use the feedback form

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes