A hobbyist’s end-to-end build log: from a bare GMKtec mini PC to a public-facing OpenStack cloud you can SSH into — including every wall I hit and how I got past it.
Why I Did This
I’d run Proxmox before and exposed services from VMs to the internet, so I understood virtualization and home networking. What I wanted was the next step up: a real cloud orchestration platform — multi-tenancy, an API, self-service provisioning — the kind of thing AWS and GCP are built on, but running on hardware sitting on my desk.
The goal was concrete:
- A working OpenStack private cloud on a single mini PC.
- The dashboard reachable from the public internet.
- The ability to provision VMs and SSH into them.
I had one complication that turns out to be very common: my home internet is behind CGNAT — no public IP, no port forwarding possible. Spoiler: that didn’t stop anything. It just shaped the architecture.
The Hardware
- GMKtec mini PC — Intel CPU with VT-x, 32 GB RAM , 1 TB NVMe
- Two onboard NICs (one 2.5GbE, one 1GbE)
- A JetKVM for out-of-band access (KVM-over-IP) — this turned out to be a lifesaver
- A MacBook as my workstation
- A domain managed in Cloudflare
32 GB RAM is genuinely the practical floor for a single-node OpenStack that still has room to run real VMs. The control plane alone eats 12–16 GB.
Proxmox vs OpenStack: The Mental Model
If you’re coming from Proxmox like I was, the key reframe is this: the hypervisor layer is identical (KVM/QEMU + libvirt). Everything above it is what’s different. On Proxmox you click “create VM.” On OpenStack, that same action is decomposed across services that each own one job:
What you did on Proxmox OpenStack equivalent Service Web UI / API Horizon dashboard / CLI Horizon Users & permissions Tokens, projects, roles Keystone ISO / templates Cloud images (qcow2) Glance Create VM, size it Instance from a flavor Nova Bridges, NAT, VLANs Virtual networks, routers, ports Neutron Manual per-VM firewall Security groups Neutron Port-forward to a VM Floating IP (1:1 NAT) Neutron
Two things bit me coming from Proxmox, so internalize them early:
- Cloud images, not ISOs. OpenStack boots pre-built cloud images that self-configure on first boot via cloud-init. You do not click through an installer. (This caused my single biggest time sink — more later.)
- Security groups default to deny. A new VM blocks all inbound until you explicitly open ports.
Architecture: Working Around CGNAT
Since I had no public IP, the design splits cleanly into two planes:
- Admin plane — me reaching the dashboard and SSHing into VMs.
- Service plane — public web traffic to services (future).
Both reach the outside world through Cloudflare Tunnel , which dials outbound from my network. Because the connection originates from inside, CGNAT is irrelevant — nothing inbound needs to be routable. This is essentially the zero-trust / BeyondCorp pattern, and it’s arguably more modern than the classic “public IP + port forward” approach.
Key realization: Cloudflare Tunnel is perfect for HTTP/HTTPS (the dashboard, web services), but it cannot give a VM raw arbitrary-port public access. A true public VPS needs a real routable IP. For a homelab/POC where you reach your own VMs, tunnels + a bastion pattern are exactly right. Real multi-user VPS hosting is a hardware/IP upgrade later, not a re-learn.
Deployment Method: Why Kolla-Ansible
Three real options:
- DevStack — dev/test only, explicitly not for anything you keep. Rejected.
- OpenStack-Ansible — production-grade but heavy and complex; wrong place to start.
- Kolla-Ansible — production-grade, runs every service as a Docker container , great single-node “all-in-one” support, real upgrade path.
For a container-comfortable beginner who wants something real that can grow, Kolla-Ansible is the clear pick. Debugging is just docker logs.
The Build, Step by Step
1. Install Ubuntu Server (the right version!)
This is where I hit my first real lesson. My instinct was “use the newest LTS.” Wrong. OpenStack and Kolla only support specific, tested host OS + release combinations, and the matrix moves fast.
After checking the live support matrix (not a guide, not memory):
- Ubuntu 24.04 LTS as the host
- Kolla-Ansible / OpenStack 2025.1 “Epoxy”
I initially installed 22.04 based on outdated guidance, then discovered 2024.1 had been retired to unmaintained/ and 2025.1 had dropped 22.04 support. The fix without reinstalling everything: an in-place upgrade.
sudo apt update && sudo apt upgrade -y
sudo apt install -y update-manager-core
sudo do-release-upgrade
Lesson: Always match your host OS to what the deployer officially tests today. “Latest” is a liability in infrastructure, not a feature.
BIOS prerequisites (do these first, via JetKVM):
- Enable Intel VT-x / virtualization (non-negotiable — VMs won’t run without it)
- Disable Secure Boot
Verify virtualization after boot:
egrep -c '(vmx|svm)' /proc/cpuinfo # >0 means enabled
2. Pin a Static IP
Identify your NIC (modern names like eno1, not eth0):
ip -br addr
Set a static IP in netplan outside your router’s DHCP range, with JetKVM open in case you lock yourself out:
# /etc/netplan/00-installer-config.yaml
network:
version: 2
ethernets:
eno1:
dhcp4: false
addresses: [192.168.0.200/24]
routes:
- to: default
via: 192.168.0.1
nameservers:
addresses: [1.1.1.1, 8.8.8.8]
sudo netplan apply
3. Install Kolla-Ansible
# host prep
sudo apt install -y git python3-dev libffi-dev gcc libssl-dev python3-venv python3-pip
sudo timedatectl set-ntp true
# isolated venv
python3 -m venv ~/kolla-venv
source ~/kolla-venv/bin/activate
pip install -U pip
pip install 'ansible-core>=2.16'
# clone the EXACT supported branch with git (more reliable than pip's git+ shallow clone)
cd ~
git clone --branch stable/2025.1 https://opendev.org/openstack/kolla-ansible
pip install ./kolla-ansible
kolla-ansible install-deps
Lesson: pip install git+...@branch does a shallow clone that can fail to find the branch. A plain git clone --branch is more reliable. And confirm the branch actually exists first: git ls-remote --heads .
4. Configure globals.yml
sudo mkdir -p /etc/kolla && sudo chown $USER:$USER /etc/kolla
cp -r ~/kolla-venv/share/kolla-ansible/etc_examples/kolla/* /etc/kolla/
cp ~/kolla-venv/share/kolla-ansible/ansible/inventory/all-in-one ~/
kolla-genpwd
Key settings (single-node, single-NIC NAT to start):
kollabasedistro: "ubuntu"
openstack_release: "2025.1"
kollainternal_vipaddress: "192.168.0.250" # a SPARE LAN IP, not the host's
network_interface: "eno1"
neutronexternalinterface: "dummy1" # see note below
neutronpluginagent: "ovn"
enable_haproxy: "yes"
The single-NIC trick: Neutron’s external interface must be a “naked” (no-IP) interface, and it must not be the one your host IP lives on (or you lock yourself out). With one NIC, create a dummy interface for Neutron to bridge onto so it never touches your real NIC:
sudo ip link add dummy1 type dummy
sudo ip link set dummy1 up
5. Bootstrap → Prechecks → Deploy
kolla-ansible bootstrap-servers -i ~/all-in-one # installs Docker, etc.
kolla-ansible prechecks -i ~/all-in-one # validates config
kolla-ansible deploy -i ~/all-in-one # pulls images, starts everything (~30 min)
Prechecks are your friend — they fail loudly and specifically. I hit a chain of them, each a quick fix:
- No module named 'docker' → pip install docker
- No module named 'dbus' → sudo apt install -y libdbus-1-dev libdbus-glib-1-dev pkg-config && pip install dbus-python
- Please enable at least one backend for Cinder → I set enable_cinder: "no" for the POC (VMs run fine on ephemeral disk; add block storage later)
Also needed passwordless sudo for the deploy user so Ansible can escalate non-interactively:
echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/$USER
A successful run ends with failed=0. Mine: ok=449 changed=273 failed=0. 449 tasks, a full cloud, in containers.
6. Verify
pip install python-openstackclient
kolla-ansible post-deploy -i ~/all-in-one
source /etc/kolla/admin-openrc.sh
openstack service list # keystone, nova, neutron, glance, placement... all there
Horizon dashboard: http:// — admin password via:
grep keystoneadminpassword /etc/kolla/passwords.yml
Provisioning the First VM
# Image — CirrOS first to test the pipeline (tiny, 16MB)
wget http://download.cirros-cloud.net/0.6.2/cirros-0.6.2-x86_64-disk.img
openstack image create "cirros" --file cirros-0.6.2-x86_64-disk.img \
--disk-format qcow2 --container-format bare --public
# Flavor (CPU/RAM/disk)
openstack flavor create --vcpus 2 --ram 2048 --disk 20 m1.small
# Private tenant network
openstack network create tenant-net
openstack subnet create tenant-subnet --network tenant-net \
--subnet-range 10.0.0.0/24 --gateway 10.0.0.1 \
--dns-nameserver 1.1.1.1 --allocation-pool start=10.0.0.10,end=10.0.0.200
# Security group (open SSH + ping)
openstack security group create vm-access
openstack security group rule create vm-access --protocol tcp --dst-port 22 --remote-ip 0.0.0.0/0
openstack security group rule create vm-access --protocol icmp --remote-ip 0.0.0.0/0
# SSH key + launch
openstack keypair create --public-key ~/.ssh/id_ed25519.pub mykey
openstack server create --image cirros --flavor m1.small \
--network tenant-net --security-group vm-access --key-name mykey my-first-vm
ACTIVE with a 10.0.0.x IP = a VM running on your own cloud. 🎉
Networking: The Keystone Concept (Router + Floating IPs)
VMs on a private 10.0.0.x network can reach out (via NAT) but aren't reachable in. To SSH into them cleanly, you need a Neutron router bridging the tenant network to an external network , plus floating IPs.
# External (provider) network
openstack network create --external --provider-network-type flat \
--provider-physical-network physnet1 external-net
openstack subnet create external-subnet --network external-net \
--subnet-range 192.168.0.0/24 --gateway 192.168.0.1 \
--allocation-pool start=192.168.0.224,end=192.168.0.254 --no-dhcp
# Router wiring both sides
openstack router create main-router
openstack router set main-router --external-gateway external-net
openstack router add subnet main-router tenant-subnet
# Floating IP → VM
openstack floating ip create external-net
openstack server add floating ip my-first-vm <FLOATING-IP>
The Single-NIC Limitation (and the real fix)
With OVN, there’s no qrouter namespace to hop through, and the dummy1 interface has no physical uplink — so floating IPs weren't reachable from the LAN. The proper fix was using the second physical NIC as the external interface:
# globals.yml
neutronexternalinterface: "enp3s0" # real NIC, cabled, no IP
kolla-ansible reconfigure -i ~/all-in-one --tags neutron
Gotcha: the reconfigure updated the config but didn’t actually swap the physical port on the OVS bridge — it still had dummy1. I had to move it manually (OVS runs inside a container):
sudo docker exec openvswitch_vswitchd ovs-vsctl list-ports br-ex # showed dummy1
sudo docker exec openvswitch_vswitchd ovs-vsctl del-port br-ex dummy1
sudo docker exec openvswitch_vswitchd ovs-vsctl add-port br-ex enp3s0
After that, the floating IP pinged from the whole LAN. Lesson: when you change neutron_external_interface, verify the physical port actually moved on br-ex — don't assume reconfigure did it.
Making the Dashboard Public via Cloudflare Tunnel
Two halves that must match: the tunnel and telling OpenStack its external hostname.
Tunnel:
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared-linux-amd64.deb
cloudflared tunnel login
cloudflared tunnel create openstack
cloudflared tunnel route dns openstack dashboard.example.com
# ~/.cloudflared/config.yml
tunnel: openstack
credentials-file: /home/user/.cloudflared/<TUNNEL-ID>.json
ingress:
- hostname: dashboard.example.com
service: http://192.168.0.250:80
- service: http_status:404
Tell OpenStack the FQDN (in globals.yml) and reconfigure:
kollaexternalfqdn: "dashboard.example.com"
kolla-ansible reconfigure -i ~/all-in-one --tags horizon,haproxy
The “Cookies may be turned off” fix: Horizon behind an HTTPS-terminating proxy needs to trust the forwarded protocol. Create /etc/kolla/config/horizon/_9999-custom-settings.py:
SECUREPROXY_SSL_HEADER = ("HTTP_X_FORWARDEDPROTO", "https")
CSRFTRUSTEDORIGINS = ['https://dashboard.example.com']
CSRFCOOKIESECURE = True
SESSIONCOOKIESECURE = True
Then kolla-ansible reconfigure -i ~/all-in-one --tags horizon.
Make the tunnel permanent (systemd, survives reboots):
sudo mkdir -p /etc/cloudflared
sudo cp ~/.cloudflared/config.yml ~/.cloudflared/*.json /etc/cloudflared/
# point credentials-file in the copied config at /etc/cloudflared/<UUID>.json
sudo tee /etc/systemd/system/cloudflared.service >/dev/null <<'EOF'
[Unit]
Description=Cloudflare Tunnel
After=network.target
[Service]
ExecStart=/usr/bin/cloudflared --config /etc/cloudflared/config.yml tunnel run
Restart=on-failure
User=root
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now cloudflared
Dashboard now public at https://dashboard.example.com, over HTTPS, through CGNAT, surviving reboots.
The Bug That Cost Me the Most: Cloud Image vs Installer ISO
I spent a long time convinced SSH key injection was broken. VMs were ACTIVE, the network worked, metadata was healthy, keys were correct — but SSH always fell back to asking for a password.
The culprit, revealed only when I opened the noVNC console : the VM was sitting at the Ubuntu installer’s language-selection screen. I had registered an installer ISO in Glance, not a cloud image. An installer never runs cloud-init, never injects your key — it’s just waiting for someone to install an OS.
The fix:
# The REAL cloud image — note "cloudimg" in the name, ~600MB .img
wget https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
openstack image create "ubuntu-24.04" --file ubuntu-24.04-server-cloudimg-amd64.img \
--disk-format qcow2 --container-format bare --public
The keeper lesson:
- Cloud images = cloudimg, ~500–700 MB .img (qcow2). Use these for OpenStack.
- Installer ISOs = live-server, ~2–3 GB .iso. These are for installing on bare metal. Never for cloud VMs.
After swapping to the real cloud image, cloud-init ran, injected the key, and:
ssh-keygen -R <floating-ip> # clear stale host key from a previous VM on that IP
ssh ubuntu@<floating-ip> # straight into Ubuntu 24.04, passwordless
(That REMOTE HOST IDENTIFICATION HAS CHANGED warning is expected when you recreate a VM on a reused IP — ssh-keygen -R clears it.)
What I Ended Up With
- A bare-metal OpenStack private cloud (Kolla-Ansible, 2025.1, Ubuntu 24.04), every service containerized
- Horizon publicly accessible via Cloudflare Tunnel, no public IP, surviving reboots
- Full Neutron networking: tenant network, external network, router, floating IPs on a real NIC
- Direct, key-authenticated SSH from my laptop into a real Ubuntu VM
Lessons Worth Tattooing
- Cloud images, not installer ISOs. cloudimg .img, never live-server .iso. This one cost the most.
- Match versions to the live support matrix — newest ≠ supported. Check today, not from memory or an old blog.
- Verify OVS bridge changes actually applied — ovs-vsctl list-ports br-ex after changing the external interface.
- Prechecks are your friend — they fail specifically; read the error, fix, re-run.
- Have out-of-band access (JetKVM/IPMI). Networking changes will eventually drop your SSH, and the console saves you.
- CGNAT isn’t a blocker — outbound tunnels (Cloudflare/Tailscale) sidestep it entirely.
- Get it working simply first, then add the clean architecture. Single-NIC NAT → then the router and second NIC.
Where to Go Next
- A bastion + ingress architecture for secure multi-VM access
- Public web services on VMs via more Cloudflare tunnels (subdomain-per-service)
- Terraform with the OpenStack provider to codify the whole tenant as reproducible infrastructure
- Re-enable Cinder for persistent block storage
If you’ve got a spare mini PC and a weekend (okay — maybe two), this is one of the most educational projects you can do. You don’t just use a cloud; you understand every layer because you had to fight through each one yourself.
Got questions or hit a different wall? Drop a comment below.
Top comments (0)