free-oracle-vps GitHub
Guide ยท Oracle Cloud Always Free

A free 4-core, 24 GB RAM server, with no surprise bills

Do the account steps yourself. Then one script, or one prompt for your coding agent, builds the server and the cost guards.

$0 / month 4 ARM cores 24 GB RAM 200 GB disk
Quick start, after the account steps
1
curl -LsSf https://astral.sh/uv/install.sh | sh
2
uvx --from oci-cli oci setup bootstrap
3
uv run https://free-oracle-vps.yarden-zamir.com/oracle-free-server.py --plan
Shows what the script would do and changes nothing. Run it again without --plan to build the server.

0Why Oracle

Prices per year, for about 4 cores, 16 GB or more of RAM and about 300 GB of disk.

OptionPer yearNotes
Oracle Always Free (ARM)$04 cores, 24 GB, 200 GB. Needs software that runs on ARM.
SSD Nodesabout $180Cheap shared server.
AWS Lightsail, 4 cores / 16 GB / 320 GBabout $1,008Simplest AWS option. No Israel region.
AWS EC2 in Israel, 1 year paid ahead, plus diskabout $1,220t4g.xlarge plus 300 GB of disk and a public IP.

One Oracle ARM core is a full physical core. On most other clouds, "4 vCPU" means 2 physical cores.

1Your part: the account

A script cannot do these steps. They need your card, your phone and a browser login.

  1. Sign up at signup.cloud.oracle.com. Use a password manager for the password. Click "Start my free trial". The trial gives $300 of credit for 30 days. The Always Free resources stay free after that.
  2. Pick the home region with care.

    ๐Ÿ”ด You cannot change it later, and the free ARM server exists only in the home region.

    • Israel Central (Jerusalem): lowest latency from Israel. It has one data center only.
    • Sweden Central (Stockholm) or Spain Central (Madrid): less used, so free capacity is easier to get.
    • Germany Central (Frankfurt) or Netherlands Northwest (Amsterdam): good, but popular.
  3. Add your card and finish the signup. Wait for the email that says your account is ready.
  4. Turn on two-factor login. In the console, open your profile, then Security, then Enable multi-factor authentication.
  5. Install uv, the tool that runs the script. It also gets Python and the Oracle tools for you. On a Mac or Linux:
    curl -LsSf https://astral.sh/uv/install.sh | sh

    On Windows, in PowerShell:

    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
  6. Log in from a new terminal:
    uvx --from oci-cli oci setup bootstrap

    A browser opens. Log in there. In the terminal, enter your home region by name (for example il-jerusalem-1) or by its number in the list. Accept the defaults for the other prompts.

2Build the server

Run the script straight from this site. First check what it would do, without any change:

uv run https://free-oracle-vps.yarden-zamir.com/oracle-free-server.py --plan

Then build the server:

uv run https://free-oracle-vps.yarden-zamir.com/oracle-free-server.py

To keep a copy and read it first, download it, then run the local file:

curl -LsSfO https://free-oracle-vps.yarden-zamir.com/oracle-free-server.py
uv run oracle-free-server.py --plan

The script asks for an email for the cost alert. Then it does the rest, in this order:

  • Quotas that block every paid resource, and a 1 USD budget that emails you about any charge.
  • A network, a public subnet and Oracle's firewall rules.
  • The server. If Oracle has no free capacity, the script tries every data center again each minute. Press Ctrl+C to stop.
  • A reserved public IP address that never changes.
  • A Host oracle-vps entry in ~/.ssh/config, and the same ports opened in the server's own firewall.

Run it again at any time. It reuses what already exists.

Options

