Skip to content
← All posts

Free-forever VM on Oracle Cloud (and how not to lose it)

A complete guide to creating an Oracle Cloud Always Free virtual machine with a public IP from the CLI, keeping it running 24/7 at no cost, and preventing Oracle from reclaiming it for being idle.

By Pablo Contreras32 min read12 chapters

  • oracle-cloud
  • always-free
  • oci-cli
  • vm
  • self-hosting

Cost: USD 0 per month · Time: ~1 h the first time · Level: intermediate (Linux terminal, SSH)


TL;DR

  • What you get: an Ubuntu 24.04 VM with a public IP, firewall open on 22/80/443, and no expiration date.
  • Free shapes: ARM A1.Flex (up to 2 OCPU and 12 GB) or AMD E2.1.Micro (1/8 OCPU, 1 GB). A1 is usually out of capacity; the Micro is almost always available.
  • The risk: Oracle may reclaim instances whose 95th percentile CPU stays below 20% for 7 days (if network and, on A1 only, memory also stay below 20%).
  • The fix: on A1, a small instance (used RAM exceeds 20%); on Micro, a lowest-priority CPU keepalive.
  • Monitoring: the VM logs its CPU every minute, computes the p95 the same way Oracle does, and sends a report to a webhook every 45 min.

Works for any lightweight service: a bot, a small API, a notification server, a proxy, a cron job.


Index

#ChapterWhat it covers
01Why Oracle CloudComparison with Render, Hetzner, AWS, and when not to use it
02What the free tier includesFree Trial vs. Always Free, official figures, duration
03Account signup and regionThe irreversible decision: the home region
04API key and the oci CLIDoing everything from the terminal
05Networking: compartment, VCN, subnet, and firewallNetwork script with ports 22, 80, and 443
06Creating the instanceScript with retries for A1 and Micro as plan B
07First VM bootSSH, swap, Oracle’s iptables, updates
08Idle reclamationThe exact rule and the keepalive
09Measuring CPU and status reportp95 like Oracle, report every 45 min
10Operations and teardownStart, stop, back up, delete everything
11Migrating from Micro to A1When ARM capacity shows up
12Problems and solutionsEverything that failed and how it was fixed

Conventions

  • Example values: IP 203.0.113.10 (documentation range), region sa-santiago-1, project name my-vm. Replace them with your own.
  • Placeholders: <TENANCY_OCID>, <USER_OCID>, <FINGERPRINT>. OCIDs are not secrets; the private API key is.
  • Terminal: Linux or WSL2 with bash, curl, jq, and uv (or pipx).
  • Data as of: October 2026. Chapters link to the official source where one exists.

oracle-cloud always-free oci-cli vm self-hosting

Why Oracle Cloud

The problem

  • Many small services need a machine that is always on with a public IP: a bot, a webhook, a notification server, an internal API.
  • Free PaaS tiers “sleep” the service when it gets no traffic.
  • AWS and GCP free tiers expire or charge for the IPv4 address.

What Oracle Always Free offers

  • A free VM with no expiration date (not a 12-month trial).
  • Public IP included and 10 TB of outbound traffic per month.
  • Up to 2 AMD Micro VMs (1 GB) or the equivalent of 2 OCPU and 12 GB on ARM.
  • It doesn’t shut down for lack of traffic: it stays on 24/7.

Comparison

Approximate prices as of October 2026.

OptionMonthly costAlways onPersistent diskDrawback
Oracle Always FreeUSD 0YesYes (up to 200 GB)Idle reclamation; clunky signup; A1 out of capacity
Render FreeUSD 0No: sleeps after 15 min without trafficNo~1 min cold start; 750 h/month; arbitrary restarts
Hetzner (smallest VPS)~EUR 4–5YesYesIt’s paid
AWS t4g.nano~USD 3 + ~USD 3.6 for the IPv4YesYes~USD 7 for a minimal machine
Your own PCUSD 0Only while it’s onYesNo fixed public IP; depends on your hardware

Render Free in detail

Source: render.com/docs/free.

  • Spins down after 15 min without inbound traffic and takes ~1 min to come back: the first request fails or waits.
  • 750 h per month per workspace: a 24/7 service uses 744 h in a 31-day month, so only one fits.
  • No persistent disk: anything local is lost on every restart or redeploy.
  • It may restart free services at any time.

Good for demos; not for something that must always respond.

When NOT to use Oracle Always Free

  • Production with customers: Oracle can reclaim the instance and documents no advance notice.
  • Data without backups: the VM can disappear.
  • If you don’t want to manage a server: patches, firewall, and monitoring are your responsibility.

oracle-cloud always-free comparison render hetzner aws

What the free tier includes

Source: Always Free Resources (accessed October 2026).


Free Trial vs. Always Free

After creating the account, the console shows:

Free Tier account · You are in a Free Trial. When your trial is over, your account is limited to Always Free resources.

Free TrialAlways Free
What it isUSD 300 credit for any serviceFree resources with no expiration date
DurationUsually 30 days (the official page doesn’t specify; check your console)Indefinite, as long as the account exists and the resource isn’t reclaimed
When it endsAnything not Always Free is stopped and later deletedKeeps running
ChargesNone, unless you Upgrade to Pay As You GoNone

