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-bv StorageClass
  • 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-bv StorageClass 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