Proxmox VE 9.2 (ARM64) on Oracle Cloud

Proxmox VE 9.2 (ARM64) on Oracle Cloud
Photo by Clark Van Der Beken / Unsplash

Proxmox shipped official ARM64 support in August 2026. This is the procedure I used to run it on an Oracle Cloud Ampere A1 instance, written up after building it end to end. The approach: import a Debian 13 ARM64 cloud image as a custom image, then install Proxmox on top of it. Verified August 2026 on pve-manager/9.2.9, kernel 7.0.14-6-pve, VM.Standard.A1.Flex, us-ashburn-1.

Why Proxmox on a cloud VM

A single cloud instance running Docker gets you a long way. What Proxmox adds here is mostly operational:

  • Snapshots and rollback. Containers sit on ZFS, so a snapshot before an upgrade is instant and nearly free, and rolling back is one command.
  • Scheduled backups, built in. vzdump with retention policies, configured in the UI rather than assembled from cron and shell scripts.
  • Real isolation between workloads. Unprivileged LXC containers with their own filesystems, per-container CPU and memory limits, and an optional per-container firewall.
  • A console that works when networking doesn't. The web UI gives you a shell into any container even after you've broken its network — genuinely useful on a box with no physical console.
  • A large ecosystem. The community-scripts catalogue installs most self-hosted software as a container in one command, and they run unmodified on arm64.

You could get similar results from Incus or plain LXD with more assembly. Proxmox's appeal is that the storage, backup, networking and UI pieces are already wired together.

You get containers, not VMs

VM.Standard.A1.Flex doesn't expose EL2, so there's no nested virtualization. LXC containers run at full native speed. VMs fall back to TCG software emulation at roughly 1/20 speed. Hardware virtualization on Ampere at Oracle needs BM.Standard.A1.160 bare metal.

If you want a container host with a good UI, snapshots and backups, this works well. If you need VMs, this isn't the platform.

The path

About 60–90 minutes, most of it waiting.

  • Step 1 — import a Debian ARM64 image. Runs from your workstation against the OCI API, and is the only step that needs a script.
  • Step 2 — launch the instance. Ordinary OCI console work.
  • Steps 3–9 — prepare Debian, swap the kernel, install Proxmox, then storage, networking, remote access and verification. All over SSH on the instance.

Only Step 1 needs a script. Everything else is console clicks or shell commands.

Prerequisites

  • An OCI tenancy. Pay-As-You-Go avoids the "out of host capacity" errors common on free accounts.
  • An API key: Profile → My profile → API keys → Add API key. Download the private key and save the config snippet to ~/.oci/config.
  • pip install oci
  • An SSH keypair:
  ssh-keygen -t ed25519 -f ~/.ssh/pve_oracle -C pve-oracle

The whole build runs over SSH and the API — no browser-based serial console needed.

Step 1 — Import the image

Download and verify:

curl -fLO https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2
curl -fLO https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS
sha512sum -c SHA512SUMS --ignore-missing

Create a bucket named os-images in the OCI console and upload the .qcow2 to it.

The image needs to be imported with launchMode=CUSTOM and firmware=UEFI_64, because ARM cloud images boot via UEFI. That combination isn't reachable through the CLI or Python SDK — CreateImageDetails exposes launch_mode but not launch_options — so sign the REST call directly:

import requests, oci
from oci.signer import Signer

cfg = oci.config.from_file()
compute = oci.core.ComputeClient(cfg)
namespace = oci.object_storage.ObjectStorageClient(cfg).get_namespace().data

signer = Signer(tenancy=cfg["tenancy"], user=cfg["user"],
                fingerprint=cfg["fingerprint"],
                private_key_file_location=cfg["key_file"])

body = {
    "compartmentId": cfg["tenancy"],
    "displayName": "debian-13-arm64",
    "launchMode": "CUSTOM",
    "launchOptions": {
        "bootVolumeType": "PARAVIRTUALIZED",
        "networkType": "PARAVIRTUALIZED",
        "remoteDataVolumeType": "PARAVIRTUALIZED",
        "firmware": "UEFI_64",
        "isConsistentVolumeNamingEnabled": False,
        "isPvEncryptionInTransitEnabled": False,
    },
    "imageSourceDetails": {
        "sourceType": "objectStorageTuple",
        "namespaceName": namespace,
        "bucketName": "os-images",
        "objectName": "debian-13-genericcloud-arm64.qcow2",
        "sourceImageType": "QCOW2",
        "operatingSystem": "Debian",
        "operatingSystemVersion": "13",
    },
}