Rule of thumb: only create resources labeled Always Free-eligible. Everything else disappears when the trial ends.

Compute

ShapeFree quantityCPURAMNetwork
VM.Standard.E2.1.Micro (AMD x86)Up to 2 instances1/8 OCPU (burstable)1 GBUp to 50 Mbps to the internet
VM.Standard.A1.Flex (Ampere ARM)1,500 OCPU hours + 9,000 GB hours per monthEquivalent to 2 OCPUEquivalent to 12 GBDepends on the shape
  • A1 can be used as one 2 OCPU/12 GB instance or as two 1 OCPU instances.
  • Always Free instances can only be created in the home region.
  • During the trial, oci limits shows much larger quotas (for example 41 OCPU and 277 GB of A1). Those come from the credit, not Always Free.

Storage and networking

ResourceFree
Block Volume (boot + data)200 GB total, home region only
Boot volumeMinimum ~47–50 GB per instance (the page is inconsistent)
Volume backups5 total
Object Storage20 GB and 50,000 requests per month
Outbound traffic10 TB per month
VCNUp to 2
Load Balancer1 flexible 10 Mbps + 1 Network Load Balancer

Other services

ServiceFree
Monitoring500 million datapoints ingested, 1 billion retrieved
Notifications1 million HTTPS and 1,000 emails per month
Email Delivery3,000 emails per month
Autonomous Database2 databases with 1 OCPU and 20 GB
Vault20 HSM key versions, 150 secrets

How long it lasts

  • The account and Always Free resources: no stated expiration.
  • A specific instance: as long as it isn’t reclaimed for being idle (chapter 08) and you don’t delete it.
  • The trial credit: ~30 days.

oracle-cloud always-free free-trial limits

Account signup and home region

Steps

  1. Go to https://signup.cloud.oracle.com/.

  2. Country, name, and email; verify the email.

  3. Choose the account name (cloud account name or tenancy). You’ll need it on every login.

  4. Choose the home region (see below).

  5. Verification card: a temporary charge is made and then reversed. Nothing is billed without an Upgrade.

  6. Wait for provisioning. The screen says:

    We are creating your account; this process can take up to 15 minutes.

    Two emails arrive: account created and account ready (fully provisioned). You can try signing in at https://cloud.oracle.com/ before the second one.

The home region: the irreversible decision

  • It cannot be changed after signup.
  • Always Free instances can only be created there.
  • The form warns:

    Free accounts are not available in all commercial data regions. Availability of each region may change over time. Do not attempt to create multiple accounts to change regions.

  • Oracle doesn’t publish which regions accept free accounts or the A1 capacity of each one. The real source is the form’s selector.

Regions close to South America

RegionIdentifier
Colombia Central (Bogotá)sa-bogota-1
Chile Central (Santiago)sa-santiago-1
Chile West (Valparaíso)sa-valparaiso-1
Brazil East (São Paulo)sa-saopaulo-1
Brazil Southeast (Vinhedo)sa-vinhedo-1
Mexico Central (Querétaro)mx-queretaro-1
Mexico Northeast (Monterrey)mx-monterrey-1
US East (Ashburn)us-ashburn-1

Source: Regions and Availability Domains.

How to choose

  1. Check which ones appear in the selector: those that don’t support free accounts are missing or show a warning.
  2. Pick the one closest to your users.
  3. Large regions (Ashburn, Phoenix, Frankfurt) have a reputation for being out of A1 capacity. This is anecdotal and unverifiable.

Real case

  • Home region: Chile Central (Santiago), with a single availability domain (AD-1).
  • No A1 capacity on signup day (12 attempts). See chapter 06.

oracle-cloud signup regions home-region

API key and the oci CLI

Why the CLI

  • Everything becomes repeatable commands (and lives in this guide).
  • It lets you automatically retry instance creation when there’s no capacity, which is tedious from the web console.
  • Oracle doesn’t use a simple token: it uses an API signing key (RSA pair) tied to your user.

1. Generate the API key in the console

  1. Profile icon (top right) → My profile.
  2. Under Resources (bottom left) → API keys → Add API key.
  3. Generate API key pair → Download private key (.pem) → Add.
  4. Copy the Configuration file preview block:
[DEFAULT]
user=ocid1.user.oc1..<...>
fingerprint=aa:bb:cc:...
tenancy=ocid1.tenancy.oc1..<...>
region=sa-santiago-1
key_file=<path to your private keyfile>

2. Save the key and config

mkdir -p ~/.oci
mv ~/Downloads/<your-key>.pem ~/.oci/oci_api_key.pem

cat > ~/.oci/config <<'EOF'
[DEFAULT]
user=<USER_OCID>
fingerprint=<FINGERPRINT>
tenancy=<TENANCY_OCID>
region=sa-santiago-1
key_file=~/.oci/oci_api_key.pem
EOF