OptionDefaultEffect
nameoracle-vpsServer name and SSH host name, for example uv run oracle-free-server.py my-box.
--emailaskedEmail for the cost alert.
--ssh-key~/.ssh/id_ed25519.pubSSH public key. The script creates the key if it does not exist.
--tcp80,443TCP ports to open besides 22. Use none for SSH only.
--udpnoneUDP ports to open, for example 443 for HTTP/3.
--ocpus --memory --disk4, 24, 200Server size. The free limit is the maximum, so the script refuses more.
--ubuntu24.0422.04, 24.04 or 26.04.
--budget1Monthly USD amount for the budget alert.
--retry60Seconds between tries while Oracle has no capacity.
--profileDEFAULTProfile in ~/.oci/config.
--no-quotas --no-budgetSkip the cost guards. Not recommended.
--no-reserved-ipKeep the temporary public IP.
--no-server-firewallOpen the ports only in Oracle's firewall, not inside the server.
--planShow what the script would do. Change nothing.
Show the full script
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["oci>=2.189,<3"]
# ///
"""Create a free Oracle Cloud ARM server with cost guards.

Before you run it: sign up, turn on two-factor login, then log in with
`uvx --from oci-cli oci setup bootstrap`. Run it again at any time: it reuses what already exists.
Guide: https://free-oracle-vps.yarden-zamir.com
Source: https://github.com/Yarden-zamir/free-oracle-vps
"""

import argparse
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path

import oci
from oci.core.models import (
    CreateInternetGatewayDetails,
    CreatePublicIpDetails,
    CreateSubnetDetails,
    CreateVcnDetails,
    CreateVnicDetails,
    GetPublicIpByPrivateIpIdDetails,
    IcmpOptions,
    IngressSecurityRule,
    InstanceSourceViaImageDetails,
    LaunchInstanceDetails,
    LaunchInstanceShapeConfigDetails,
    PortRange,
    RouteRule,
    TcpOptions,
    UdpOptions,
    UpdatePublicIpDetails,
    UpdateRouteTableDetails,
    UpdateSecurityListDetails,
)

SHAPE = "VM.Standard.A1.Flex"
FREE = {"ocpus": 4, "memory": 24, "disk": 200}  # Always Free ARM allowance
QUOTA_NAME, BUDGET_NAME = "free-tier-only", "free-tier-guard"
QUOTA_STATEMENTS = [
    "zero compute-core quotas in tenancy",
    "zero compute-memory quotas in tenancy",
    "set compute-core quota standard-a1-core-count to 4 in tenancy",
    "set compute-core quota standard-a1-core-regional-count to 4 in tenancy",
    "set compute-memory quota standard-a1-memory-count to 24 in tenancy",
    "set compute-memory quota standard-a1-memory-regional-count to 24 in tenancy",
    "set compute-core quota standard-e2-micro-core-count to 2 in tenancy",
    "zero block-storage quotas in tenancy",
    "set block-storage quota total-storage-gb to 200 in tenancy",
    "set block-storage quota volume-count to 2 in tenancy",
    "set block-storage quota backup-count to 5 in tenancy",
    "zero database quotas in tenancy",
    "set database quota adb-free-count to 2 in tenancy",
    "zero load-balancer quotas in tenancy",
    "set load-balancer quota lb-flexible-count to 1 in tenancy",
    "set load-balancer quota lb-flexible-bandwidth-sum to 10 in tenancy",
]


def step(title: str) -> None:
    print(f"\n\033[1m==> {title}\033[0m")


def ok(text: str) -> None:
    print(f"    \033[32mโœ“\033[0m {text}")


def plan(text: str) -> None:
    print(f"    \033[33m+\033[0m would {text}")


@dataclass
class Cloud:
    args: argparse.Namespace
    tenancy: str
    region: str
    identity: oci.identity.IdentityClient
    network: oci.core.VirtualNetworkClient
    network_ops: oci.core.VirtualNetworkClientCompositeOperations
    compute: oci.core.ComputeClient
    quotas: oci.limits.QuotasClient
    budgets: oci.budget.BudgetClient

    @classmethod
    def connect(cls, args: argparse.Namespace) -> "Cloud":
        config = oci.config.from_file(profile_name=args.profile)
        kw = {"retry_strategy": oci.retry.DEFAULT_RETRY_STRATEGY}
        network = oci.core.VirtualNetworkClient(config, **kw)
        cloud = cls(
            args,
            config["tenancy"],
            config["region"],
            oci.identity.IdentityClient(config, **kw),
            network,
            oci.core.VirtualNetworkClientCompositeOperations(network),
            oci.core.ComputeClient(config, **kw),
            oci.limits.QuotasClient(config, **kw),
            oci.budget.BudgetClient(config, **kw),
        )
        cloud.wait_for_login()
        return cloud

    def wait_for_login(self) -> None:
        """A new API key returns 401 for a few minutes after `oci setup bootstrap`."""
        for _ in range(12):
            try:
                self.identity.list_availability_domains(self.tenancy)
                return
            except oci.exceptions.ServiceError as e:
                if e.status != 401:
                    raise
                time.sleep(10)
        sys.exit("Oracle still rejects the login. Run `uvx --from oci-cli oci setup bootstrap` again.")

    def all(self, list_call, *args, **filters) -> list:
        return oci.pagination.list_call_get_all_results(list_call, *args, **filters).data


