Running Kubernetes for Under $10/Month with UpCloud

Learn how to deploy a managed Kubernetes cluster on UpCloud for under $10/month using OpenTofu. Complete with private networking, NAT gateway, and production ready configuration.

Running Kubernetes for Under $10/Month

You've seen the bills. A managed Kubernetes control plane alone can run you $50-70/month before you've even deployed a single pod. For a home project, a learning environment, or a small production service running 24/7, that's hard to justify.

With UpCloud's pricing model and OpenTofu (the opensource fork of Terraform), you can spin up a fully managed Kubernetes cluster with private networking, NAT gateway, and encrypted storage for around $6/month. Yes, just $6 for a fully managed kubernetes cluster.

By the end of this tutorial, you'll have a working cluster defined entirely as code, ready to deploy services or learn Kubernetes without breaking the bank.

What You're Building

This isn't a toy setup. You'll get:

  • A managed Kubernetes cluster with a single worker node
  • Private networking with Software Defined Networking (SDN)
  • NAT gateway for outbound traffic from private nodes
  • Storage encryption enabled on all disks
  • Everything defined in OpenTofu code

The total monthly cost? Approximately $6, depending on your zone.

Why OpenTofu Instead of Terraform?

If you've been using Terraform, you know the drill, write code, run terraform init/plan/apply, watch resources spin up. OpenTofu is a drop-in replacement driven by the Linux Foundation. Same design, same ecosystem, thousands of providers and modules you can use today.

The difference? It's open source. If OpenTofu is the first tool you learn, you've essentially learned Terraform for free. And if you've been waiting for a reason to switch, this is as low friction as it gets.

Prerequisites

Before we start, you'll need:

  1. OpenTofu v1.15.0+ (or Terraform if you prefer)
  2. An UpCloud account with API token enabled
  3. UpCloud CLI utility upctl
  4. kubectl to connect to the cluster
  5. Basic familiarity with command line tools

You'll need to create an UpCloud account and API key. New accounts sometimes come with free credits, which means you might not pay anything for this entire tutorial. Otherwise, a $10 top-up is more than enough.

Get your API token from UpCloud Hub → Settings → API Keys.

Project Structure

Create your project directory and initialize the files:

mkdir k8s-upcloud
cd k8s-upcloud
touch backend.tf          # Local state configuration
touch main.tf             # Resource definitions
touch outputs.tf          # Output values
touch provider.tf         # Provider configuration
touch terraform.tfvars    # Default variable values
touch variables.tf        # Variable definitions
touch versions.tf         # Version constraints

Let's write some code.

Step 1: Set Up Environment

First, export your UpCloud API token as an environment variable:

export UPCLOUD_TOKEN="your-api-token-here"

This lets OpenTofu authenticate without hardcoding secrets in your files or Git repository.

Step 2: Provider and Version Setup

Start with versions.tf to lock down your tools:

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    upcloud = {
      source  = "UpCloudLtd/upcloud"
      version = "~> 5.43"
    }
  }
}

This sets the versions and providers, OpenTofu 1.15.0 or newer, plus the UpCloud provider.

Then provider.tf for authentication:

provider "upcloud" {
  # API token from UPCLOUD_TOKEN environment variable
}

Leaving it empty means it pulls the API key from the UPCLOUD_TOKEN environment variable. No secrets in code.

Step 3: Backend Configuration

Create backend.tf to tell OpenTofu where to store state:

terraform {
  backend "local" {
    path = "terraform.tfstate"
  }
}

This creates a local state file tracking all your resources. The state contains sensitive information, so keep it safe and never share it publicly.

Step 4: Define Your Variables

The variables.tf file defines what you can customize. Here are the core variables:

variable "name" {
  description = "Name of the UpCloud Managed Kubernetes cluster."
  type        = string
}

variable "zone" {
  description = "UpCloud zone for the network and cluster."
  type        = string
  default     = "pl-waw1"
}

variable "k8s_version" {
  description = "Kubernetes version."
  type        = string
  default     = "1.35"
}

variable "plan" {
  description = "Control plane plan"
  type        = string
  default     = "dev-md"
  validation {
    condition     = contains(["dev-md", "prod-md", "prod-md-ha"], var.plan)
    error_message = "Plan must be dev-md, prod-md or prod-md-ha"
  }
}

These are mostly self explanatory. name gets prepended to all resources. zone picks the datacenter—pl-waw1 is Warsaw, but choose what's closest to your users. k8s_version is straightforward. And plan is the control plane tier: dev-md is free (perfect for testing), prod-md adds redundancy, and prod-md-ha is full high availability.

That validation block is a nice touch—it stops you from typing something silly like super-cool-plan and getting a cryptic API error later. You'll get a clear message instead.

The dev-md plan is free for the control plane. That's the secret to keeping costs down.

Now for the nodepools:

variable "nodepools" {
  description = "Map of node groups to create in the cluster"
  type = map(object({
    anti_affinity          = bool
    utility_network_access = bool
    node_count             = number
    plan                   = string
    storage_encryption     = string
    labels                 = map(string)
    taints = list(object({
      key    = string
      value  = string
      effect = string
    }))
  }))

  default = {
    "default-nodepool" = {
      anti_affinity          = true
      utility_network_access = true
      storage_encryption     = "data-at-rest"
      node_count             = 1
      plan                   = "STARTER-1xCPU-2GB"
      labels = {
        "workload" = "general"
      }
      taints = []
    },
  }
}

variable "private_nodepools" {
  description = "Whether the node groups should be private (spins up a NAT gateway for outbound access)"
  type        = bool
  default     = true
}

The private_nodepools variable decides if your nodes sit behind a NAT gateway (private) or have public IPs. The default nodepool uses a STARTER-1xCPU-2GB plan 1 vCPU and 2 GB RAM—which keeps costs minimal. Feel free to scale up for more pods or add more nodes for high availability.

Step 5: Resource Definitions

The main.tf file ties everything together. Start with the router:

resource "upcloud_router" "this" {
  name = "${var.name}-router"
}

Simple enough. This creates a virtual router for your Kubernetes network.

Next, the NAT gateway (conditional):

resource "upcloud_gateway" "this" {
  count    = var.private_nodepools ? 1 : 0
  name     = "${var.name}-nat-gw"
  zone     = var.zone
  plan     = var.gateway_plan
  features = ["nat"]

  router {
    id = upcloud_router.this.id
  }
}

Notice the count? This only creates the NAT gateway if private_nodepools is true. The gateway lets your private nodes (those without public IPs) reach the internet—mostly for pulling container images from Docker Hub. Traffic flows: node → NAT gateway → router → internet.

Now the network:

resource "upcloud_network" "this" {
  name   = "${var.name}-net"
  zone   = var.zone
  router = upcloud_router.this.id

  ip_network {
    address            = var.network_cidr
    dhcp               = true
    family             = "IPv4"
    dhcp_default_route = var.private_nodepools ? true : false
  }
}

This is your cluster's private Software Defined Network (SDN), managed by UpCloud. The ip_network.address sets the CIDR block. dhcp = true means nodes get IPs automatically. And dhcp_default_route? When your nodes are private, this points their default route to the NAT gateway so they can reach the outside world.

The main event, the Kubernetes cluster:

resource "upcloud_kubernetes_cluster" "this" {
  name                    = var.name
  zone                    = var.zone
  network                 = upcloud_network.this.id
  labels                  = merge(var.labels, { cluster : var.name })
  plan                    = var.plan
  version                 = var.k8s_version
  private_node_groups     = var.private_nodepools
  control_plane_ip_filter = var.control_plane_ip_filter
  storage_encryption      = var.storage_encryption
  upgrade_strategy_type   = var.upgrade_strategy
}

This creates the managed Kubernetes control plane. It ties together the network, applies your chosen plan and version, and sets security settings. private_node_groups tells UpCloud to spin up worker nodes without public IPs. control_plane_ip_filter restricts who can talk to the API server. storage_encryption and upgrade_strategy_type do what you'd expect.

Finally, the node groups:

resource "upcloud_kubernetes_node_group" "this" {
  for_each = var.nodepools

  cluster                = upcloud_kubernetes_cluster.this.id
  name                   = each.key
  node_count             = each.value.node_count
  plan                   = each.value.plan
  anti_affinity          = each.value.anti_affinity
  storage_encryption     = var.storage_encryption
  utility_network_access = each.value.utility_network_access

  labels = each.value.labels

  dynamic "taint" {
    for_each = each.value.taints
    content {
      key    = taint.value.key
      value  = taint.value.value
      effect = taint.value.effect
    }
  }
}

This creates the actual nodepools using the variables we defined. The nodepools connect to the cluster via upcloud_kubernetes_cluster.this.id. You can define multiple nodepools for different workloads, but remember, more nodes mean higher costs.

Step 6: Outputs

Create outputs.tf with these values:

output "cluster_id" {
  description = "The ID of the Kubernetes cluster."
  value       = upcloud_kubernetes_cluster.this.id
}

output "cluster_name" {
  description = "The name of the Kubernetes cluster."
  value       = var.name
}

These give you meaningful outputs once the cluster is ready. You'll need the cluster_id to fetch the kubeconfig and connect to your cluster.

Step 7: Configure Variables

The terraform.tfvars file sets actual values for your resources:

# Cluster configuration
name = "demo-cluster"
k8s_version = "1.35"
plan = "dev-md"

# Network configuration
network_cidr = "172.10.1.0/24"

# Nodepool configuration
nodepools = {
  "general" = {
    plan                   = "STARTER-1xCPU-2GB"
    node_count             = 1
    anti_affinity          = true
    utility_network_access = true
    storage_encryption     = "data-at-rest"
    labels = {
      "workload" = "general"
    }
    taints = []
  }
}