chmod 700 ~/.oci && chmod 600 ~/.oci/*

Check that the fingerprint matches the key:

openssl rsa -in ~/.oci/oci_api_key.pem -pubout -outform DER | openssl md5 -c

3. Install the CLI

uv tool install oci-cli      # or: pipx install oci-cli
oci --version

If it says command not found: run uv tool update-shell and open a new terminal.

4. Verify access (read-only)

T=$(awk -F= '/^tenancy=/{print $2}' ~/.oci/config)
oci iam region-subscription list --tenancy-id "$T" --output table

Expected output:

| is-home-region | region-key | region-name   | status |
| True           | SCL        | sa-santiago-1 | READY  |

Watch out: oci iam region-subscription list without --tenancy-id fails with Missing option(s) --tenancy-id.

5. Check your quotas

oci iam availability-domain list --compartment-id "$T" --query 'data[].name'
for l in standard-a1-core-count standard-a1-memory-count; do
  oci limits value list --compartment-id "$T" --service-name compute --name "$l" \
    --query 'data[].{ad:"availability-domain",value:value}' --output table
done

Security

  • The key has the power of your administrator user over the entire account.
  • Never paste it into a chat, repo, or ticket. Keep it only in ~/.oci/.
  • Revoke it under API keys → Delete if it leaks.
  • To limit its scope: create a separate user with a policy restricted to the project’s compartment.

Common errors

ErrorCause or fix
NotAuthenticatedFingerprint, user, or tenancy copied incorrectly
Permissions warningoci setup repair-file-permissions --file ~/.oci/config
Missing option(s) --tenancy-idThat command requires the tenancy explicitly

oci-cli api-key authentication

Networking: compartment, VCN, subnet, and firewall

What gets created

ResourcePurpose
CompartmentIsolates the project from the rest of the account; deleting it cleans up everything
VCN 10.0.0.0/16Virtual private network
Internet GatewayInbound and outbound internet access
Route 0.0.0.0/0 → gatewayIn the VCN’s default route table
Security ListInbound only on 22 (SSH), 80 (HTTP), and 443 (HTTPS) + fragmentation ICMP
Public subnet 10.0.0.0/24Where the VM lives, with a public IP

Script: oci-crear-red.sh

Runs on your PC with the CLI configured (chapter 04). Prints the OCIDs ready for eval.

#!/usr/bin/env bash
# Creates compartment, VCN, internet gateway, route, security list (22/80/443), and public subnet.
# Usage: eval "$(bash oci-crear-red.sh my-vm)"
set -euo pipefail
NAME="${1:-my-vm}"
LABEL="${NAME//-/}"                       # dns-label: letters and numbers only
T=$(awk -F= '/^tenancy=/{print $2}' ~/.oci/config)

# 1. Compartment
C=$(oci iam compartment create --compartment-id "$T" --name "$NAME" \
  --description "Project $NAME" --wait-for-state ACTIVE --query 'data.id' --raw-output)
sleep 20                                  # the compartment takes a while to propagate

# 2. VCN
V=$(oci network vcn create -c "$C" --cidr-blocks '["10.0.0.0/16"]' --display-name "$NAME-vcn" \
  --dns-label "${LABEL}vcn" --wait-for-state AVAILABLE --query 'data.id' --raw-output)

# 3. Internet Gateway
IG=$(oci network internet-gateway create -c "$C" --vcn-id "$V" --is-enabled true \
  --display-name "$NAME-igw" --wait-for-state AVAILABLE --query 'data.id' --raw-output)

# 4. Default route to the gateway
RT=$(oci network vcn get --vcn-id "$V" --query 'data."default-route-table-id"' --raw-output)
oci network route-table update --rt-id "$RT" --force --wait-for-state AVAILABLE \
  --route-rules "[{\"destination\":\"0.0.0.0/0\",\"destinationType\":\"CIDR_BLOCK\",\"networkEntityId\":\"$IG\"}]" >/dev/null

# 5. Security List: 22, 80, 443, and ICMP 3/4
SL=$(oci network vcn get --vcn-id "$V" --query 'data."default-security-list-id"' --raw-output)
ING='[
 {"protocol":"6","source":"0.0.0.0/0","tcpOptions":{"destinationPortRange":{"min":22,"max":22}}},
 {"protocol":"6","source":"0.0.0.0/0","tcpOptions":{"destinationPortRange":{"min":80,"max":80}}},
 {"protocol":"6","source":"0.0.0.0/0","tcpOptions":{"destinationPortRange":{"min":443,"max":443}}},
 {"protocol":"1","source":"0.0.0.0/0","icmpOptions":{"type":3,"code":4}}]'
oci network security-list update --security-list-id "$SL" --force --wait-for-state AVAILABLE \
  --ingress-security-rules "$ING" \
  --egress-security-rules '[{"protocol":"all","destination":"0.0.0.0/0"}]' >/dev/null

# 6. Public subnet
SN=$(oci network subnet create -c "$C" --vcn-id "$V" --cidr-block 10.0.0.0/24 \
  --display-name "$NAME-subnet" --dns-label "${LABEL}sub" --wait-for-state AVAILABLE \
  --query 'data.id' --raw-output)

printf 'export C=%s\nexport V=%s\nexport IG=%s\nexport RT=%s\nexport SL=%s\nexport SN=%s\n' \
  "$C" "$V" "$IG" "$RT" "$SL" "$SN"

Usage:

eval "$(bash oci-crear-red.sh my-vm)"
echo "$C $SN"     # save them: the following chapters use them

It takes ~2 minutes.

Notes

  • --force on the update commands skips the interactive confirmation; those update commands replace the entire rule list.
  • Protocol 6 = TCP, 1 = ICMP. The ICMP type 3 code 4 rule enables Path MTU Discovery.
  • --wait-for-state makes each step wait until the resource exists.
  • To open another port, add a rule to the ING JSON and rerun the security-list update.

The two firewall layers

Oracle has two firewalls, and both must allow the port:

  1. The VCN’s Security List (this chapter).
  2. iptables inside the VM: Oracle’s Ubuntu image ships with a REJECT for all new inbound traffic except SSH. It’s opened in chapter 07.

oracle-cloud vcn security-list firewall oci-cli

Creating the instance

A1 or Micro

VM.Standard.A1.Flex (ARM)VM.Standard.E2.1.Micro (AMD)
Free CPU / RAMUp to 2 OCPU / 12 GB1/8 OCPU / 1 GB
AvailabilityOften returns “Out of host capacity”Usually available
Reclamation ruleCPU, network, and memoryCPU and network only
Avoiding reclamationSmall instance: used RAM already exceeds 20%Needs a CPU keepalive
RecommendationFirst choicePlan B when A1 has no capacity

A1 trick: create it with 1 OCPU and 1 GB. Ubuntu plus a lightweight service use ~400 MB, more than 20% of the RAM: it’s never considered idle and there’s no need to generate artificial load. See chapter 08.

1. SSH key

ssh-keygen -t ed25519 -N "" -C "oci-my-vm" -f ~/.ssh/oci_my_vm

2. Script: oci-crear-instancia.sh

Requires C and SN exported (chapter 05).

#!/usr/bin/env bash
# Creates the Always Free instance with Ubuntu 24.04.
# Usage: eval "$(bash oci-crear-instancia.sh a1)"     → A1 1 OCPU / 1 GB, retries every 2 min
#        eval "$(bash oci-crear-instancia.sh micro)"  → VM.Standard.E2.1.Micro
set -uo pipefail
KIND="${1:-a1}"
NAME="${NAME:-my-vm}"
SSH_PUB="${SSH_PUB:-$HOME/.ssh/oci_my_vm.pub}"
MAX_TRIES="${MAX_TRIES:-720}"             # 720 × 2 min = 24 h
: "${C:?export C}" "${SN:?export SN}"

AD=$(oci iam availability-domain list -c "$C" --query 'data[0].name' --raw-output)
if [ "$KIND" = "a1" ]; then
  SHAPE=VM.Standard.A1.Flex; CFG=(--shape-config '{"ocpus":1,"memoryInGBs":1}')
else
  SHAPE=VM.Standard.E2.1.Micro; CFG=(); MAX_TRIES=1
fi

# Latest Ubuntu 24.04 image compatible with the shape (aarch64 for A1, x86_64 for Micro)
IMG=$(oci compute image list -c "$C" --operating-system "Canonical Ubuntu" \
  --operating-system-version "24.04" --shape "$SHAPE" --sort-by TIMECREATED --sort-order DESC \
  --limit 1 --query 'data[0].id' --raw-output)

for i in $(seq 1 "$MAX_TRIES"); do
  OUT=$(oci compute instance launch -c "$C" --availability-domain "$AD" --shape "$SHAPE" "${CFG[@]}" \
    --image-id "$IMG" --subnet-id "$SN" --assign-public-ip true \
    --display-name "$NAME" --hostname-label "${NAME//-/}" --ssh-authorized-keys-file "$SSH_PUB" \
    --wait-for-state RUNNING --max-wait-seconds 600 --query 'data.id' --raw-output 2>&1)
  if [[ "$OUT" == ocid1.instance* ]]; then
    IP=$(oci compute instance list-vnics --instance-id "$OUT" --query 'data[0]."public-ip"' --raw-output)
    printf 'export I=%s\nexport IP=%s\n' "$OUT" "$IP"
    exit 0
  fi
  echo "$(date '+%F %T') attempt $i: $(echo "$OUT" | grep -o '"message": "[^"]*"' | head -1)" >&2
  [ "$i" -lt "$MAX_TRIES" ] && sleep 120
done
exit 1

3. Usage

Try A1 for a while first; if there’s no capacity, fall back to Micro:

MAX_TRIES=6 bash oci-crear-instancia.sh a1 > instancia.env || bash oci-crear-instancia.sh micro > instancia.env
eval "$(cat instancia.env)"; echo "$I $IP"

To wait for A1 in the background (up to 24 h):

nohup bash oci-crear-instancia.sh a1 > instancia-a1.env 2> intentos-a1.log &
tail -f intentos-a1.log

“Out of host capacity”

2026-10-11 15:52:04 attempt 1: "message": "Out of host capacity."
  • It’s not your fault: the region has no free ARM hosts for the free tier.
  • There’s no queue: whoever asks right when one frees up wins. Retrying every few minutes for hours or days usually works.
  • Real case in Santiago: 12 failed A1 attempts; the Micro was created on the first try in ~2 minutes.

Verify

oci compute instance get --instance-id "$I" --query 'data.{estado:"lifecycle-state",forma:shape}'

oracle-cloud compute a1-flex e2-micro out-of-capacity

First VM boot

1. First SSH

ssh -i ~/.ssh/oci_my_vm ubuntu@203.0.113.10
  • For the first 1–2 minutes it responds with Connection refused: cloud-init is still starting up. Retry.
  • The Ubuntu image user is ubuntu (with passwordless sudo).

Shortcut in ~/.ssh/config:

Host my-vm
  HostName 203.0.113.10
  User ubuntu
  IdentityFile ~/.ssh/oci_my_vm

2. What the image ships with

On a freshly created Micro:

x86_64 · Ubuntu 24.04.5 LTS · 954 MB RAM (~400 MB used at boot) · no swap
-P INPUT ACCEPT
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited

The final iptables REJECT blocks 80 and 443 even if the Security List allows them. It’s the most common mistake on Oracle.

3. Script: vm-base.sh

Swap, ports 80/443 in iptables, and automatic security patches. Idempotent.

#!/usr/bin/env bash
# Base setup for an Oracle Ubuntu 24.04 VM. Runs ON THE VM with sudo.
# Usage from your PC: ssh my-vm 'sudo bash -s' < vm-base.sh
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
PORTS="${PORTS:-80 443}"

# 1. 1 GB swap (the Micro has 1 GB RAM; apt and other processes can run out of memory)
if ! swapon --show | grep -q /swapfile; then
  fallocate -l 1G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
  grep -q /swapfile /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi

# 2. iptables: open the ports BEFORE the final REJECT (position 5) and persist
for p in $PORTS; do
  iptables -C INPUT -p tcp -m state --state NEW --dport "$p" -j ACCEPT 2>/dev/null \
    || iptables -I INPUT 5 -p tcp -m state --state NEW --dport "$p" -j ACCEPT
done
netfilter-persistent save

# 3. Automatic security patches
apt-get update -q
apt-get install -y -q unattended-upgrades

iptables -S INPUT
swapon --show

Usage:

ssh my-vm 'sudo bash -s' < vm-base.sh
ssh my-vm 'sudo env PORTS="80 443 8080" bash -s' < vm-base.sh   # other ports

Every new port must also be opened in the Security List (chapter 05).

4. Check from outside

With any service listening on the port (for example sudo python3 -m http.server 80):

curl -I http://203.0.113.10

If it hangs: check the Security List and iptables. If it responds with Connection refused: the firewall is fine, but nothing is listening.

oracle-cloud ssh iptables swap ubuntu

Idle reclamation: the rule and how to avoid it

The official rule

Exact text from Always Free Resources:

Oracle will deem virtual machine and bare metal compute instances as idle if, during a 7-day period, the following are true:

  • CPU utilization for the 95th percentile is less than 20%
  • Network utilization is less than 20%
  • Memory utilization is less than 20% (applies to A1 shapes only)

Idle Always Free compute instances may be reclaimed by Oracle.

How to read it

  • The conditions are joined by “and”: the instance is idle only if all of them are true. It’s enough for one to exceed 20% for it not to be.
  • 7-day 95th percentile: all of the week’s measurements are sorted and the one with 95% below it is taken. If CPU exceeds 20% for a bit more than 5% of the time, the p95 is above 20%.
  • Memory only counts on A1. On the Micro, only CPU and network count.
  • A lightweight service (a bot, a webhook, a notification server) barely uses CPU or network: under this rule, it almost always counts as idle.
  • The documentation doesn’t say whether or how it warns before reclaiming.
  • Forums recommend upgrading the account to Pay As You Go, but the documentation doesn’t say it exempts you from reclamation.
  • A stopped instance doesn’t use CPU either: don’t leave it off for days.

Strategies

StrategyApplies toHowAssessment
Small instanceA11 OCPU and 1 GB: normal usage (~400 MB) exceeds 20% of RAMClean, no artificial load. The best
CPU keepaliveMicro (and A1 if desired)Cron job that uses CPU for a little over a minute every few minutesWorks; it’s artificial load (gray area under the terms)
Network trafficBothGenerate sustained trafficWastes bandwidth; discarded

The keepalive (Micro)

ssh my-vm "echo \"*/10 * * * * root timeout 75 nice -n 19 sh -c 'cat /dev/urandom | sha256sum >/dev/null' 2>/dev/null || true\" | sudo tee /etc/cron.d/oci-keepalive"