def first[T](items: list[T]) -> T | None:
    return items[0] if items else None


def quotas(c: Cloud) -> None:
    step("Quotas (block paid resources)")
    if first(c.all(c.quotas.list_quotas, c.tenancy, name=QUOTA_NAME)):
        return ok(f"quota policy {QUOTA_NAME} exists")
    if c.args.plan:
        return plan(f"create quota policy {QUOTA_NAME}")
    c.quotas.create_quota(
        oci.limits.models.CreateQuotaDetails(
            compartment_id=c.tenancy,
            name=QUOTA_NAME,
            statements=QUOTA_STATEMENTS,
            description="Block all resources beyond the Always Free allowance",
        )
    )
    ok(f"quota policy {QUOTA_NAME}")


def budget(c: Cloud) -> None:
    step("Budget alert")
    if first(c.all(c.budgets.list_budgets, c.tenancy, display_name=BUDGET_NAME)):
        return ok(f"budget {BUDGET_NAME} exists")
    if c.args.plan:
        return plan(f"create a {c.args.budget} USD monthly budget that emails {c.args.email}")
    models = oci.budget.models
    created = c.budgets.create_budget(
        models.CreateBudgetDetails(
            compartment_id=c.tenancy,
            display_name=BUDGET_NAME,
            amount=c.args.budget,
            reset_period="MONTHLY",
            target_type="COMPARTMENT",
            targets=[c.tenancy],
        )
    ).data
    c.budgets.create_alert_rule(
        created.id,
        models.CreateAlertRuleDetails(
            display_name="any-spend",
            type="ACTUAL",
            threshold=1,
            threshold_type="PERCENTAGE",
            recipients=c.args.email,
            message="Oracle Cloud charged money. The free tier is exceeded.",
        ),
    )
    ok(f"{c.args.budget} USD monthly budget {BUDGET_NAME}, alerts to {c.args.email}")


def ingress_rules(tcp: list[int], udp: list[int]) -> list[IngressSecurityRule]:
    def rule(source: str, protocol: str, **options) -> IngressSecurityRule:
        return IngressSecurityRule(source=source, protocol=protocol, is_stateless=False, **options)

    def port(p: int) -> PortRange:
        return PortRange(min=p, max=p)

    # The first three rules are the Oracle defaults for a new network. The update replaces the whole list.
    return [
        rule("0.0.0.0/0", "6", tcp_options=TcpOptions(destination_port_range=port(22))),
        rule("0.0.0.0/0", "1", icmp_options=IcmpOptions(type=3, code=4)),
        rule("10.0.0.0/16", "1", icmp_options=IcmpOptions(type=3)),
        *(rule("0.0.0.0/0", "6", tcp_options=TcpOptions(destination_port_range=port(p))) for p in tcp),
        *(rule("0.0.0.0/0", "17", udp_options=UdpOptions(destination_port_range=port(p))) for p in udp),
    ]


def open_ports(a: argparse.Namespace) -> str:
    udp = ", ".join(map(str, a.udp)) or "none"
    return f"TCP {', '.join(map(str, [22, *a.tcp]))}; UDP {udp}"


