Catching an Oracle Free Tier ARM Instance
Oracle’s Always Free tier includes an ARM VM that outclasses anything else you can get for zero dollars. The catch: in most regions, clicking Create in the console returns Out of host capacity almost every time. Capacity does free up, but in unpredictable windows you will never hit by hand.
I stopped clicking and let a 67-line Python script poll the API from a cheap VPS. It caught an instance in under a day. This post has the script, the numbers from its run, and, the part most guides skip, what to do in the first hour after you win so you don’t lose it again.
First: the free tier shrank in June 2026
Almost every guide and README out there says the Always Free A1 allowance is 4 OCPUs and 24 GB of RAM. That’s stale. In June 2026 Oracle quietly halved it with no announcement: the current docs list 1,500 OCPU-hours and 9,000 GB-hours per month for the VM.Standard.A1.Flex shape.
A 30-day month is 720 hours, so that works out to 2 OCPUs and 12 GB of RAM running 24/7, with essentially no headroom. Size your instance accordingly:
- Request 2 OCPU / 12 GB, not the 4/24 every old tutorial tells you to.
- Don’t plan on a second A1 instance or a later resize; a single 2/12 machine already uses ~99% of the monthly allowance.
Still a very good machine for free: 2 Ampere cores and 12 GB of RAM runs a lot of Docker containers.
The script
Everything you need, in one file, runnable with uv thanks to inline dependency metadata:
# /// script
# requires-python = ">=3.12"
# dependencies = ["oci"]
# ///
import itertools, random, time
import oci
config = oci.config.from_file("~/.oci/config", "DEFAULT")
compute = oci.core.ComputeClient(config, timeout=(10, 60))
identity = oci.identity.IdentityClient(config)
TENANCY = config["tenancy"]
SUBNET_ID = "ocid1.subnet.oc1...." # your pre-created subnet
IMAGE_ID = "ocid1.image.oc1...." # aarch64 image in your region
SSH_KEY = open("/home/you/.ssh/oracle-arm.pub").read().strip()
NAME = "my-arm-box"
OCPUS = 2
MEMORY = 12
BOOT_GB = 200
existing = [i for i in compute.list_instances(compartment_id=TENANCY).data
if i.display_name == NAME and i.lifecycle_state not in ("TERMINATED", "TERMINATING")]
if existing:
print(f"instance already exists ({existing[0].lifecycle_state}), nothing to do", flush=True)
raise SystemExit(0)
ads = [ad.name for ad in
identity.list_availability_domains(compartment_id=TENANCY).data]
print("ADs:", ads, flush=True)
def details(ad):
return oci.core.models.LaunchInstanceDetails(
availability_domain=ad,
compartment_id=TENANCY,
display_name=NAME,
shape="VM.Standard.A1.Flex",
shape_config=oci.core.models.LaunchInstanceShapeConfigDetails(
ocpus=OCPUS, memory_in_gbs=MEMORY),
source_details=oci.core.models.InstanceSourceViaImageDetails(
image_id=IMAGE_ID,
boot_volume_size_in_gbs=BOOT_GB,
boot_volume_vpus_per_gb=10),
create_vnic_details=oci.core.models.CreateVnicDetails(
subnet_id=SUBNET_ID,
assign_public_ip=True,
assign_private_dns_record=True),
metadata={"ssh_authorized_keys": SSH_KEY},
is_pv_encryption_in_transit_enabled=True,
)
for n, ad in enumerate(itertools.cycle(ads), 1):
ts = time.strftime("%H:%M:%S")
try:
inst = compute.launch_instance(details(ad)).data
print(f"[{ts}] GOT IT: {inst.id}", flush=True)
break
except oci.exceptions.ServiceError as e:
print(f"[{ts}] #{n} {ad} -> {e.status} {e.code}: {e.message}", flush=True)
if e.status == 429:
time.sleep(300)
continue
if e.status not in (500, 502, 503):
raise
except oci.exceptions.RequestException as e:
print(f"[{ts}] #{n} {ad} -> network error: {type(e).__name__}: {e}", flush=True)
time.sleep(random.uniform(60, 90))
What it does:
- Cycles through every availability domain in your region instead of hammering one. Frankfurt has three; capacity appears in different ADs at different times.
- Waits 60–90 seconds with jitter between attempts, and backs off 5 minutes if Oracle ever returns a 429. In ~530 attempts I never saw one; this pacing is polite enough.
- Exits immediately if the instance already exists, so a restart of the loop can’t accidentally launch a duplicate once you’ve won.
- Raises on anything that isn’t a capacity error, so a bad config fails loudly instead of retrying forever. “Out of host capacity” comes back as a 500
InternalError, which is what the loop swallows and retries.
Prerequisites, all one-time setup in the console or CLI:
- An API signing key added to your user (
~/.oci/config; the console’s API Keys page generates the config snippet for you). - A VCN with a public subnet. Easiest path: console → Networking → Start VCN Wizard. Copy the subnet OCID.
- The OCID of an aarch64 image in your region (console → Compute → Images, or start a manual instance creation, pick Ubuntu 24.04 for aarch64, and lift the OCID from there).
Run it somewhere that stays up
The loop needs to run for hours, maybe days. I put it on a $12/year VPS under a systemd user unit rather than keeping my laptop awake:
[Unit]
Description=OCI ARM capacity retry
After=network-online.target
StartLimitIntervalSec=1800
StartLimitBurst=5
[Service]
Type=simple
WorkingDirectory=%h/oracle-arm
ExecStart=%h/.local/bin/uv run %h/oracle-arm/launch.py
Restart=on-failure
RestartSec=120
[Install]
WantedBy=default.target
loginctl enable-linger $USER # keep user units running after logout
systemctl --user enable --now oracle-arm
journalctl --user -u oracle-arm -f
Restart=on-failure plus the already-exists guard in the script means a transient crash resumes the hunt, and a win stays a win.
How long it took
Real numbers from the journal, Frankfurt (eu-frankfurt-1), late August 2026:
~11.5 hours, roughly 530 attempts. Started 13:23, GOT IT at 00:55 that night. Every single failure was 500 InternalError: Out of host capacity, then one attempt just worked.
One data point isn’t a distribution, but it matches what others report: capacity comes in bursts, and a patient loop catches one within a day or two.
The first hour after you win
This is the part most “beat out of capacity” guides never mention.
Guard against idle reclamation
Oracle reclaims idle Always Free instances: if CPU, network, and memory utilization all stay under their thresholds for 7 days, the instance is stopped. A box you’re still setting up sits exactly in that idle profile.
A tiny cron keeps it visibly alive:
*/10 * * * * curl -s https://one.one.one.one -o /dev/null; dd if=/dev/urandom bs=1M count=64 2>/dev/null | gzip > /dev/null
The curl generates network activity, the dd | gzip burns a couple seconds of CPU. Once the machine runs real workloads you can drop it.
The firewall exists twice
Opening a port means opening it in both places: the VCN security list (Oracle’s side, in the console) and the instance’s own firewall. Ubuntu images ship with iptables rules that drop everything except SSH; a rule added to the security list alone silently does nothing, and vice versa. This one produces the most confusing “but I opened the port!” debugging sessions.
Small print worth knowing
- The public IP is ephemeral: it survives reboots from inside the OS, but a stop/start from the console can change it. Convert it to a reserved IP in the console if you point DNS at it.
- The Ubuntu Minimal image has no
rsyslog, so there’s no/var/log/syslog; usejournalctl. It also lacks little conveniences you may expect; plain Ubuntu is the comfier choice unless you want minimal. - The only authorized SSH key is the one from the script. Don’t lose it: I overwrote mine hours after the catch and the cheapest fix was terminating the instance and restarting the loop.
- Enable
unattended-upgradesearly. This box will likely run for years; don’t let it rot.
Conclusion
Don’t fight the console. A polite API loop on a machine that stays up caught a free 2 OCPU / 12 GB ARM instance in half a day. Size for the post-June-2026 limits (2/12, not 4/24), and spend the first hour after GOT IT on survival: a heartbeat against idle reclamation, and remembering the firewall exists twice.