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.
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:
- OpenTofu v1.15.0+ (or Terraform if you prefer)
- An UpCloud account with API token enabled
- UpCloud CLI utility
upctl kubectlto connect to the cluster- 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
STARTERplan gives you one worker node. No high availability. - No auto scaling: You scale manually by changing
node_countand runningtofu 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-mdplan is fine for development, but production workloads should useprod-mdorprod-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-2GBfor a 2-core node likeSTARTER-2xCPU-2GB(around $8/month). You're still under $10 with the NAT gateway, and you get double the compute. - Increase
node_countto 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.