def network(c: Cloud) -> str | None:
    """Create the network and return the subnet id."""
    step("Network")
    name, available = c.args.name, ["AVAILABLE"]
    vcn = first(c.all(c.network.list_vcns, c.tenancy, display_name=f"{name}-vcn", lifecycle_state="AVAILABLE"))
    if not vcn and c.args.plan:
        plan(f"create network {name}-vcn, internet gateway, route and public subnet")
        plan(f"open {open_ports(c.args)} in the Oracle firewall")
        return None
    vcn = (
        vcn
        or c.network_ops.create_vcn_and_wait_for_state(
            CreateVcnDetails(
                compartment_id=c.tenancy, display_name=f"{name}-vcn", cidr_blocks=["10.0.0.0/16"], dns_label="vcn"
            ),
            available,
        ).data
    )
    ok(f"network {name}-vcn")

    gateway = first(c.all(c.network.list_internet_gateways, c.tenancy, vcn_id=vcn.id, display_name=f"{name}-igw"))
    if not gateway and c.args.plan:
        plan(f"create internet gateway {name}-igw")
    else:
        gateway = (
            gateway
            or c.network_ops.create_internet_gateway_and_wait_for_state(
                CreateInternetGatewayDetails(
                    compartment_id=c.tenancy, vcn_id=vcn.id, display_name=f"{name}-igw", is_enabled=True
                ),
                available,
            ).data
        )
        route = RouteRule(destination="0.0.0.0/0", destination_type="CIDR_BLOCK", network_entity_id=gateway.id)
        if c.args.plan:
            plan(f"set the default route to internet gateway {name}-igw")
        else:
            c.network.update_route_table(vcn.default_route_table_id, UpdateRouteTableDetails(route_rules=[route]))
            ok(f"internet gateway {name}-igw and route")

    rules = ingress_rules(c.args.tcp, c.args.udp)
    if c.args.plan:
        plan(f"open {open_ports(c.args)} in the Oracle firewall")
    else:
        c.network.update_security_list(
            vcn.default_security_list_id, UpdateSecurityListDetails(ingress_security_rules=rules)
        )
        ok(f"Oracle firewall: {open_ports(c.args)}")

    subnet = first(
        c.all(
            c.network.list_subnets, c.tenancy, vcn_id=vcn.id, display_name=f"{name}-subnet", lifecycle_state="AVAILABLE"
        )
    )
    if not subnet and c.args.plan:
        plan(f"create public subnet {name}-subnet")
        return None
    subnet = (
        subnet
        or c.network_ops.create_subnet_and_wait_for_state(
            CreateSubnetDetails(
                compartment_id=c.tenancy,
                vcn_id=vcn.id,
                display_name=f"{name}-subnet",
                cidr_block="10.0.0.0/24",
                dns_label="public",
                route_table_id=vcn.default_route_table_id,
                security_list_ids=[vcn.default_security_list_id],
            ),
            available,
        ).data
    )
    ok(f"public subnet {name}-subnet")
    return subnet.id


def is_capacity_error(e: oci.exceptions.ServiceError) -> bool:
    return e.status == 429 or (e.status == 500 and "capacity" in (e.message or "").lower())


def server(c: Cloud, subnet_id: str | None) -> str | None:
    """Create the server and return its id. Free ARM capacity is often sold out, so retry until Oracle has some."""
    step("Server")
    a = c.args
    existing = [
        i for i in c.all(c.compute.list_instances, c.tenancy, display_name=a.name) if i.lifecycle_state != "TERMINATED"
    ]
    if existing:
        ok(f"server {a.name} exists ({existing[0].lifecycle_state})")
        return existing[0].id

    images = c.all(
        c.compute.list_images,
        c.tenancy,
        operating_system="Canonical Ubuntu",
        operating_system_version=a.ubuntu,
        shape=SHAPE,
        sort_by="TIMECREATED",
        sort_order="DESC",
    )
    image = first(images) or sys.exit(f"No Ubuntu {a.ubuntu} ARM image in {c.region}.")
    domains = [d.name for d in c.identity.list_availability_domains(c.tenancy).data]
    spec = f"{a.ocpus} cores, {a.memory} GB RAM, {a.disk} GB disk, {image.display_name}"
    if a.plan or subnet_id is None:
        plan(f"create server {a.name}: {spec}")
        return None

    details = LaunchInstanceDetails(
        compartment_id=c.tenancy,
        display_name=a.name,
        shape=SHAPE,
        shape_config=LaunchInstanceShapeConfigDetails(ocpus=a.ocpus, memory_in_gbs=a.memory),
        source_details=InstanceSourceViaImageDetails(image_id=image.id, boot_volume_size_in_gbs=a.disk),
        create_vnic_details=CreateVnicDetails(subnet_id=subnet_id, assign_public_ip=True, hostname_label=a.name),
        metadata={"ssh_authorized_keys": a.ssh_key.read_text().strip()},
    )
    attempt = 0
    while True:
        attempt += 1
        for domain in domains:
            details.availability_domain = domain
            try:
                # Retry here, not in the SDK: the SDK retries 500 errors only a few times.
                launched = c.compute.launch_instance(details, retry_strategy=oci.retry.NoneRetryStrategy()).data
            except oci.exceptions.ServiceError as e:
                if not is_capacity_error(e):
                    raise
                continue
            oci.wait_until(c.compute, c.compute.get_instance(launched.id), "lifecycle_state", "RUNNING")
            ok(f"server {a.name} is running: {spec}")
            return launched.id
        print(
            f"    {time.strftime('%H:%M:%S')} attempt {attempt}: no free capacity, next try in "
            f"{a.retry} seconds (Ctrl+C to stop)"
        )
        time.sleep(a.retry)


