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 AMDE2.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
| # | Chapter | What it covers |
|---|---|---|
| 01 | Why Oracle Cloud | Comparison with Render, Hetzner, AWS, and when not to use it |
| 02 | What the free tier includes | Free Trial vs. Always Free, official figures, duration |
| 03 | Account signup and region | The irreversible decision: the home region |
| 04 | API key and the oci CLI | Doing everything from the terminal |
| 05 | Networking: compartment, VCN, subnet, and firewall | Network script with ports 22, 80, and 443 |
| 06 | Creating the instance | Script with retries for A1 and Micro as plan B |
| 07 | First VM boot | SSH, swap, Oracle’s iptables, updates |
| 08 | Idle reclamation | The exact rule and the keepalive |
| 09 | Measuring CPU and status report | p95 like Oracle, report every 45 min |
| 10 | Operations and teardown | Start, stop, back up, delete everything |
| 11 | Migrating from Micro to A1 | When ARM capacity shows up |
| 12 | Problems and solutions | Everything that failed and how it was fixed |
Conventions
- Example values: IP
203.0.113.10(documentation range), regionsa-santiago-1, project namemy-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, anduv(orpipx). - 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.
| Option | Monthly cost | Always on | Persistent disk | Drawback |
|---|---|---|---|---|
| Oracle Always Free | USD 0 | Yes | Yes (up to 200 GB) | Idle reclamation; clunky signup; A1 out of capacity |
| Render Free | USD 0 | No: sleeps after 15 min without traffic | No | ~1 min cold start; 750 h/month; arbitrary restarts |
| Hetzner (smallest VPS) | ~EUR 4–5 | Yes | Yes | It’s paid |
AWS t4g.nano | ~USD 3 + ~USD 3.6 for the IPv4 | Yes | Yes | ~USD 7 for a minimal machine |
| Your own PC | USD 0 | Only while it’s on | Yes | No 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 Trial | Always Free | |
|---|---|---|
| What it is | USD 300 credit for any service | Free resources with no expiration date |
| Duration | Usually 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 ends | Anything not Always Free is stopped and later deleted | Keeps running |
| Charges | None, unless you Upgrade to Pay As You Go | None |
Rule of thumb: only create resources labeled Always Free-eligible. Everything else disappears when the trial ends.
Compute
| Shape | Free quantity | CPU | RAM | Network |
|---|---|---|---|---|
VM.Standard.E2.1.Micro (AMD x86) | Up to 2 instances | 1/8 OCPU (burstable) | 1 GB | Up to 50 Mbps to the internet |
VM.Standard.A1.Flex (Ampere ARM) | 1,500 OCPU hours + 9,000 GB hours per month | Equivalent to 2 OCPU | Equivalent to 12 GB | Depends 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 limitsshows much larger quotas (for example 41 OCPU and 277 GB of A1). Those come from the credit, not Always Free.
Storage and networking
| Resource | Free |
|---|---|
| Block Volume (boot + data) | 200 GB total, home region only |
| Boot volume | Minimum ~47–50 GB per instance (the page is inconsistent) |
| Volume backups | 5 total |
| Object Storage | 20 GB and 50,000 requests per month |
| Outbound traffic | 10 TB per month |
| VCN | Up to 2 |
| Load Balancer | 1 flexible 10 Mbps + 1 Network Load Balancer |
Other services
| Service | Free |
|---|---|
| Monitoring | 500 million datapoints ingested, 1 billion retrieved |
| Notifications | 1 million HTTPS and 1,000 emails per month |
| Email Delivery | 3,000 emails per month |
| Autonomous Database | 2 databases with 1 OCPU and 20 GB |
| Vault | 20 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
-
Country, name, and email; verify the email.
-
Choose the account name (cloud account name or tenancy). You’ll need it on every login.
-
Choose the home region (see below).
-
Verification card: a temporary charge is made and then reversed. Nothing is billed without an Upgrade.
-
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
| Region | Identifier |
|---|---|
| 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
- Check which ones appear in the selector: those that don’t support free accounts are missing or show a warning.
- Pick the one closest to your users.
- 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
- Profile icon (top right) → My profile.
- Under Resources (bottom left) → API keys → Add API key.
- Generate API key pair → Download private key (
.pem) → Add. - 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 listwithout--tenancy-idfails withMissing 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
| Error | Cause or fix |
|---|---|
NotAuthenticated | Fingerprint, user, or tenancy copied incorrectly |
| Permissions warning | oci setup repair-file-permissions --file ~/.oci/config |
Missing option(s) --tenancy-id | That command requires the tenancy explicitly |
oci-cli api-key authentication
Networking: compartment, VCN, subnet, and firewall
What gets created
| Resource | Purpose |
|---|---|
| Compartment | Isolates the project from the rest of the account; deleting it cleans up everything |
VCN 10.0.0.0/16 | Virtual private network |
| Internet Gateway | Inbound and outbound internet access |
Route 0.0.0.0/0 → gateway | In the VCN’s default route table |
| Security List | Inbound only on 22 (SSH), 80 (HTTP), and 443 (HTTPS) + fragmentation ICMP |
Public subnet 10.0.0.0/24 | Where 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
--forceon theupdatecommands skips the interactive confirmation; thoseupdatecommands replace the entire rule list.- Protocol
6= TCP,1= ICMP. The ICMP type 3 code 4 rule enables Path MTU Discovery. --wait-for-statemakes each step wait until the resource exists.- To open another port, add a rule to the
INGJSON and rerun thesecurity-list update.
The two firewall layers
Oracle has two firewalls, and both must allow the port:
- The VCN’s Security List (this chapter).
iptablesinside the VM: Oracle’s Ubuntu image ships with aREJECTfor 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 / RAM | Up to 2 OCPU / 12 GB | 1/8 OCPU / 1 GB |
| Availability | Often returns “Out of host capacity” | Usually available |
| Reclamation rule | CPU, network, and memory | CPU and network only |
| Avoiding reclamation | Small instance: used RAM already exceeds 20% | Needs a CPU keepalive |
| Recommendation | First choice | Plan 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 passwordlesssudo).
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
| Strategy | Applies to | How | Assessment |
|---|---|---|---|
| Small instance | A1 | 1 OCPU and 1 GB: normal usage (~400 MB) exceeds 20% of RAM | Clean, no artificial load. The best |
| CPU keepalive | Micro (and A1 if desired) | Cron job that uses CPU for a little over a minute every few minutes | Works; it’s artificial load (gray area under the terms) |
| Network traffic | Both | Generate sustained traffic | Wastes 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
| Part | Why |
|---|---|
*/10 | Every 10 minutes |
timeout 75 | 75 s of load ≈ 12.5% of the time, more than the 5% the p95 needs; it never keeps running |
nice -n 19 | Lowest priority: your services always get the CPU first |
sha256sum of /dev/urandom | Uses 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
| Source | Where it runs | Purpose |
|---|---|---|
| Oracle Monitoring | Oracle’s API, from your PC with oci | The figure Oracle sees |
| Local log | The VM, every minute | Lets 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 isidle.%in cron is escaped as\%.- systemd timer, not cron:
*/45in cron fires at :00 and :45 (15 min gaps).OnUnitActiveSec=45mincounts from the last run, andOnBootSec=3minsends 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-upgradesinstalls 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-keepaliveand/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
| Problem | Cause | Solution |
|---|---|---|
| The region I want doesn’t appear | It doesn’t support free accounts or availability changed | Pick another one from the list; the region can’t be changed later |
NotAuthenticated | Fingerprint, user, or tenancy copied incorrectly in ~/.oci/config | Recopy the Configuration file preview and verify the fingerprint with openssl |
Missing option(s) --tenancy-id | iam region-subscription list requires the tenancy | --tenancy-id "$(awk -F= '/^tenancy=/{print $2}' ~/.oci/config)" |
| Config permissions warning | Files readable by others | oci setup repair-file-permissions --file ~/.oci/config |
oci: command not found after installing | uv PATH not loaded | uv tool update-shell and open a new terminal |
Networking and instance
| Problem | Cause | Solution |
|---|---|---|
NotAuthorizedOrNotFound right after creating the compartment | Propagation | sleep 20 before creating the VCN |
| ”Out of host capacity” on A1 | No free ARM hosts for the free tier | Retry in a loop every 2 min or use Micro |
Huge A1 quotas in oci limits | They come from the trial credit | Stick to the Always Free 2 OCPU / 12 GB |
SSH Connection refused | cloud-init still starting up | Wait 1–2 min and retry |
| 80/443 don’t respond even though the Security List allows them | The image’s iptables with a final REJECT | Insert the ACCEPT before the REJECT and run netfilter-persistent save |
apt dies from lack of memory on the Micro | 1 GB without swap | 1 GB swap (chapter 07) |
Reclamation and monitoring
| Problem | Cause | Solution |
|---|---|---|
| A report says “CPU 0%“ | Snapshot of an instant | Look at the 7-day p95 |
| On Micro, high RAM doesn’t protect you | The memory rule only applies to A1 | CPU keepalive |
| ”Every 45 min” with cron fires at :00 and :45 | */45 isn’t a real interval | systemd timer with OnUnitActiveSec=45min |
A cron line with % gets cut off | % is special in crontab | Escape it as \% |
| A downed VM doesn’t alert | It can’t report if it’s dead | The absence of a report for more than 45 min is the alarm |
| Instance reclaimed | CPU p95 (and network; on A1, memory too) below 20% for 7 days | Recreate it with the scripts; new IP → update DNS |
troubleshooting oracle-cloud