Proxmox VE 9.2 (ARM64) on Oracle Cloud
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.
vzdumpwith 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 - Shape —
VM.Standard.A1.Flex, 2 OCPU / 12 GB - Boot volume — 60 GB, becomes Proxmox
local - Block volume — 140 GB, attached paravirtualized, becomes ZFS
- Public IP — Reserved
- 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-networkdkeepsenp0s6.ifupdown2gets onlyvmbr0.
Becausevmbr0has 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: upto 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
+, underset -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-routesis off. - Local VCN subnet resolves through
tailscale0. A peer advertises an overlapping subnet — add theip rule. pct listis 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, sojournalctl -u <svc>looks empty.
Prior art
Guides that helped along the way:
- klabsdev, Run Proxmox on OCI — For Free — the Debian custom-image approach, on x86
- Frank Ruan, Configuring network for Proxmox VE on OCI ARM — vNIC passthrough for containers
- Proxmox forum thread 161578 — the NAT bridge configuration
- PeronGH's gist on OCI custom image import — the
CUSTOM+UEFI_64import recipe, for Fedora. Saved me hours. - Jeff Geerling on Proxmox's ARM release — bare-metal Ampere Altra