def public_ip(c: Cloud, instance_id: str) -> str:
    """Give the server a reserved IP. It stays the same when the server is recreated."""
    step("Public IP")
    attachment = first(c.all(c.compute.list_vnic_attachments, c.tenancy, instance_id=instance_id))
    if attachment is None:
        sys.exit("The server has no network card yet. Run the script again in a minute.")
    private = first([p for p in c.all(c.network.list_private_ips, vnic_id=attachment.vnic_id) if p.is_primary])
    try:
        current = c.network.get_public_ip_by_private_ip_id(
            GetPublicIpByPrivateIpIdDetails(private_ip_id=private.id)
        ).data
    except oci.exceptions.ServiceError as e:
        if e.status != 404:
            raise
        current = None
    if c.args.no_reserved_ip or (current and current.lifetime == "RESERVED"):
        if current is None:
            sys.exit("The server has no public IP. Run without --no-reserved-ip.")
        ok(f"public IP {current.ip_address} ({current.lifetime.lower()})")
        return current.ip_address
    if c.args.plan:
        plan(f"replace the temporary IP {current.ip_address if current else ''} with a reserved IP {c.args.name}-ip")
        return current.ip_address if current else ""

    reserved = first(
        [
            p
            for p in c.all(c.network.list_public_ips, "REGION", c.tenancy, lifetime="RESERVED")
            if p.display_name == f"{c.args.name}-ip"
        ]
    )
    reserved = (
        reserved
        or c.network_ops.create_public_ip_and_wait_for_state(
            CreatePublicIpDetails(compartment_id=c.tenancy, lifetime="RESERVED", display_name=f"{c.args.name}-ip"),
            ["AVAILABLE"],
        ).data
    )
    if current:  # Oracle cannot convert a temporary IP, so SSH drops for a moment during the swap.
        c.network_ops.delete_public_ip_and_wait_for_state(current.id, ["TERMINATED"])
    c.network_ops.update_public_ip_and_wait_for_state(
        reserved.id, UpdatePublicIpDetails(private_ip_id=private.id), ["ASSIGNED"]
    )
    ok(f"reserved public IP {reserved.ip_address}")
    return reserved.ip_address


def ssh_setup(c: Cloud, ip: str) -> None:
    step("SSH")
    a = c.args
    config = Path.home() / ".ssh" / "config"
    config.parent.mkdir(mode=0o700, exist_ok=True)
    text = config.read_text() if config.exists() else ""
    if f"Host {a.name}\n" in text:
        print(f"    Host {a.name} is already in {config}. Check that its HostName is {ip}.")
    elif a.plan:
        plan(f"add Host {a.name} to {config}")
    else:
        if text:
            config.with_name(f"config.bak-{time.strftime('%Y%m%d-%H%M%S')}").write_text(text)
        identity = a.ssh_key.with_suffix("")
        config.write_text(
            f"{text}\nHost {a.name}\n\tHostName {ip}\n\tUser ubuntu\n\tIdentityFile {identity}\n\tIdentitiesOnly yes\n"
        )
        ok(f"added Host {a.name} to {config}")
    if a.plan:
        return

    ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new", a.name]
    for _ in range(20):
        if subprocess.run([*ssh, "true"], capture_output=True).returncode == 0:
            break
        time.sleep(15)
    else:
        sys.exit(f"SSH to {a.name} does not answer. Try again later: ssh {a.name}")
    ok(f"ssh {a.name} works")

    ports = [("tcp", p) for p in a.tcp] + [("udp", p) for p in a.udp]
    if a.no_server_firewall or not ports:
        return
    # Oracle's Ubuntu images block every port except 22 with iptables. Insert the rules above the REJECT rule.
    rules = "\n".join(f"open {proto} {p}" for proto, p in ports)
    remote = f"""set -e
open() {{
  iptables -C INPUT -p "$1" --dport "$2" -j ACCEPT 2>/dev/null && return
  n=$(iptables -L INPUT --line-numbers | awk '$2 == "REJECT" {{ print $1; exit }}')
  if [ -n "$n" ]; then
    iptables -I INPUT "$n" -p "$1" --dport "$2" -j ACCEPT
  else
    iptables -A INPUT -p "$1" --dport "$2" -j ACCEPT
  fi
}}
{rules}
netfilter-persistent save >/dev/null 2>&1
"""
    subprocess.run([*ssh, "sudo", "bash", "-s"], input=remote, text=True, check=True)
    ok(f"server firewall: {', '.join(f'{proto.upper()} {p}' for proto, p in ports)} open")