Contents of /etc/cron.d/oci-keepalive:

*/10 * * * * root timeout 75 nice -n 19 sh -c 'cat /dev/urandom | sha256sum >/dev/null' 2>/dev/null || true
PartWhy
*/10Every 10 minutes
timeout 7575 s of load ≈ 12.5% of the time, more than the 5% the p95 needs; it never keeps running
nice -n 19Lowest priority: your services always get the CPU first
sha256sum of /dev/urandomUses CPU without touching disk or network

Evidence

Maximum CPU per minute according to Oracle Monitoring (UTC):

21:11  50%   21:12  88%   ← keepalive
21:13   0%  …  21:20   0%
21:21  56%   21:22  47%   ← keepalive

95th percentile for the last hour: 49.9% according to Oracle and 49% according to the VM’s local log. Well above 20%.

Don’t be fooled by “0%”

Seeing CPU 0% at a given moment doesn’t mean risk: it’s a snapshot of an instant without the keepalive. What matters is the 7-day p95. Chapter 09 measures and reports it.

If it gets reclaimed

  • The public IP changes when you recreate the instance: update your DNS.
  • Whatever was on the disk is lost: keep your install scripts and backups elsewhere (chapter 10).
  • The status report stops arriving: the missing report is the alarm.

oracle-cloud always-free idle-reclamation keepalive p95

