Tools: Containers, The Wrong Way, For Always-Free Fun and Profit

Tools: Containers, The Wrong Way, For Always-Free Fun and Profit

Prelude: Oracle Cloud's Always-Free Tier

The VM

Free Kubernetes. Kinda.

The Part Where You Discover You Have a Long-Lived Server Problem

The Thought That Won't Go Away

The --privileged flag

The Image

Oracle Linux's New Job Description

The Part That Makes It Worth It

Is This "Correct"? Oracle Cloud has an always-free tier. Not a trial. Not "free for 12 months." Always free. Four ARM-based cores, 24GB of RAM, 200GB of storage. For nothing. Forever. Their Ampere Altra processors are genuinely good silicon. People benchmark these against x86 and come away impressed. And the ARM64 ecosystem is in good shape — most container images you'll actually use (your databases, your ingress controllers, your monitoring stack) have ARM64 builds. The days of "does this even run on ARM" are mostly behind us. The fact that Oracle is giving this hardware away to get people onto their platform is, frankly, their problem. If you have any kind of homelab itch — self-hosted apps, a personal Kubernetes playground, a place to run things you don't want living on your laptop — you should have one of these VMs. The barrier is a credit card for verification (they won't charge it) and about twenty minutes. Once you have an account, getting a VM up is a few clicks in the OCI console: You'll also want a separate block volume: Storage → Block Volumes → Create, 150GB, attach it to your instance. If you're Terraform-inclined, it looks something like this: K3s goes up in about thirty seconds: This gives you a single-node Kubernetes cluster. One control plane, one worker, same machine. It's not highly available — if the VM goes down, your cluster goes down. If you want something closer to HA, OCI's free tier technically allows you to split your 4 OCPUs and 24GB across multiple VMs — you could do three VMs at 1 OCPU / 8GB each (control pane gets 2 OCPUs) and run a proper multi-node setup with an embedded etcd quorum. That's a valid path. This post is about the single-node case because I don't care about any of that and neither should you for a homelab. The point is: Kubernetes, on ARM, for free. This feels unreasonable but here we are. Here's the thing about a cloud VM: you don't touch it daily like your laptop or your desktop. You SSH into it when something breaks, or when you need to add a new service. If you're running Kubernetes, you SSH in even less — kubectl handles most of it and you rarely need to touch the node directly. But the machine keeps running. Packages drift out of date. You hotfixed something at 2am while sick and half-forgot. You're not sure which K3s version you're actually on or whether it's the one you meant to be running. The machine accumulates entropy in the background while you're not looking. This is the long-lived server problem and it's why configuration management tools exist. The standard answer: write an Ansible playbook, push it to git, have the machine pull and run it on a schedule. Define the desired state, let Ansible converge to it. So you write the playbook. It works. You commit it to git, set up a cron job on the VM to pull and run it every five minutes, and declare victory. And then you start noticing the friction. Ansible is... fine for keeping a server configured. But what you're increasingly fighting is a different problem — you want something closer to what apt upgrade does, but for your entire environment. Not "apply these tasks," but "this is the version of the world I want, please be it." Ansible can do it but it's not really what it's designed for, and you can feel the difference. The playbook describes a path to the desired state, not the desired state itself. Those are subtly different things and the difference starts to matter when you're maintaining the playbook over months. The other problem: there's no local testing story. You make a change, push to git, wait for the cron job, SSH in to see if anything broke. Your laptop is not a Linux ARM server with K3s running on it. You can't just run the playbook locally and catch problems before they hit the VM. I work with Kubernetes other every day. Kubernetes runs containers. Containers are versioned, immutable artifacts — you build one, push it to a registry, pull it somewhere else, it behaves exactly the same. You can run it locally to test it. You update it by pushing a new version. Rolling back means pulling an old tag. Everything about this model is better than a long-lived server managed by a configuration management tool. And somewhere around the third time I was debugging why the playbook had done something unexpected, I had the thought: why can't I just containerize this entire problem? K3s is not a web app. K3s needs to interact with the host kernel — manipulate iptables, create network interfaces, manage cgroups, set up container networking. You can't just docker run k3s and expect it to work. --privileged is Docker and Podman's "I know what I'm doing" flag. It gives the container essentially full access to the host kernel — every capability, every device, no security filtering. It's the nuclear option. And it turns out, it's also the officially documented way to run K3s in a container. From K3s's own docs: That's in the K3s documentation. Not a workaround. Not a hack. --privileged is how you do this. Adding --network host gives the container the host's network stack directly, which K3s needs to set up its own networking correctly: Does this feel wrong? Yes. A privileged container with host networking is basically just a process. Security people will say this. They're not wrong. But it's a process defined by an OCI image. Which means it has a version tag. A Dockerfile in a git repo. A build pipeline. And — crucially — you can docker run it on your laptop and test it before it touches anything real. The trade is: you give up container isolation (which K3s was going to make you give up anyway to do its job) and you get everything else that comes with the container model. That's a good trade. rancher/k3s as the base means K3s is pre-installed at a specific pinned version. K3s has an auto-deploy feature: anything in /var/lib/rancher/k3s/server/manifests/ gets automatically applied when K3s starts. Drop ArgoCD's install manifest in there, point ArgoCD at a git repo, and everything else deploys itself from git. The image doesn't need to know about any of it. The entrypoint starts things in the right order: A small shell script runs at VM startup (wired up via systemd) and does exactly this: Five commands. Run once on first boot. That is the entire configuration management story for Oracle Linux. After that, Oracle Linux's responsibilities are: That's it. Oracle Linux is now a very fancy process supervisor. It doesn't know what's running inside the container and doesn't need to. You can test it locally. Your entire server environment, running on your laptop. K3s comes up, deploys things, you poke at it with kubectl. You find the problem before it touches the VM. This was impossible with Ansible. Change anything in the Dockerfile — update the K3s version, add a package, update a manifest — build a new image tag, push to your registry. A systemd timer on the VM checks for image updates at 3am: No SSH. No playbook run. No waiting for convergence. The boot volume can die and you don't care. Everything stateful — K3s cluster state, persistent volumes, all of it — lives at /data on the separate block volume, mounted into the container. Oracle can update or replace the boot volume. Run the startup script, the container comes back up, finds its state on /data, continues exactly where it left off. No. Using --privileged is not what containers are designed for. Running K3s inside a container on a VM where you could just run K3s directly is adding a layer that doesn't need to be there from a pure architecture standpoint. But "architecturally pure" and "actually useful for your situation" are different questions. This approach gives you a reproducible, testable, versionable environment on a server you interact with twice a year. The feedback loop goes from "push to git, wait for cron, SSH in and hope" to "docker run on your laptop, push when it works." For a free ARM Kubernetes node that you barely touch, that's the right trade. Also, --privileged is in the K3s docs. So maybe it's fine. Cover photo by Scott Rodgerson on Unsplash Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ resource "oci_core_instance" "my-arm-instance" { shape = "VM.Standard.A1.Flex" shape_config { ocpus = 4 memory_in_gbs = 24 } source_details { source_type = "image" source_id = var.oracle_linux_minimal_image_id boot_volume_size_in_gbs = 50 # OCI minimum } } resource "oci_core_volume" "data" { # this is where anything you'd like to still exist tomorrow lives size_in_gbs = 150 } resource "oci_core_instance" "my-arm-instance" { shape = "VM.Standard.A1.Flex" shape_config { ocpus = 4 memory_in_gbs = 24 } source_details { source_type = "image" source_id = var.oracle_linux_minimal_image_id boot_volume_size_in_gbs = 50 # OCI minimum } } resource "oci_core_volume" "data" { # this is where anything you'd like to still exist tomorrow lives size_in_gbs = 150 } resource "oci_core_instance" "my-arm-instance" { shape = "VM.Standard.A1.Flex" shape_config { ocpus = 4 memory_in_gbs = 24 } source_details { source_type = "image" source_id = var.oracle_linux_minimal_image_id boot_volume_size_in_gbs = 50 # OCI minimum } } resource "oci_core_volume" "data" { # this is where anything you'd like to still exist tomorrow lives size_in_gbs = 150 } -weight: 500;">curl -sfL https://get.k3s.io | sh -weight: 500;">kubectl get nodes -weight: 500;">curl -sfL https://get.k3s.io | sh -weight: 500;">kubectl get nodes -weight: 500;">curl -sfL https://get.k3s.io | sh -weight: 500;">kubectl get nodes -weight: 500;">docker run \ --privileged \ --name k3s-server \ -p 6443:6443 \ -d rancher/k3s:v1.29.3-k3s1 \ server -weight: 500;">docker run \ --privileged \ --name k3s-server \ -p 6443:6443 \ -d rancher/k3s:v1.29.3-k3s1 \ server -weight: 500;">docker run \ --privileged \ --name k3s-server \ -p 6443:6443 \ -d rancher/k3s:v1.29.3-k3s1 \ server podman run -d \ --privileged \ --network host \ -v /data:/data \ ---weight: 500;">restart always \ ghcr.io/you/k3s-env:latest podman run -d \ --privileged \ --network host \ -v /data:/data \ ---weight: 500;">restart always \ ghcr.io/you/k3s-env:latest podman run -d \ --privileged \ --network host \ -v /data:/data \ ---weight: 500;">restart always \ ghcr.io/you/k3s-env:latest FROM rancher/k3s:v1.29.3-k3s1 # Whatever else you want running alongside K3s RUN -weight: 500;">apk add --no-cache tailscale # K3s auto-deploys anything placed here on startup COPY argocd--weight: 500;">install.yaml /var/lib/rancher/k3s/server/manifests/argocd.yaml COPY root-app.yaml /var/lib/rancher/k3s/server/manifests/root-app.yaml COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] FROM rancher/k3s:v1.29.3-k3s1 # Whatever else you want running alongside K3s RUN -weight: 500;">apk add --no-cache tailscale # K3s auto-deploys anything placed here on startup COPY argocd--weight: 500;">install.yaml /var/lib/rancher/k3s/server/manifests/argocd.yaml COPY root-app.yaml /var/lib/rancher/k3s/server/manifests/root-app.yaml COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] FROM rancher/k3s:v1.29.3-k3s1 # Whatever else you want running alongside K3s RUN -weight: 500;">apk add --no-cache tailscale # K3s auto-deploys anything placed here on startup COPY argocd--weight: 500;">install.yaml /var/lib/rancher/k3s/server/manifests/argocd.yaml COPY root-app.yaml /var/lib/rancher/k3s/server/manifests/root-app.yaml COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] #!/bin/bash # Secrets are in /data/env, written at startup source /data/env # Start networking, wait for it to be ready tailscaled --state=/data/tailscale.state & tailscale up --authkey=$TAILSCALE_AUTHKEY until tailscale -weight: 500;">status; do sleep 2; done # K3s in the foreground — keeps the container alive exec k3s server --data-dir=/data/rancher/k3s #!/bin/bash # Secrets are in /data/env, written at startup source /data/env # Start networking, wait for it to be ready tailscaled --state=/data/tailscale.state & tailscale up --authkey=$TAILSCALE_AUTHKEY until tailscale -weight: 500;">status; do sleep 2; done # K3s in the foreground — keeps the container alive exec k3s server --data-dir=/data/rancher/k3s #!/bin/bash # Secrets are in /data/env, written at startup source /data/env # Start networking, wait for it to be ready tailscaled --state=/data/tailscale.state & tailscale up --authkey=$TAILSCALE_AUTHKEY until tailscale -weight: 500;">status; do sleep 2; done # K3s in the foreground — keeps the container alive exec k3s server --data-dir=/data/rancher/k3s #!/bin/bash -weight: 500;">dnf -weight: 500;">install -y podman mkdir -p /data mount /dev/sdb /data cat > /data/env <<EOF TAILSCALE_AUTHKEY=your-key-here OTHER_SECRET=whatever EOF chmod 600 /data/env podman run -d \ --privileged \ --network host \ -v /data:/data \ --env-file /data/env \ ---weight: 500;">restart always \ --name k3s-env \ ghcr.io/you/k3s-env:latest #!/bin/bash -weight: 500;">dnf -weight: 500;">install -y podman mkdir -p /data mount /dev/sdb /data cat > /data/env <<EOF TAILSCALE_AUTHKEY=your-key-here OTHER_SECRET=whatever EOF chmod 600 /data/env podman run -d \ --privileged \ --network host \ -v /data:/data \ --env-file /data/env \ ---weight: 500;">restart always \ --name k3s-env \ ghcr.io/you/k3s-env:latest #!/bin/bash -weight: 500;">dnf -weight: 500;">install -y podman mkdir -p /data mount /dev/sdb /data cat > /data/env <<EOF TAILSCALE_AUTHKEY=your-key-here OTHER_SECRET=whatever EOF chmod 600 /data/env podman run -d \ --privileged \ --network host \ -v /data:/data \ --env-file /data/env \ ---weight: 500;">restart always \ --name k3s-env \ ghcr.io/you/k3s-env:latest -weight: 500;">docker run -it --privileged --network host \ -v /tmp/test-data:/data \ --env-file .env.test \ ghcr.io/you/k3s-env:latest -weight: 500;">docker run -it --privileged --network host \ -v /tmp/test-data:/data \ --env-file .env.test \ ghcr.io/you/k3s-env:latest -weight: 500;">docker run -it --privileged --network host \ -v /tmp/test-data:/data \ --env-file .env.test \ ghcr.io/you/k3s-env:latest podman pull ghcr.io/you/k3s-env:latest && podman -weight: 500;">restart k3s-env podman pull ghcr.io/you/k3s-env:latest && podman -weight: 500;">restart k3s-env podman pull ghcr.io/you/k3s-env:latest && podman -weight: 500;">restart k3s-env - Compute → Instances → Create Instance - Change the shape to VM.Standard.A1.Flex — that's the ARM one - Set OCPUs to 4 and memory to 24GB (max free allocation) - Pick your image. Oracle Linux if you're comfortable with -weight: 500;">dnf. Ubuntu Server if you're a -weight: 500;">apt person. Either works — both have Minimal variants that strip out a lot of packages out. And if you're about to do what I'm about to describe, you'll want the Minimal version. - Oracle Linux if you're comfortable with -weight: 500;">dnf. Ubuntu Server if you're a -weight: 500;">apt person. - Either works — both have Minimal variants that strip out a lot of packages out. And if you're about to do what I'm about to describe, you'll want the Minimal version. - Add your SSH key - Oracle Linux if you're comfortable with -weight: 500;">dnf. Ubuntu Server if you're a -weight: 500;">apt person. - Either works — both have Minimal variants that strip out a lot of packages out. And if you're about to do what I'm about to describe, you'll want the Minimal version. - Keeping Podman alive - Applying its own package updates at 3am via -weight: 500;">dnf-automatic (or unattended-upgrades on Ubuntu) — -weight: 500;">install it, -weight: 500;">enable the timer, forget about it