def ports(text: str) -> list[int]:
    values = [int(p) for p in text.replace(",", " ").split()] if text.strip() not in ("", "none") else []
    if any(not 1 <= p <= 65535 for p in values):
        raise argparse.ArgumentTypeError("ports must be from 1 to 65535")
    return values


def within(limit: int):
    def check(text: str) -> int:
        value = int(text)
        if not 1 <= value <= limit:
            raise argparse.ArgumentTypeError(f"must be from 1 to {limit}, the Always Free limit")
        return value

    return check


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(
        prog="oracle-free-server.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    p.add_argument("name", nargs="?", default="oracle-vps", help="server name and SSH host name (default: oracle-vps)")
    p.add_argument("--email", help="email for the cost alert (asked if missing)")
    p.add_argument("--ssh-key", type=Path, default=Path.home() / ".ssh" / "id_ed25519.pub", help="SSH public key")
    p.add_argument("--tcp", type=ports, default=[80, 443], help="TCP ports to open besides 22 (default: 80,443)")
    p.add_argument("--udp", type=ports, default=[], help="UDP ports to open, for example 443 for HTTP/3")
    p.add_argument("--ocpus", type=within(FREE["ocpus"]), default=FREE["ocpus"], help="cores (default: 4)")
    p.add_argument("--memory", type=within(FREE["memory"]), default=FREE["memory"], help="RAM in GB (default: 24)")
    p.add_argument(
        "--disk", type=within(FREE["disk"]), default=FREE["disk"], help="disk in GB, at least 50 (default: 200)"
    )
    p.add_argument("--ubuntu", choices=["22.04", "24.04", "26.04"], default="24.04", help="Ubuntu version")
    p.add_argument("--budget", type=float, default=1.0, help="monthly budget in USD for the alert (default: 1)")
    p.add_argument("--retry", type=int, default=60, help="seconds between capacity retries (default: 60)")
    p.add_argument("--profile", default="DEFAULT", help="profile in ~/.oci/config")
    p.add_argument("--no-quotas", action="store_true", help="skip the quotas that block paid resources")
    p.add_argument("--no-budget", action="store_true", help="skip the budget alert")
    p.add_argument("--no-reserved-ip", action="store_true", help="keep the temporary public IP")
    p.add_argument("--no-server-firewall", action="store_true", help="do not open the ports inside the server")
    p.add_argument("--plan", action="store_true", help="show what the script would do, change nothing")
    a = p.parse_args()

    if (
        any(ch not in "abcdefghijklmnopqrstuvwxyz0123456789-" for ch in a.name)
        or not a.name[:1].isalpha()
        or len(a.name) > 30
    ):
        p.error("the name must start with a letter and use only a-z, 0-9 and '-' (max 30)")
    if a.disk < 50:
        p.error("--disk must be at least 50, the smallest Oracle boot disk")
    a.ssh_key = a.ssh_key.expanduser()
    if not a.ssh_key.exists() and not a.plan:
        print(f"No key at {a.ssh_key}. Creating one.")
        subprocess.run(
            ["ssh-keygen", "-t", "ed25519", "-f", str(a.ssh_key.with_suffix("")), "-N", "", "-C", a.name], check=True
        )
    if not a.no_budget and not a.email:
        a.email = input("Email for the cost alert: ").strip()
    if not a.no_budget and "@" not in a.email:
        p.error("give a valid --email for the cost alert, or use --no-budget")
    return a


def main() -> None:
    args = parse_args()
    if not Path("~/.oci/config").expanduser().exists():
        sys.exit("Log in first: uvx --from oci-cli oci setup bootstrap")
    c = Cloud.connect(args)
    step(f"Oracle region {c.region}, server {args.name}{' (plan only, no changes)' if args.plan else ''}")
    if not args.no_quotas:
        quotas(c)
    if not args.no_budget:
        budget(c)
    instance_id = server(c, network(c))
    if instance_id is None:
        return step("Plan done. Run again without --plan to apply.")
    ip = public_ip(c, instance_id)
    ssh_setup(c, ip)
    step("Done" if not args.plan else "Plan done. Run again without --plan to apply.")
    print(f"    Connect: ssh {args.name}\n    IP:      {ip}")
    if not args.plan:
        print(
            "    Next: upgrade to Pay As You Go in the Oracle console:\n"
            "          Billing & Cost Management > Upgrade and Manage Payment > Pay As You Go"
        )


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit("\nStopped.")
    except oci.exceptions.ServiceError as e:
        sys.exit(f"\nOracle error {e.status} {e.code}: {e.message}")

3After the build

When the script ends, you have:

Last step: upgrade to Pay As You Go

In the Oracle console, open the menu (โ˜ฐ), then Billing & Cost Management, then Upgrade and Manage Payment. Pick Pay As You Go.

Why upgrade? On a free account, Oracle can stop a server that stays below 20% use for 7 days. After 30 days, a free account also loses everything that is not Always Free. Pay As You Go removes both risks. The quotas block every paid resource, so the upgrade alone does not cost money. Expect a temporary card hold of about $100. Oracle sends the email "your subscription(s) has been updated" when the upgrade is done.

4The catches

idle reclaim

Idle servers can go

On a free account, Oracle can stop a server that stays below 20% CPU, network and memory use for 7 days. Pay As You Go removes this.

account risk

Accounts can close

People report sudden closures of free accounts after fraud checks. Keep backups of important data outside Oracle.

ARM

ARM only

Most Linux software and Docker images run on ARM. Some old x86-only programs do not.

no promise

No uptime promise

Free resources have no guarantee and no support. Oracle can change the free limits.

traffic

Traffic over 10 TB

Outbound traffic is free up to 10 TB a month. More costs about $9 per TB. Quotas cannot block this. The budget alert tells you.

disk full

No free disk left

The server uses all 200 GB of free disk. The quotas block more disk. Oracle includes 5 free disk backups.

The real money risk is a stolen login. Attackers use stolen cloud logins to mine crypto on large servers. Two-factor login protects the console. The quotas block paid servers even with a stolen key. The key in ~/.oci/ lets any program on your computer manage your account. Delete the folder when you no longer need it.

5Help

The script says "no free capacity" for a long time

Free ARM capacity is often sold out in popular regions. Let the script run; it tries again each minute. Some people wait hours. The Pay As You Go upgrade usually makes capacity easier to get.

"Oracle still rejects the login"

A new login key takes a few minutes to work, and the script waits up to 2 minutes. If it still fails, run the login again:

uvx --from oci-cli oci setup bootstrap
My website does not load on port 80 or 443

Two firewalls must allow the port: Oracle's firewall and the server's own iptables rules. The script opens both. If you used --no-server-firewall, add the rules in /etc/iptables/rules.v4 above the final REJECT line. Oracle's Ubuntu images do not use ufw.

How do I remove everything?

In the Oracle console, terminate the server (Compute, Instances) and choose to delete its disk. Then delete the reserved IP, the network, the quota policy and the budget. Free resources cost nothing while they exist, so you can also keep them.

Does this work on Windows?

Yes. Install uv with the PowerShell command in step 5, then run the same commands. The server firewall step needs the OpenSSH client, which Windows 10 and 11 include. Nobody tested it on Windows yet; open an issue if it fails.