Measuring CPU like Oracle and the status report

Two sources

SourceWhere it runsPurpose
Oracle MonitoringOracle’s API, from your PC with ociThe figure Oracle sees
Local logThe VM, every minuteLets the VM report its own p95

Oracle Monitoring from the CLI

The compute agent publishes CpuUtilization in the oci_computeagent namespace.

Maximum CPU per minute over the last hour and a half:

S=$(date -u -d '-90 min' +%Y-%m-%dT%H:%M:%SZ); E=$(date -u +%Y-%m-%dT%H:%M:%SZ)
oci monitoring metric-data summarize-metrics-data -c "$C" --namespace oci_computeagent \
  --query-text 'CpuUtilization[1m].max()' --start-time "$S" --end-time "$E" \
  --query 'data[0]."aggregated-datapoints"[].[timestamp,value]' --output json \
  | jq -r '.[] | "\(.[0][11:16]) UTC  \(.[1]|floor)%"'

95th percentile (for Oracle’s actual window use -7 days and [1d]: you get one p95 per day, since MQL doesn’t accept intervals above 1d):

S=$(date -u -d '-60 min' +%Y-%m-%dT%H:%M:%SZ)
oci monitoring metric-data summarize-metrics-data -c "$C" --namespace oci_computeagent \
  --query-text 'CpuUtilization[1h].percentile(0.95)' --start-time "$S" --end-time "$E" \
  --query 'data[0]."aggregated-datapoints"[-1].value' --raw-output

