DEV Community

Sumit
Sumit

Posted on • Originally published at Medium on

Building a Self-Hosted OpenStack Private Cloud on a Mini PC (Behind CGNAT, No Public IP)

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:

  1. A working OpenStack private cloud on a single mini PC.
  2. The dashboard reachable from the public internet.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. Pin a Static IP

Identify your NIC (modern names like eno1, not eth0):

ip -br addr
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Horizon dashboard: http:// — admin password via:

grep keystoneadminpassword /etc/kolla/passwords.yml
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Tell OpenStack the FQDN (in globals.yml) and reconfigure:

kollaexternalfqdn: "dashboard.example.com"

kolla-ansible reconfigure -i ~/all-in-one --tags horizon,haproxy
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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

  1. Cloud images, not installer ISOs. cloudimg .img, never live-server .iso. This one cost the most.
  2. Match versions to the live support matrix  — newest ≠ supported. Check today, not from memory or an old blog.
  3. Verify OVS bridge changes actually applied  — ovs-vsctl list-ports br-ex after changing the external interface.
  4. Prechecks are your friend  — they fail specifically; read the error, fix, re-run.
  5. Have out-of-band access (JetKVM/IPMI). Networking changes will eventually drop your SSH, and the console saves you.
  6. CGNAT isn’t a blocker  — outbound tunnels (Cloudflare/Tailscale) sidestep it entirely.
  7. 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)