r = requests.post(f'https://iaas.{cfg["region"]}.oraclecloud.com/20160918/images',
                  json=body, auth=signer, timeout=60)
r.raise_for_status()
image_id = r.json()["id"]

# import takes about 6 minutes
img = oci.wait_until(compute, compute.get_image(image_id),
                     "lifecycle_state", "AVAILABLE",
                     max_wait_seconds=2400).data

assert img.launch_options.firmware == "UEFI_64", img.launch_options.firmware

compute.add_image_shape_compatibility_entry(
    image_id=image_id, shape_name="VM.Standard.A1.Flex")
print("ready:", image_id)

The assert is worth keeping — firmware is fixed at import time, so confirming it before you launch saves a rebuild. The shape compatibility entry is what makes the image selectable for A1.

Step 2 — Launch the instance

Console work. Create a VCN with an internet gateway, a default route, and a public subnet.

For the security list I opened TCP 22, UDP 41641 (lets Tailscale connect directly rather than relaying), and ICMP type 3 code 4 for path MTU discovery. I deliberately left 8006 closed — the web UI goes on an overlay network in Step 8.

Worth noting: the Debian cloud image ships with no local firewall rules, so the security list is the only thing in front of the instance.

Then launch with these settings:

  • Image — your imported debian-13-arm64
  • ShapeVM.Standard.A1.Flex, 2 OCPU / 12 GB
  • Boot volume — 60 GB, becomes Proxmox local
  • Block volume — 140 GB, attached paravirtualized, becomes ZFS
  • Public IPReserved
  • SSH key — paste ~/.ssh/pve_oracle.pub

60 + 140 GB fits the free block-storage allowance.

Choose a reserved public IP rather than an ephemeral one. Ephemeral addresses are tied to the instance, so rebuilding means a new address.

Then connect. Everything from here runs as root on the instance:

ssh -i ~/.ssh/pve_oracle debian@<public-ip>
sudo -i

Step 3 — Prepare Debian

Stop cloud-init managing the hostname, but leave its network management enabled — it re-renders a working network config on every boot, which is useful insurance on a box you reach only over SSH.

cat >/etc/cloud/cloud.cfg.d/99-pve.cfg <<'EOF'
preserve_hostname: true
manage_etc_hosts: false
EOF

hostnamectl set-hostname pve

Pick the final hostname now. Proxmox bakes it into /etc/pve/nodes/<name>/. I renamed a node later and it was fiddly: /etc/pve is a FUSE filesystem where cp -a fails silently, VMIDs are unique cluster-wide so guest configs have to be moved rather than copied, and while a VMID exists in two node directories pct list shows nothing at all.

Proxmox needs the hostname to resolve to a non-loopback IPv4 address. Use the private vNIC address — on OCI the public IP is 1:1 NAT and never appears on an interface:

PRIV=$(ip -4 -o addr show enp0s6 | awk '{print $4}' | cut -d/ -f1)

cat >/etc/hosts <<EOF
127.0.0.1	localhost
$PRIV	pve.example.com pve

::1		localhost ip6-localhost ip6-loopback
ff02::1		ip6-allnodes
ff02::2		ip6-allrouters
EOF

hostname --ip-address    # must print $PRIV

If getent hosts pve also returns a link-local IPv6, that's nss-myhostname and it's fine — hostname --ip-address is the check that matters.

Add the Proxmox repository. The stanza is architecture-agnostic; apt resolves arm64 on its own:

wget https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg \
     -O /usr/share/keyrings/proxmox-archive-keyring.gpg

sha256sum /usr/share/keyrings/proxmox-archive-keyring.gpg
# 136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45

cat >/etc/apt/sources.list.d/pve-install-repo.sources <<'EOF'
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF

apt update
apt-cache policy proxmox-ve    # expect: Candidate: 9.2.0 ... arm64 Packages

Don't continue until that shows an arm64 candidate.

Step 4 — Swap the kernel

This is the step to be careful with: you're replacing the kernel on a machine you reach only over SSH, and Proxmox describes ARM support as best-effort outside NVIDIA Grace and Vera. It booted first try for me, but the safety net costs little.

Take a boot volume backup first. In the console: Boot volumes → your volume → Create backup.