# Cluster settings
private_nodepools = true
gateway_plan = "essentials"
storage_encryption = "data-at-rest"
upgrade_strategy = "rolling-update"
control_plane_ip_filter = ["0.0.0.0/0"]

# Labels
labels = {
  "workload" = "general"
}

This is where the rubber meets the road. network_cidr sets your private network range 172.10.1.0/24 gives you 254 IPs.

The nodepools block defines your worker nodes. general is just a label you could use cpu-optimized, memory-heavy, or whatever makes sense. Inside, plan is the node size (STARTER-1xCPU-2GB is the cheapest). node_count is how many you want. anti_affinity = true spreads nodes across different physical hosts, so if one machine dies, you don't lose everything at once. utility_network_access lets nodes talk to UpCloud services over the private network. And labels? Those are tags you can use to tell Kubernetes which pods should run on which nodes.

Then the cluster wide settings. private_nodepools = true means your worker nodes get no public IPs. They're hidden behind the NAT gateway. gateway_plan picks the NAT tier; essentials is the budget option. storage_encryption turns on disk encryption. upgrade_strategy rolls out updates one node at a time instead of nuking everything at once. control_plane_ip_filter controls who can talk to your Kubernetes API—0.0.0.0/0 means anyone, which is fine for testing but you'll want to lock this down in production.

The STARTER-1xCPU-2GB node runs about $6/month. The essentials NAT gateway is free. You're looking at roughly $6/month total.

Step 8: Initialize and Deploy

Now for the actual work:

cd k8s-upcloud
tofu init
tofu plan
tofu apply

OpenTofu will show you exactly what it's about to create:

  • 1 Kubernetes cluster (control plane)
  • 1 node group with 1 worker node
  • 1 network
  • 1 router
  • 1 NAT gateway

It'll take a few minutes and show progress as resources are created. Once done, you'll see the cluster_id and cluster_name outputs in green. That's your cue to connect.

Step 9: Connect to Your Cluster

You can grab the kubeconfig from the UpCloud dashboard, or use the CLI:

# Download kubeconfig from UpCloud and save it
upctl kubernetes config $(tofu output cluster_id) --write kubeconfig.yaml
export KUBECONFIG=kubeconfig.yaml
kubectl get nodes

You should see your worker node in Ready status. You now have a fully managed Kubernetes cluster.

Cost Breakdown

Let's talk numbers (prices vary by zone, these are approximate):

Resource Plan Monthly Cost
Control plane dev-md Free
Worker node STARTER-1xCPU-2GB $6
NAT Gateway essentials Free
Storage (system disk) Included Free
Total $6

Compare that to managed Kubernetes offerings that charge $50+ just for the control plane, and the value becomes obvious.

Tradeoffs and Limitations

This isn't a free lunch. Here's what you're giving up:

  • Single node: The STARTER plan gives you one worker node. No high availability.
  • No auto scaling: You scale manually by changing node_count and running tofu apply.
  • Zone bound: Everything lives in one zone. Cross zone redundancy requires a different setup.
  • No Mission Critical Services: You will not be using this for mission critical services in production.
  • Dev plan control plane: The dev-md plan is fine for development, but production workloads should use prod-md or prod-md-ha.

If you need production grade resilience, bump up to prod-md-ha and add more nodes. The cost goes up, but you're still looking at a fraction of what major cloud providers charge.

Cleanup When You're Done

If you're not running the cluster longterm, destroy the resources when you're finished:

tofu destroy

This removes everything, nodepools, cluster, network, router, and NAT gateway. Your bill stops accruing immediately.

What's Next

You now have a working Kubernetes cluster for under $10/month. From here, you can:

  • Swap STARTER-1xCPU-2GB for a 2-core node like STARTER-2xCPU-2GB (around $8/month). You're still under $10 with the NAT gateway, and you get double the compute.
  • Increase node_count to 2 or 3 and enable anti-affinity for high availability. Spread nodes across different hosts for fault tolerance.
  • Add a free UpCloud load balancer for ingress.
  • Turn these files into a reusable OpenTofu module. Wrap the resources, expose inputs for name, zone, nodepools, and call it from multiple environments. One module, as many clusters as you need.
  • Configure CI/CD to deploy with tofu apply.
  • Experiment with private nodepools and NAT gateway routing.
  • Deploy some services in your cluster.

The infrastructure is yours, use and expand it however you want.

The Bottom Line

You don't need to spend hundreds to run Kubernetes. With UpCloud's pricing and a fully managed cluster, you're looking at costs within reach of any budget. The code is portable with OpenTofu, the costs are predictable, and when you're done, everything disappears with a single command.

That $6 cluster? It's enough to learn Kubernetes, host a small service, or experiment with microservices without the guilt of a surprise bill. The question isn't whether you can afford to run Kubernetes anymore. It's what you'll build first.

Complete Code

The full source code is available here.