Other metrics in the namespace: MemoryUtilization, NetworksBytesIn, NetworksBytesOut. In the console: instance → Metrics.

Status report from the VM

The VM logs its CPU every minute, computes the 24 h and 7-day p95, checks RAM, disk, and services, and sends a report every 45 min to a webhook (any URL that accepts a POST: Slack, Discord, Telegram, your own endpoint…).

  • Why from the VM: it doesn’t depend on your PC being on, and it sees internal data.
  • Limitation: if the VM dies, it sends nothing. Not receiving the report for more than 45 min is the alarm.

Sample report:

VM OK (16:43)
CPU p95 24h 49% · 7d 49% (min. 20%) · now 0% (load 0.00 0.00 0.01)
RAM 409/954 MB (42%) · swap 0 MB · disk 8%
active services: cron · up 45 minutes

Report script: vm-report.sh

#!/usr/bin/env bash
# VM status report. Run by the systemd timer every 45 min.
# Config in /etc/vm-report.env (root only):
#   REPORT_WEBHOOK=https://...        URL that receives the POST
#   REPORT_FORMAT=text|slack|discord  text: plain body; slack/discord: JSON
#   REPORT_AUTH="Bearer ..."          optional, Authorization header
#   SERVICES="cron nginx"             systemd services to watch
#   CHECK_URL=https://...             optional, public URL that must return 2xx
#   TZ_REPORT=America/Guayaquil
set -u
. /etc/vm-report.env
: "${REPORT_FORMAT:=text}" "${SERVICES:=cron}" "${TZ_REPORT:=UTC}"
fails=()

# CPU: current (5 s), load, and p95 from the per-minute log (/var/log/cpu-minute.log: "epoch pct")
cpu=$((100 - $(vmstat 5 2 | tail -1 | awk '{print $15}')))
load=$(cut -d' ' -f1-3 /proc/loadavg)
p95() {
  awk -v since="$(( $(date +%s) - $1 ))" '$1 >= since {print $2}' /var/log/cpu-minute.log 2>/dev/null \
    | sort -n | awk '{a[NR]=$1} END {if (NR) print a[int(NR*0.95 + 0.999)]; else print "?"}'
}
p95_24h=$(p95 86400); p95_7d=$(p95 604800)
[ "$p95_7d" = "?" ] || [ "$p95_7d" -ge 20 ] || fails+=("CPU p95 7d ${p95_7d}% (Oracle could reclaim it)")

# RAM, swap, disk
read -r mem_total mem_used <<<"$(free -m | awk '/^Mem:/{print $2, $3}')"
mem_pct=$((mem_used * 100 / mem_total))
swap_used=$(free -m | awk '/^Swap:/{print $3}')
disk_pct=$(df --output=pcent / | tail -1 | tr -dc '0-9')
[ "$mem_pct" -lt 90 ] || fails+=("RAM ${mem_pct}%")
[ "$disk_pct" -lt 85 ] || fails+=("disk ${disk_pct}%")

# Services
for s in $SERVICES; do systemctl is-active --quiet "$s" || fails+=("$s inactive"); done

# Public URL (optional)
if [ -n "${CHECK_URL:-}" ]; then
  code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$CHECK_URL")
  [[ "$code" == 2* ]] || fails+=("$CHECK_URL returned $code")
fi

reboot=""; [ -f /var/run/reboot-required ] && reboot=" · reboot required"
now=$(TZ="$TZ_REPORT" date '+%H:%M')
body="CPU p95 24h ${p95_24h}% · 7d ${p95_7d}% (min. 20%) · now ${cpu}% (load ${load})
RAM ${mem_used}/${mem_total} MB (${mem_pct}%) · swap ${swap_used} MB · disk ${disk_pct}%
active services: ${SERVICES} · up $(uptime -p | sed 's/^up //')${reboot}"

if [ ${#fails[@]} -eq 0 ]; then title="VM OK ($now)"
else title="VM FAILURE ($now)"; body="$(IFS='; '; echo "${fails[*]}")
$body"; fi

case "$REPORT_FORMAT" in
  slack)   payload=$(jq -n --arg t "*$title*"$'\n'"$body" '{text: $t}');      ctype=application/json ;;
  discord) payload=$(jq -n --arg t "**$title**"$'\n'"$body" '{content: $t}'); ctype=application/json ;;
  *)       payload="$title"$'\n'"$body";                                       ctype=text/plain ;;
esac
curl -fsS -m 10 -o /dev/null -H "Content-Type: $ctype" \
  ${REPORT_AUTH:+-H "Authorization: $REPORT_AUTH"} -d "$payload" "$REPORT_WEBHOOK"

Installer: vm-install-report.sh

Copy vm-report.sh to the VM and then run this installer there:

#!/usr/bin/env bash
# Installs the per-minute CPU log and the report timer. Runs ON THE VM with sudo.
set -e
install -m 0755 /tmp/vm-report.sh /usr/local/bin/vm-report

# Configuration (edit it later with sudoedit /etc/vm-report.env)
if [ ! -f /etc/vm-report.env ]; then
  umask 077
  cat > /etc/vm-report.env <<EOF
REPORT_WEBHOOK=${REPORT_WEBHOOK:?missing REPORT_WEBHOOK}
REPORT_FORMAT=${REPORT_FORMAT:-text}
SERVICES="${SERVICES:-cron}"
CHECK_URL=${CHECK_URL:-}
TZ_REPORT=${TZ_REPORT:-UTC}
EOF
fi

# Per-minute CPU log (55 s average); daily pruning to 8 days
cat > /etc/cron.d/cpu-minute <<'EOF'
* * * * * root echo "$(date +\%s) $((100 - $(vmstat 55 2 | tail -1 | awk '{print $15}')))" >> /var/log/cpu-minute.log
17 3 * * * root f=/var/log/cpu-minute.log; awk -v s=$(( $(date +\%s) - 691200 )) '$1>=s' $f > $f.tmp && mv $f.tmp $f
EOF

cat > /etc/systemd/system/vm-report.service <<'EOF'
[Unit]
Description=VM status report
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/vm-report
EOF

cat > /etc/systemd/system/vm-report.timer <<'EOF'
[Unit]
Description=VM status report every 45 minutes

[Timer]
OnBootSec=3min
OnUnitActiveSec=45min

[Install]
WantedBy=timers.target
EOF

apt-get install -y -q jq >/dev/null
systemctl daemon-reload
systemctl enable --now vm-report.timer
systemctl start vm-report.service
systemctl list-timers vm-report.timer --no-pager | sed -n 2p

Usage from your PC:

scp vm-report.sh my-vm:/tmp/
ssh my-vm "sudo env REPORT_WEBHOOK='https://hooks.slack.com/services/...' REPORT_FORMAT=slack \
  SERVICES='cron' TZ_REPORT=America/Guayaquil bash -s" < vm-install-report.sh

To add the authentication header, write REPORT_AUTH="Bearer <token>" to /etc/vm-report.env with sudoedit.

Details that matter

  • vmstat 55 2: the second line is the 55 s average; column 15 is idle.
  • % in cron is escaped as \%.
  • systemd timer, not cron: */45 in cron fires at :00 and :45 (15 min gaps). OnUnitActiveSec=45min counts from the last run, and OnBootSec=3min sends a report after every boot.
  • Measured agreement: local p95 49% vs. Oracle 49.9% over the same hour.
  • While the log has less than 7 days of data, the “7d” figure only covers what’s there.

Operating the report

sudo systemctl start vm-report.service       # report now
systemctl list-timers vm-report.timer        # next run
journalctl -u vm-report -n 20                # latest runs
sudo systemctl disable --now vm-report.timer # disable

oracle-cloud monitoring p95 systemd-timer webhook

Operations and teardown

Instance state

oci compute instance get --instance-id "$I" --query 'data."lifecycle-state"' --raw-output   # RUNNING
oci compute instance list -c "$C" --query 'data[].{n:"display-name",s:"lifecycle-state",forma:shape}' --output table

Start, stop, restart

oci compute instance action --instance-id "$I" --action SOFTRESET   # graceful restart
oci compute instance action --instance-id "$I" --action STOP
oci compute instance action --instance-id "$I" --action START

A stopped instance uses no CPU and counts as idle for reclamation.

Updates

  • unattended-upgrades installs security patches on its own.
  • If the report says “reboot required”:
ssh my-vm 'sudo apt-get update && sudo apt-get -y upgrade && sudo reboot'

Everything enabled in systemd (services, the report timer) and the cron jobs start automatically.

Backups

The VM can be reclaimed. Back up elsewhere:

  • Install scripts: in a repo; they rebuild the server in minutes.
  • Data and configuration: whatever your service stores, plus /etc/cron.d/oci-keepalive and /etc/vm-report.env.
ssh my-vm 'sudo tar czf - /etc/cron.d /etc/vm-report.env /path/to/your/data' > respaldo-$(date +%F).tgz