Build the initramfs with MODULES=most. Cloud images use MODULES=dep, which includes only drivers for hardware present at build time:

sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf

Boot the new kernel once, keeping the old one as the default. If the new kernel doesn't come up, the next boot returns to Debian on its own — recoverable with an API reboot:

sed -i 's/^GRUB_DEFAULT=.*/GRUB_DEFAULT=saved/' /etc/default/grub
apt install -y proxmox-default-kernel
update-grub

CFG=/boot/grub/grub.cfg
SUBMENU=$(grep -oP "^submenu '[^']*' \$menuentry_id_option '\K[^']+" "$CFG" | head -1)

PVE="" ; DEB=""
while read -r e; do
  case "$e" in
    gnulinux-advanced-*) continue ;;
    *pve*) [ -z "$PVE" ] && PVE="$e" ;;
    *)     [ -z "$DEB" ] && DEB="$e" ;;
  esac
done < <(grep -oP "\$menuentry_id_option '\K[^']*-advanced-[^']*" "$CFG")

grub-set-default "${SUBMENU}>${DEB}"   # persistent fallback
grub-reboot      "${SUBMENU}>${PVE}"   # one-shot
reboot

The GRUB entries are matched on -advanced- and pve rather than on the kernel version. Version strings like 6.12.105+deb13-cloud-arm64 contain + and ., which are regex metacharacters — I hit this, and under set -e the failed match aborted the script before printing anything, leaving GRUB_DEFAULT=saved with no saved entry.

Reconnect and confirm:

uname -r    # 7.0.14-6-pve

Step 5 — Install Proxmox

Give ifupdown2 a config file before it arrives, so the physical NIC stays with systemd-networkd:

cat >/etc/network/interfaces <<'EOF'
auto lo
iface lo inet loopback

source /etc/network/interfaces.d/*
EOF
mkdir -p /etc/network/interfaces.d

Then install. Postfix will prompt — choose Local only and accept the default mail name:

apt install -y proxmox-ve postfix chrony
apt remove -y linux-image-arm64 linux-image-cloud-arm64 os-prober

On arm64 the Debian kernel packages are linux-image-arm64 and linux-image-cloud-arm64. Removing them prints "System booted in EFI-mode but 'grub-efi-amd64' meta-package not installed", which is cosmetic on this architecture.

Only one boot entry remains now, so drop the one-shot machinery:

sed -i 's/^GRUB_DEFAULT=.*/GRUB_DEFAULT=0/' /etc/default/grub
update-grub
grep -c '^menuentry' /boot/grub/grub.cfg    # expect 1

Set a root password for the web UI and disable the enterprise repo:

passwd root
echo 'Enabled: false' >> /etc/apt/sources.list.d/pve-enterprise.sources

You now have pve-manager/9.2.9 (running kernel: 7.0.14-6-pve).

Step 6 — Storage

Turn the second volume into a ZFS pool. proxmox-ve brings ZFS with it. Address the disk by by-id so device renaming can't break the pool:

DISK=$(for d in /dev/disk/by-id/*; do
         case "$d" in *-part*) continue ;; esac
         [ "$(readlink -f "$d")" = /dev/sdb ] && echo "$d" && break
       done)

zpool create -f -o ashift=12 \
  -O compression=zstd -O atime=off -O xattr=sa -O acltype=posixacl \
  tank "$DISK"

echo "options zfs zfs_arc_max=2147483648" >/etc/modprobe.d/zfs.conf
update-initramfs -u -k all

pvesm add zfspool tank --pool tank --content rootdir,images

Cap the ARC — left alone ZFS will take up to half the RAM, which you'd rather give to containers.

You end up with local (~59 GB, templates and backups) and tank (139 GB, thin-provisioned, snapshots, zstd compression).

Step 7 — Container networking

OCI's vNIC only permits traffic from its assigned MAC address, so containers get a NAT bridge with no physical port rather than a bridged one.

I also left the physical NIC with systemd-networkd rather than handing it to ifupdown2:

systemd-networkd keeps enp0s6. ifupdown2 gets only vmbr0.
Because vmbr0 has no physical port, the two don't interact, and Proxmox network changes can't affect SSH.
cat >/etc/network/interfaces <<'EOF'
auto lo
iface lo inet loopback

auto vmbr0
iface vmbr0 inet static
    address 10.10.10.1/24
    bridge-ports none
    bridge-stp off
    bridge-fd 0
    mtu 1500
    post-up   sysctl -qw net.ipv4.ip_forward=1
    post-up   iptables -t nat -A POSTROUTING -s '10.10.10.0/24' -o enp0s6 -j MASQUERADE
    post-up   iptables -t nat -A POSTROUTING -s '10.10.10.0/24' -o tailscale0 -j MASQUERADE
    post-down iptables -t nat -D POSTROUTING -s '10.10.10.0/24' -o tailscale0 -j MASQUERADE
    post-down iptables -t nat -D POSTROUTING -s '10.10.10.0/24' -o enp0s6 -j MASQUERADE
    post-up   iptables -t raw -I PREROUTING -i fwbr+ -j CT --zone 1
    post-down iptables -t raw -D PREROUTING -i fwbr+ -j CT --zone 1

source /etc/network/interfaces.d/*
EOF

mkdir -p /run/network
echo "d /run/network 0755 root root -" > /etc/tmpfiles.d/ifupdown2-network.conf
ifreload -a

The enp0s6 rule covers the internet; the tailscale0 rule covers anything you reach over the overlay network from Step 8. Without the second one a container reaches the internet fine but silently fails to reach your LAN, because the host routes those packets out tailscale0 with an un-masqueraded 10.10.10.x source.

The fwbr+ rules put Proxmox's per-container firewall bridges in their own conntrack zone alongside the host masquerade. MTU is 1500 because the OCI vNIC runs at 9000, which containers shouldn't inherit for internet traffic.

The mkdir matters. Without it ifreload reports error: Another instance of this program is already running — I spent time on this. The lock lives at /run/network/.lock, and when the parent directory is missing, lock creation fails and the error is misleading. networking.service normally creates it, but hadn't run because there was no /etc/network/interfaces at boot. /run is tmpfs, hence the tmpfiles.d entry to recreate it every boot.

DHCP on the bridge

Nothing serves DHCP on this bridge by default, so containers created with ip=dhcp wait indefinitely — I hit this with a community-scripts helper, which stalls at Waiting for network in LXC container. dnsmasq, bound only to the bridge, fixes it:

apt install -y dnsmasq

cat >/etc/dnsmasq.d/vmbr0.conf <<'EOF'
interface=vmbr0
bind-interfaces
except-interface=lo
listen-address=10.10.10.1

dhcp-range=10.10.10.100,10.10.10.200,12h
dhcp-option=option:router,10.10.10.1
dhcp-option=option:dns-server,10.10.10.1
dhcp-authoritative

no-resolv
server=1.1.1.1
server=1.0.0.1

domain=lxc
local=/lxc/
expand-hosts
EOF

systemctl enable --now dnsmasq
ss -lunp | grep :53    # should list 10.10.10.1, not your OCI address

bind-interfaces with interface=vmbr0 keeps it off enp0s6, and it coexists with systemd-resolved on 127.0.0.53, so the host's own resolver is unaffected.

One cosmetic fix while you're here — Debian 13's cloud image has no /etc/timezone, so helper scripts print Skipping timezone setup - zone 'host' not found in container:

timedatectl show --property=Timezone --value > /etc/timezone

If a container needs a public IP

NAT plus port forwarding covers most cases. Two other options:

  • Secondary vNIC passthrough — create a vNIC, leave it unconfigured on the host, and add lxc.net.1.type: phys, lxc.net.1.link: enp1s0, lxc.net.1.flags: up to the container config. The container gets its own OCI-assigned MAC. Budget: 1 vNIC per OCPU.
  • Routed /32 — assign a secondary private IP to the primary vNIC, leave it unconfigured on the host, and route it to the container's veth. Egress keeps the host MAC and an OCI-registered source IP. I didn't test this one.

Step 8 — Remote access

Rather than exposing 8006, put the host on an overlay network:

curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --advertise-routes=10.10.10.0/24 --accept-routes \
             --accept-dns=false --hostname=pve

Approve the subnet route in the Tailscale admin console. The container subnet then becomes reachable from your other devices directly — no port forwarding.

Two settings make subnet routing work properly: IPv6 forwarding, and a UDP offload tweak Tailscale asks for on startup.

printf 'net.ipv4.ip_forward = 1\nnet.ipv6.conf.all.forwarding = 1\n' \
  > /etc/sysctl.d/99-tailscale.conf && sysctl --system

cat >/etc/systemd/system/tailscale-tweaks.service <<'EOF'
[Unit]
Description=Tailscale subnet-routing tweaks
After=tailscaled.service network-online.target
Wants=tailscaled.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/ethtool -K enp0s6 rx-udp-gro-forwarding on rx-gro-list off

[Install]
WantedBy=multi-user.target
EOF

systemctl enable --now tailscale-tweaks.service

About --accept-routes

If you only connect from machines running the Tailscale client, you don't need this flag. I needed it: my workstation has no Tailscale client and reaches the tailnet through a subnet router. Without --accept-routes, tailscaled doesn't carry subnet-route traffic in either direction, and adding ip route entries manually doesn't persist — tailscaled reconciles them away.

The symptom was distinctive: Tailscale clients reached the node fine, LAN machines got nothing at all, not even ICMP, and tcpdump -i tailscale0 showed their packets never arriving.

The flag is all-or-nothing, so check what you're accepting:

apt install -y jq
tailscale status --json | jq '.Peer[] | select(.PrimaryRoutes) | {HostName, PrimaryRoutes}'

One of my peers advertised 10.0.0.0/24 — the same range as the OCI VCN this instance sits on, since Oracle uses it by default. After enabling --accept-routes, ip route get 10.0.0.1 started resolving through tailscale0. Tailscale consults its own routing table at ip rule priority 5270, so a rule above that keeps the local subnet local:

# add a second ExecStart to tailscale-tweaks.service, then restart it
ExecStart=/sbin/ip rule add to 10.0.0.0/24 lookup main priority 5000

Verify with ip route get <your-gateway> — it should resolve to the physical NIC.

Step 9 — Verify

Proxmox publishes arm64 container templates directly:

pveam update
pveam download local debian-13-standard_13.6-1_arm64.tar.zst

pct create 9000 local:vztmpl/debian-13-standard_13.6-1_arm64.tar.zst \
  --hostname nat-test --arch arm64 --ostype debian \
  --cores 1 --memory 512 --rootfs tank:2 \
  --net0 name=eth0,bridge=vmbr0,ip=dhcp,type=veth \
  --unprivileged 1 --features nesting=1 --start 1

pct exec 9000 -- ip -br addr show eth0    # expect a 10.10.10.1xx lease
pct exec 9000 -- apt-get update           # real TCP + TLS + DNS

Use ip=dhcp rather than a static address — it exercises dnsmasq, which helper scripts depend on. And use apt-get update as the test rather than ping: my first test container had a static IP and passed ping while the DHCP path was still broken.

Then clean up, reboot, and confirm the PVE kernel is default, vmbr0 is up with its NAT rule, ZFS is online, and dnsmasq is listening on 10.10.10.1:

pct stop 9000 && pct destroy 9000
reboot

The web UI is at https://<tailscale-ip>:8006, user root, realm Linux PAM.

Debugging without a console

Oracle exposes the instance's serial console log through the API, and it works on a BYOI image with no Oracle Cloud Agent installed:

h = compute.capture_console_history(
    oci.core.models.CaptureConsoleHistoryDetails(instance_id=iid)).data
h = oci.wait_until(compute, compute.get_console_history(h.id),
                   "lifecycle_state", "SUCCEEDED").data
log = compute.get_console_history_content(h.id, length=1024*1024).data

I used this to read 1,620 lines of boot log and find a failed SSH key injection without opening a browser. Since it doesn't depend on the agent, you can also disable the Oracle Cloud Agent plugins — on a hypervisor, Compute Instance Run Command and OS Management Hub are worth turning off.

Troubleshooting

Things I ran into, and what caused them:

  • Kernel script exits silently, no output. A regex matched against a kernel version containing +, under set -e.
  • error: Another instance of this program is already running. /run/network/ doesn't exist.
  • Helper script hangs at Waiting for network in LXC container. No DHCP server on the NAT bridge.
  • Skipping timezone setup - zone 'host' not found. The Debian 13 cloud image has no /etc/timezone.
  • LAN machines can't reach the node, but Tailscale clients can. --accept-routes is off.
  • Local VCN subnet resolves through tailscale0. A peer advertises an overlapping subnet — add the ip rule.
  • pct list is empty after a node rename. The VMID exists in two node directories.
  • A service shows active (running) but nothing is listening. It's blocked during startup, often on a network call. Community-script services log to /var/log/<app>.log, not the journal, so journalctl -u <svc> looks empty.

Prior art

Guides that helped along the way: