Running Kubernetes on OCI Free Tier (Part 1): Provisioning with Terraform
I already run a homelab Kubernetes cluster on Proxmox with Talos Linux, but I wanted something outside my home network. An external cluster that could monitor my homelab from a different vantage point, give me a second deployment target, and let me practice multi-cluster GitOps with real infrastructure differences. The catch: I didn’t want to pay for it.
Oracle Cloud’s Always Free Tier turned out to be surprisingly capable for this. This is Part 1 of a 3-part series covering the full setup. Here I’ll walk through what OCI gives you for free, how I provisioned everything with Terraform, and the gotchas I hit along the way.
What OCI Gives You for Free
The Always Free Tier includes enough to run a legitimate Kubernetes cluster:
- OKE Basic Cluster — a fully managed Kubernetes control plane (Oracle handles upgrades, etcd, the API server)
- 2x VM.Standard.A1.Flex — ARM64 (Ampere) compute instances. I configured mine with 2 OCPUs and 12 GB RAM each, which fits within the free tier’s total allocation of 4 OCPUs / 24 GB RAM for A1 shapes
- OCI Block Volumes — persistent storage with automatic provisioning via the
oci-bvStorageClass - Flexible Load Balancer — 10 Mbps bandwidth, enough for lightweight apps
- VCN (Virtual Cloud Network) — public and private subnets, security lists, route tables
That’s a managed Kubernetes cluster with two worker nodes, persistent storage, and a load balancer. For zero dollars.
The Terraform Setup
I manage all OCI infrastructure through Terraform, applied via Gitea Actions CI/CD. The pipeline is straightforward:
PR opened -> terraform plan (posted as PR comment)
PR merged -> terraform apply (auto-applied)
Daily cron -> drift detection
VCN and Networking
The network layout is a standard OCI pattern — a VCN with public and private subnets. Worker nodes sit in the private subnet. The load balancer and Kubernetes API endpoint live in the public subnet.
resource "oci_core_vcn" "k8s_vcn" {
compartment_id = var.compartment_id
display_name = "oci-k8s-vcn"
cidr_blocks = ["10.0.0.0/16"]
dns_label = "k8svcn"
}
resource "oci_core_subnet" "node_subnet" {
compartment_id = var.compartment_id
vcn_id = oci_core_vcn.k8s_vcn.id
display_name = "node-subnet"
cidr_block = "10.0.10.0/24"
prohibit_public_ip_on_vnic = true
dns_label = "nodes"
security_list_ids = [oci_core_security_list.node_sec_list.id]
route_table_id = oci_core_route_table.private_rt.id
}
resource "oci_core_subnet" "lb_subnet" {
compartment_id = var.compartment_id
vcn_id = oci_core_vcn.k8s_vcn.id
display_name = "lb-subnet"
cidr_block = "10.0.20.0/24"
dns_label = "loadbalancers"
security_list_ids = [oci_core_security_list.lb_sec_list.id]
}
OKE Cluster
The cluster itself is an OKE BASIC_CLUSTER. This is the free tier — you get a managed control plane but with some restrictions (more on those later).
resource "oci_containerengine_cluster" "k8s_cluster" {
compartment_id = var.compartment_id
kubernetes_version = "v1.32.1"
name = "oci-free-cluster"
vcn_id = oci_core_vcn.k8s_vcn.id
type = "BASIC_CLUSTER"
cluster_pod_network_options {
cni_type = "FLANNEL_OVERLAY"
}
endpoint_config {
is_public_ip_enabled = true
subnet_id = oci_core_subnet.api_endpoint_subnet.id
}
options {
service_lb_subnet_ids = [oci_core_subnet.lb_subnet.id]
}
}
Note the cni_type = "FLANNEL_OVERLAY" — this isn’t optional. BASIC_CLUSTER only supports Flannel. I’ll get into what that means in a minute.
Node Pool (ARM64)
resource "oci_containerengine_node_pool" "arm_pool" {
cluster_id = oci_containerengine_cluster.k8s_cluster.id
compartment_id = var.compartment_id
kubernetes_version = "v1.32.1"
name = "arm-pool"
node_shape = "VM.Standard.A1.Flex"
node_shape_config {
ocpus = 2
memory_in_gbs = 12
}
node_config_details {
size = 2
placement_configs {
availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
subnet_id = oci_core_subnet.node_subnet.id
}
}
node_source_details {
image_id = data.oci_core_images.oracle_linux_arm.images[0].id
source_type = "IMAGE"
}
}
Each node gets 2 OCPUs and 12 GB RAM. Two nodes uses the full free tier allocation (4 OCPUs / 24 GB total). The nodes run Oracle Linux 8 on ARM64.
The Terraform + Flux Boundary
Once Terraform provisions the cluster, its job is done. Everything that runs inside the cluster is managed by Flux CD, which syncs from a separate Git repository:
Terraform (johnylab-infra) Flux (johnylab-flux)
| |
|-- OKE cluster + VCN + node pool |-- flux-system (bootstrap)
|-- DNS: tunnel config + records |-- infra-oci (cert-manager, nginx, external-dns)
|-- Secrets: tunnel credentials |-- cert-manager-issuers-oci
| |-- cloudflare-oci (tunnel connector)
Gitea CI: |-- apps-oci (uptimekuma)
PR -> plan |
Merge -> auto-apply Git push -> Flux auto-reconcile
Daily -> drift detection
Two repos, two pipelines. Infrastructure changes go through Terraform PR review. Workload changes go through Git push and Flux reconciliation. Both use SOPS + Age for secrets encryption.
Free Tier Gotchas
This is the section I wish existed when I started.
Flannel Is Your Only CNI Option
OKE BASIC_CLUSTER mandates Flannel Overlay. No Cilium, no Calico, no eBPF. You get iptables-based kube-proxy and VXLAN encapsulation for cross-node traffic. Coming from Cilium on my homelab (with sockLB, direct routing, and Hubble observability), this felt like going back in time.
The practical impact: no NetworkPolicy support (Flannel doesn’t implement it), no flow-level observability, and O(n) iptables traversal for service routing. For a small cluster running monitoring tools, it’s fine. But don’t expect Cilium-level performance or security.
To run Cilium on OCI, you’d need self-managed VMs with kubeadm or Talos where you control the CNI bootstrap. But then you’re spending your free compute quota on control plane nodes and managing upgrades yourself. Not worth it for a free tier setup.
ARM Architecture Surprises
Most popular container images ship multi-arch builds now, so linux/arm64 generally just works. But I got burned a couple of times by Helm charts that reference x86-only images as defaults. Always check the image manifest before deploying something new:
docker manifest inspect <image>:<tag> | jq '.manifests[].platform'
If you don’t see arm64 in the output, it won’t run on your nodes.
Region Availability Is a Lottery
Always Free ARM shapes are capacity-constrained. Some regions have zero availability for weeks. I tried a few regions before landing on sa-bogota-1 (Bogota, Colombia), which had capacity when I needed it. If your Terraform apply fails with a capacity error, try a different region. There’s no way to reserve or waitlist.
Block Volumes Ignore Your Size Request
If your PVC requests 2 Gi, OCI provisions 50 Gi anyway. That’s the minimum Block Volume size. No cost impact on the free tier, but it caught me off guard the first time I looked at the OCI console and saw 50 Gi volumes everywhere.
The Load Balancer Is Slow to Provision
OCI’s Flexible Load Balancer takes 1-3 minutes to provision. Coming from MetalLB on the homelab (where IPs are assigned via ARP in milliseconds), the first deploy felt broken. It’s not — just slow. The nginx-ingress controller needs these annotations to stay within free tier:
service:
annotations:
oci.oraclecloud.com/load-balancer-type: "lb"
service.beta.kubernetes.io/oci-load-balancer-shape: "flexible"
service.beta.kubernetes.io/oci-load-balancer-shape-flex-min: "10"
service.beta.kubernetes.io/oci-load-balancer-shape-flex-max: "10"
10 Mbps Bandwidth Cap
The free flexible LB is capped at 10 Mbps. For monitoring dashboards and lightweight web apps, you’ll never notice. Don’t try to stream media through it.
What Actually Works Well
Despite the limitations, there’s a lot to like:
- OKE Basic is real Kubernetes. Managed control plane, node pools, all standard APIs. kubectl works exactly like you’d expect. You get automatic control plane upgrades without managing etcd backups.
- ARM nodes are solid. The Ampere A1 shapes are performant and energy-efficient. I haven’t hit a single ARM-specific runtime issue with standard workloads (nginx-ingress, cert-manager, external-dns, Uptime Kuma).
- Block Volumes just work. The
oci-bvStorageClass handles provisioning automatically. Attach, mount, done. - Cloudflare Tunnel is the move. No inbound ports, no firewall rules, no public IPs on worker nodes. A cloudflared deployment creates an outbound tunnel to Cloudflare’s edge, and you get zero-trust access to any service in the cluster.
Up Next
In Part 2, I’ll cover the multi-cluster GitOps architecture — how one Flux repository manages both my homelab and OCI cluster using kustomize overlays, and how the same base manifests adapt to fundamentally different infrastructure (eBPF vs iptables, x86 vs ARM, MetalLB vs cloud LB).
Infrastructure: Terraform + OCI Provider | Kubernetes: OKE v1.32 on ARM64 | CI/CD: Gitea Actions | Secrets: SOPS + Age