Oracle also lets you back up the boot volume (5 free backups):

AD=$(oci iam availability-domain list -c "$C" --query 'data[0].name' --raw-output)
BV=$(oci compute boot-volume-attachment list -c "$C" --availability-domain "$AD" --instance-id "$I" \
  --query 'data[0]."boot-volume-id"' --raw-output)
oci bv boot-volume-backup create --boot-volume-id "$BV" --display-name "my-vm-$(date +%F)"

Get the public IP

oci compute instance list-vnics --instance-id "$I" --query 'data[0]."public-ip"' --raw-output

Delete everything

Order: dependent resources first.

oci compute instance terminate --instance-id "$I" --preserve-boot-volume false --force --wait-for-state TERMINATED
oci network subnet delete --subnet-id "$SN" --force --wait-for-state TERMINATED
oci network route-table update --rt-id "$RT" --route-rules '[]' --force
oci network internet-gateway delete --ig-id "$IG" --force --wait-for-state TERMINATED
oci network vcn delete --vcn-id "$V" --force --wait-for-state TERMINATED
oci iam compartment delete --compartment-id "$C" --force     # may take several minutes

Also:

  • Delete the DNS records that pointed to the IP.
  • Revoke the API key in the console (My profile → API keys → Delete) if you won’t use it anymore.
  • Delete the local SSH key: rm -f ~/.ssh/oci_my_vm ~/.ssh/oci_my_vm.pub.

oracle-cloud operations backup teardown

Migrating from Micro to A1

Why migrate

  • A1 with 1 GB avoids reclamation without artificial load: used memory already exceeds 20%.
  • More CPU and RAM (up to 2 OCPU and 12 GB free).
  • The keepalive goes away.

Step 1: get capacity

Leave the script from chapter 06 retrying in the background:

NAME=my-vm-a1 nohup bash oci-crear-instancia.sh a1 > instancia-a1.env 2> intentos-a1.log &

Once you get it, first save the Micro’s OCID (step 6 uses it), then load the A1 values:

I_MICRO=$I
eval "$(cat instancia-a1.env)"   # sets I and IP for the new instance

Step 2: prepare the A1

ssh -i ~/.ssh/oci_my_vm ubuntu@$IP 'sudo bash -s' < vm-base.sh       # chapter 07
  • The architecture is arm64: install your service with ARM packages or images.
  • Don’t install the keepalive.

Step 3: copy data

ssh my-vm 'sudo tar czf - /path/to/your/data' | ssh -i ~/.ssh/oci_my_vm ubuntu@$IP 'sudo tar xzf - -C /'

Step 4: status report

Install it as in chapter 09. On A1 the relevant check is memory: replace the CPU p95 line with:

[ "$mem_pct" -ge 20 ] || fails+=("RAM ${mem_pct}% (Oracle could reclaim it)")

Step 5: update DNS

Point the A records to the new IP. With a 300 s TTL it propagates in ~5 min.

Step 6: delete the Micro

oci compute instance terminate --instance-id "$I_MICRO" --preserve-boot-volume false --force

Estimated total time: ~15 minutes once capacity is available.

oracle-cloud a1-flex migration arm64

Problems and solutions

Signup and CLI

ProblemCauseSolution
The region I want doesn’t appearIt doesn’t support free accounts or availability changedPick another one from the list; the region can’t be changed later
NotAuthenticatedFingerprint, user, or tenancy copied incorrectly in ~/.oci/configRecopy the Configuration file preview and verify the fingerprint with openssl
Missing option(s) --tenancy-idiam region-subscription list requires the tenancy--tenancy-id "$(awk -F= '/^tenancy=/{print $2}' ~/.oci/config)"
Config permissions warningFiles readable by othersoci setup repair-file-permissions --file ~/.oci/config
oci: command not found after installinguv PATH not loadeduv tool update-shell and open a new terminal

Networking and instance

ProblemCauseSolution
NotAuthorizedOrNotFound right after creating the compartmentPropagationsleep 20 before creating the VCN
”Out of host capacity” on A1No free ARM hosts for the free tierRetry in a loop every 2 min or use Micro
Huge A1 quotas in oci limitsThey come from the trial creditStick to the Always Free 2 OCPU / 12 GB
SSH Connection refusedcloud-init still starting upWait 1–2 min and retry
80/443 don’t respond even though the Security List allows themThe image’s iptables with a final REJECTInsert the ACCEPT before the REJECT and run netfilter-persistent save
apt dies from lack of memory on the Micro1 GB without swap1 GB swap (chapter 07)

Reclamation and monitoring

ProblemCauseSolution
A report says “CPU 0%“Snapshot of an instantLook at the 7-day p95
On Micro, high RAM doesn’t protect youThe memory rule only applies to A1CPU keepalive
”Every 45 min” with cron fires at :00 and :45*/45 isn’t a real intervalsystemd timer with OnUnitActiveSec=45min
A cron line with % gets cut off% is special in crontabEscape it as \%
A downed VM doesn’t alertIt can’t report if it’s deadThe absence of a report for more than 45 min is the alarm
Instance reclaimedCPU p95 (and network; on A1, memory too) below 20% for 7 daysRecreate it with the scripts; new IP → update DNS

troubleshooting oracle-cloud

↑ Back to top