<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sumit</title>
    <description>The latest articles on DEV Community by Sumit (@sumitga).</description>
    <link>https://dev.to/sumitga</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F59387%2Fb0ef172e-3d9d-456e-afb9-2c1b6bb23a6f.jpeg</url>
      <title>DEV Community: Sumit</title>
      <link>https://dev.to/sumitga</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sumitga"/>
    <language>en</language>
    <item>
      <title>I Built a Private Cloud That Runs an AI Safety System Under My Desk</title>
      <dc:creator>Sumit</dc:creator>
      <pubDate>Tue, 07 Jul 2026 11:56:17 +0000</pubDate>
      <link>https://dev.to/sumitga/i-built-a-private-cloud-that-runs-an-ai-safety-system-under-my-desk-3g5g</link>
      <guid>https://dev.to/sumitga/i-built-a-private-cloud-that-runs-an-ai-safety-system-under-my-desk-3g5g</guid>
      <description>&lt;p&gt;Picture a mineral processing plant at 3 AM. A methane sensor in the crusher hall starts climbing. Within &lt;strong&gt;5 milliseconds&lt;/strong&gt;, the breach is flagged at the edge. Within 50, it's glowing red on the control room screen. And within seconds, a locally hosted AI has studied the plant's layout, worked out which zones connect, what equipment is running, and where the airflow goes, and produced a step-by-step isolation blueprint: &lt;em&gt;shut down conveyor C4, reroute ventilation through shaft B, seal zone 7.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;No cloud bill. No third-party AI API. No open ports on the network.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;HazShield AI&lt;/strong&gt;, a distributed industrial safety system I'm building end-to-end, and the entire thing runs on a single mini PC under my desk. This series documents the build. Part 1 is about forging the platform: a genuine private cloud, carved out of 28GB of RAM, exposed to the world through a zero-trust tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The System in One Picture
&lt;/h2&gt;

&lt;p&gt;HazShield simulates a full industrial safety ecosystem: &lt;strong&gt;thousands of concurrent sensor streams&lt;/strong&gt; for gas, temperature, vibration, airflow, and seismic activity, pouring into a Rust ingestion gateway, evaluated against safety thresholds on every single reading, stored in a time-series database built for the firehose, and escalated to an AI planner when things go wrong.&lt;/p&gt;

&lt;p&gt;The design revolves around one idea I consider the heart of the whole project: &lt;strong&gt;not all data deserves the same promises.&lt;/strong&gt; Every reading entering the system is routed into one of three lanes.&lt;/p&gt;

&lt;p&gt;🔥 &lt;strong&gt;The Hot Lane&lt;/strong&gt; carries threshold violations. Sub-50ms from sensor to screen. &lt;em&gt;Never dropped, ever.&lt;/em&gt; If the message broker is unreachable, the edge spills violations to local disk and replays them when it recovers. A safety system that loses alarms isn't a safety system.&lt;/p&gt;

&lt;p&gt;🌊 &lt;strong&gt;The Warm Lane&lt;/strong&gt; carries bulk telemetry, batched and bulk-loaded into TimescaleDB thousands of rows at a time. Under extreme load, this lane &lt;em&gt;deliberately degrades&lt;/em&gt; by sampling 1-in-N readings for storage, while threshold evaluation still runs on 100% of them. Storage fidelity bends; safety never does.&lt;/p&gt;

&lt;p&gt;🧠 &lt;strong&gt;The Cold Lane&lt;/strong&gt; is AI planning. When an alarm opens, workers assemble the hazard context (which sensor, which zone, what's adjacent, what's running) and queue it for a quantized LLM running on Ollama. Local inference is precious, so identical hazard scenarios are deduplicated by prompt hash. An alarm storm of fifty related sensors produces one plan, not fifty.&lt;/p&gt;

&lt;p&gt;That asymmetry between what bends under pressure and what never does drives every technical decision in the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Private cloud&lt;/td&gt;
&lt;td&gt;OpenStack (Kolla-Ansible) on a single mini PC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provisioning&lt;/td&gt;
&lt;td&gt;Terraform + Ansible, 100% infrastructure-as-code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge ingestion&lt;/td&gt;
&lt;td&gt;Rust · Axum · Tokio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orchestration&lt;/td&gt;
&lt;td&gt;Python · FastAPI · async workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;PostgreSQL 16 + TimescaleDB · Redis Streams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;Ollama, quantized local models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control room&lt;/td&gt;
&lt;td&gt;React · TypeScript · WebSockets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public ingress&lt;/td&gt;
&lt;td&gt;Cloudflare Tunnel → Caddy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three VMs, three jobs. An &lt;strong&gt;edge node&lt;/strong&gt; that owns the public doorway and the ingestion gateway. A &lt;strong&gt;state node&lt;/strong&gt; running the time-series engine and the real-time message layer. A &lt;strong&gt;compute node&lt;/strong&gt; where the AI thinks. Each is sized to the byte after profiling what the workload actually needs, because the whole cloud has 28GB to spend and every gigabyte is contested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Squeezing a Cloud Into a Mini PC
&lt;/h2&gt;

&lt;p&gt;Here's the fun part nobody tells you about private clouds: OpenStack is built to manage thousands of hypervisors, and it &lt;em&gt;assumes&lt;/em&gt; it deserves production resources. A stock deployment spun up five API workers per service (five Neutron servers, five Keystone workers) to serve requests from exactly one human: me.&lt;/p&gt;

&lt;p&gt;After profiling the control plane container-by-container, I tuned it down to single-worker mode and stripped the services my toolchain makes redundant (Terraform replaces Heat entirely). Three lines of configuration reclaimed &lt;strong&gt;over 4GB of RAM&lt;/strong&gt;, the difference between a cloud that thrashes and one that hums:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;enable_heat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;
&lt;span class="na"&gt;openstack_service_workers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My favorite discovery came from the storage layer. While validating the scheduler's capacity math through OpenStack's Placement API, I found the hypervisor reporting a fraction of the disk I knew the machine had. The trail led all the way down the stack to the LVM layer, where Ubuntu Server's default install had left &lt;strong&gt;828GB of the SSD sitting unallocated&lt;/strong&gt;, invisible to everything above it. One online resize later, the cloud had nine times the storage, with no downtime and no reinstall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvextend &lt;span class="nt"&gt;-l&lt;/span&gt; +100%FREE /dev/ubuntu-vg/ubuntu-lv
&lt;span class="nb"&gt;sudo &lt;/span&gt;resize2fs /dev/ubuntu-vg/ubuntu-lv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run Ubuntu anywhere, check your volume group. You might be richer than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Public Domains. Zero Open Ports.
&lt;/h2&gt;

&lt;p&gt;The part of this build I show people first: HazShield has five public hostnames.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;app.…&lt;/code&gt; is the control room UI&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api.…&lt;/code&gt; handles orchestration and asset management&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stream.…&lt;/code&gt; carries live telemetry over WebSockets&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ingest.…&lt;/code&gt; is the sensor firehose&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;telemetry.…&lt;/code&gt; fronts Grafana observability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And my home router forwards &lt;strong&gt;nothing&lt;/strong&gt;. Not one port.&lt;/p&gt;

&lt;p&gt;The trick is a Cloudflare Tunnel: a lightweight daemon inside the edge VM dials &lt;em&gt;outbound&lt;/em&gt; to Cloudflare's network and holds the connection open. Public traffic rides Cloudflare's edge (TLS included), flows down the tunnel, and lands on a local Caddy instance that routes by hostname. From the internet's perspective, my house doesn't exist.&lt;/p&gt;

&lt;p&gt;One tunnel, one Caddy, one config file. That means CORS policy, security headers, and request-size limits live in exactly one place instead of being smeared across four services. Even the tunnel daemon itself runs in a hardened, resource-capped systemd sandbox (&lt;code&gt;MemoryMax=256M&lt;/code&gt;, no privileges, read-only filesystem) so nothing can starve the ingestion hot path that will share its node.&lt;/p&gt;

&lt;p&gt;The network &lt;em&gt;inside&lt;/em&gt; the cloud is just as locked down. Security group rules are expressed as intent ("the database accepts connections from members of the edge group") rather than as IP addresses. The state node's Postgres and Redis ports are unreachable from the internet, from my LAN, from anywhere except the two internal services that need them. Defense in depth, enforced twice: once at the cloud layer, once again in Postgres's own access control.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Database Designed to Survive the Firehose
&lt;/h2&gt;

&lt;p&gt;Do the math on "thousands of readings per second" and it gets scary fast: 5,000 readings/sec is &lt;strong&gt;430 million rows a day&lt;/strong&gt;. Store that naively and a 1TB SSD is dead in weeks.&lt;/p&gt;

&lt;p&gt;So the data layer is built as a lifecycle, not a table.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raw readings&lt;/strong&gt; land in a TimescaleDB hypertable in 2-hour chunks, compressed after 4 hours and &lt;em&gt;deleted after 48&lt;/em&gt;. Raw data is ephemeral by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous aggregates&lt;/strong&gt; roll everything into 10-second and 5-minute summaries, refreshed incrementally in the background. The dashboard reads these and never touches raw. Reads and writes are physically separated, which is why the UI stays fast while the firehose blasts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarm episodes&lt;/strong&gt; are stored forever. A sensor breaching at 1Hz for an hour produces &lt;em&gt;one&lt;/em&gt; alarm row with an updated timestamp, not 3,600 rows. The audit trail is permanent and compact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine is tuned for its 6GB VM with one deliciously counterintuitive setting: &lt;code&gt;synchronous_commit = off&lt;/code&gt;. Losing half a second of &lt;em&gt;bulk&lt;/em&gt; telemetry in a crash is a fine trade for write throughput, and alarm writes will override it per-transaction, because durability in this system is a &lt;strong&gt;per-write decision&lt;/strong&gt;, not a global one. Same philosophy on Redis: &lt;code&gt;maxmemory-policy noeviction&lt;/code&gt;, because a message layer that quietly discards data under pressure has no business carrying safety alarms. When Redis is full, it refuses loudly, and that refusal is precisely the backpressure signal the edge uses to trigger its local spill.&lt;/p&gt;

&lt;p&gt;Right after deployment, one query confirms the whole lifecycle machine is alive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proc_name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;timescaledb_information&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- compression ✓  retention ✓  aggregate refresh ✓  all scheduled before the first reading exists&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Moment It All Clicked
&lt;/h2&gt;

&lt;p&gt;Phase 1's finish line was a single request from my phone, on mobile data, standing in my kitchen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.&amp;lt;mydomain&amp;gt;/health
→ &lt;/span&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
&lt;span class="s"&gt;→ {"status":"ok","service":"hazshield-api","phase":"1-stub"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response headers tell the story: &lt;code&gt;server: cloudflare&lt;/code&gt; (it crossed the public internet), &lt;code&gt;cf-ray: …-PER&lt;/code&gt; (through Cloudflare's Perth edge), &lt;code&gt;via: 1.1 Caddy&lt;/code&gt; (down the tunnel, through the proxy), into a hardened service, out to a database-backed platform, and back to my hand. Every hop encrypted, every hop authenticated, zero ports open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What exists after Phase 1:&lt;/strong&gt; a memory-tuned private cloud, a fully codified tenant (every network, firewall rule, and VM is a &lt;code&gt;terraform apply&lt;/code&gt; away from resurrection), a zero-trust public ingress, and a data platform with its survival machinery already running. Not a hello-world. A foundation with opinions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Time: 10,000 Readings Per Second
&lt;/h2&gt;

&lt;p&gt;Phase 2 is where the system grows its heart: the &lt;strong&gt;Rust ingestion gateway&lt;/strong&gt;. A lock-free sensor registry that hot-swaps configuration without pausing the pipeline. Threshold evaluation inlined into the ingest path, costing microseconds per reading. A batching engine bulk-loading TimescaleDB while violations race down the hot lane.&lt;/p&gt;

&lt;p&gt;And then I try to kill it. A Python-based plant simulator with thousands of virtual sensors running realistic physics, scripted gas-plume scenarios spreading across zones, and a malicious sensor flooding at 100Hz, ramped to &lt;strong&gt;10,000 readings per second while I shoot the database mid-run&lt;/strong&gt;, to prove the one promise this whole architecture makes: bulk storage may bend, but the safety lane never breaks.&lt;/p&gt;

&lt;p&gt;The targets are already on the wall: p99 ingest latency under 10ms. Violations in the stream within 5ms. Zero alarms lost, no matter what fails.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Subscribe / follow along. Part 2 is where the Rust starts.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cloud</category>
      <category>infrastructure</category>
      <category>showdev</category>
    </item>
    <item>
      <title>From Click-Ops to Code: Managing My Self-Hosted OpenStack Cloud with Terraform</title>
      <dc:creator>Sumit</dc:creator>
      <pubDate>Thu, 02 Jul 2026 06:31:29 +0000</pubDate>
      <link>https://dev.to/sumitga/from-click-ops-to-code-managing-my-self-hosted-openstack-cloud-with-terraform-22i1</link>
      <guid>https://dev.to/sumitga/from-click-ops-to-code-managing-my-self-hosted-openstack-cloud-with-terraform-22i1</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of my homelab OpenStack series. I’d built a private cloud on a mini PC and provisioned VMs by hand. This is how I turned that entire tenant into reproducible Infrastructure as Code — and the wall I hit connecting Terraform to a self-hosted cloud.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Part 1 Left Off
&lt;/h3&gt;

&lt;p&gt;In the &lt;a href="https://dev.to/sumitga/building-a-self-hosted-openstack-private-cloud-on-a-mini-pc-behind-cgnat-no-public-ip-4p4m"&gt;first post&lt;/a&gt;, I built a full OpenStack private cloud on a single mini PC: every service in Docker via Kolla-Ansible, the dashboard public through a Cloudflare Tunnel, a Neutron router with floating IPs, and direct SSH from my laptop into a real Ubuntu VM.&lt;/p&gt;

&lt;p&gt;But everything was built by hand. Every network, router, security group, and VM came from typing openstack ... create one command at a time. If my cloud got wiped, I'd be rebuilding from memory.&lt;/p&gt;

&lt;p&gt;That’s the “pet” model — nurse each resource individually. The next step up is the “cattle” model: &lt;strong&gt;describe&lt;/strong&gt; what you want in code, and let a tool make reality match. Destroy everything, run one command, and the whole tenant rebuilds identically in about a minute.&lt;/p&gt;

&lt;p&gt;That tool is Terraform. This post is how I codified the entire tenant — and why connecting Terraform to a &lt;em&gt;self-hosted&lt;/em&gt; OpenStack was trickier than the official docs suggest.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mental Model: Two Layers
&lt;/h3&gt;

&lt;p&gt;The thing that finally made this click for me: there are &lt;strong&gt;two completely different layers&lt;/strong&gt; of cloud automation, and people conflate them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Building the cloud itself&lt;/strong&gt;  — installing Keystone, Nova, Neutron. That was Kolla-Ansible’s job. It’s the layer AWS hides from you entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using the cloud to create resources&lt;/strong&gt;  — networks, VMs, floating IPs &lt;em&gt;on&lt;/em&gt; the running cloud. That’s Terraform’s job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On AWS you only ever see the second layer, because Amazon already did the first. Running your own private cloud means you’re suddenly responsible for both. Terraform talks to OpenStack’s APIs — the same ones the CLI uses — so everything I’d done manually had a direct Terraform equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; the Terraform you write against OpenStack is structurally identical to Terraform against AWS, GCP, or Azure. Same init → plan → apply workflow, same HCL language — only the provider changes. Learn it once, use it everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting Up (the Safe Way)
&lt;/h3&gt;

&lt;p&gt;I’m cautious about anything that could touch a working setup, so I leaned hard on Terraform’s safety features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;terraform plan is a dry run.&lt;/strong&gt; It shows exactly what it &lt;em&gt;would&lt;/em&gt; create/change/destroy and changes nothing. You can run it endlessly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;terraform apply asks for confirmation.&lt;/strong&gt; Nothing happens until you type yes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform only manages what it created.&lt;/strong&gt; It keeps a state file of its own resources. My hand-built VM and networks were invisible to it — it literally cannot touch what it doesn’t know about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I named every new resource tf-* and used a different tenant CIDR (10.10.0.0/24 vs my existing 10.0.0.0/24), guaranteeing it would sit &lt;em&gt;alongside&lt;/em&gt; my manual setup, never replace it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Install + repo skeleton
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Terraform via HashiCorp's apt repo&lt;/span&gt;
wget &lt;span class="nt"&gt;-O-&lt;/span&gt; https://apt.releases.hashicorp.com/gpg | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/share/keyrings/hashicorp-archive-keyring.gpg
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;lsb_release &lt;span class="nt"&gt;-cs&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; main"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/hashicorp.list
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; terraform

&lt;span class="c"&gt;# git-ready project folder&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/openstack-terraform &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/openstack-terraform
git init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical first step — the **&lt;/strong&gt;.gitignore.** Terraform generates state and credential files that contain secrets and must &lt;em&gt;never&lt;/em&gt; hit GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
*.tfvars
clouds.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pushing a terraform.tfstate full of passwords is a classic, dangerous beginner mistake. Block it from line one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Wall: Connecting Terraform to a Self-Hosted Cloud
&lt;/h3&gt;

&lt;p&gt;This is where I lost the most time, and it’s the part tutorials gloss over because they assume a “normal” cloud. Here’s the chain of issues I hit, in order:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Wrong cloud name
&lt;/h3&gt;

&lt;p&gt;The provider reads credentials from clouds.yaml. My first error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: cloud admin does not exist in clouds.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kolla names its cloud entry kolla-admin, not admin. Fixed the provider block to match.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The public FQDN trap
&lt;/h3&gt;

&lt;p&gt;Next, terraform plan hung for a full minute then failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Get "http://dashboard.example.com:9696/v2.0/networks": connection error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: my clouds.yaml pointed at my &lt;strong&gt;public Cloudflare domain&lt;/strong&gt;. But Cloudflare only tunnels the dashboard (ports 80/443) — &lt;em&gt;not&lt;/em&gt; the OpenStack API ports like 9696 (Neutron) or 5000 (Keystone). So Terraform was trying to reach Neutron through a tunnel that has no route for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; point Terraform at the &lt;strong&gt;internal VIP&lt;/strong&gt; directly. Since Terraform runs on the host, it can reach the internal endpoints with no tunnel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# clouds.yaml&lt;/span&gt;
&lt;span class="na"&gt;auth_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://192.168.0.250:5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The service catalog still pointed public
&lt;/h3&gt;

&lt;p&gt;Even after fixing auth_url, it failed again on :9696 at the public domain. Why? After authenticating, Terraform asks Keystone "where are the other services?" via the &lt;strong&gt;service catalog&lt;/strong&gt;  — and the catalog had the public FQDN registered for Neutron.&lt;/p&gt;

&lt;p&gt;Checking the endpoints confirmed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openstack endpoint list &lt;span class="nt"&gt;--service&lt;/span&gt; network
&lt;span class="c"&gt;# public → http://dashboard.example.com:9696 (the one that hangs)&lt;/span&gt;
&lt;span class="c"&gt;# internal → http://192.168.0.250:9696 (the one I want)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clean fix is &lt;strong&gt;endpoint overrides&lt;/strong&gt; in the provider, forcing Terraform straight to internal URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"openstack"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cloud&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"kolla-admin"&lt;/span&gt;

  &lt;span class="nx"&gt;endpoint_overrides&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"identity"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://192.168.0.250:5000/v3/"&lt;/span&gt;
    &lt;span class="s2"&gt;"network"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://192.168.0.250:9696/v2.0/"&lt;/span&gt;
    &lt;span class="s2"&gt;"compute"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://192.168.0.250:8774/v2.1/"&lt;/span&gt;
    &lt;span class="s2"&gt;"image"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://192.168.0.250:9292/"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; the public FQDN is for the human-facing dashboard only. Programmatic API access (Terraform, CLI) uses internal endpoints. That separation is normal and correct — but you have to wire it explicitly when the catalog defaults to public.&lt;/p&gt;

&lt;p&gt;Once that was right, terraform plan read my existing external network and returned its ID — authenticated, talking to my cloud, creating nothing. The "hello world" of IaC.&lt;/p&gt;

&lt;h3&gt;
  
  
  Writing the Whole Tenant as Code
&lt;/h3&gt;

&lt;p&gt;With the connection solid, I codified everything. A clean Terraform project splits into files by purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;main.tf — provider + all resources&lt;/li&gt;
&lt;li&gt;variables.tf — configurable inputs with defaults&lt;/li&gt;
&lt;li&gt;outputs.tf — values printed after apply (the floating IP, an SSH command)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The resources mirror exactly what I’d built by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Tenant network + subnet&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"openstack_networking_network_v2"&lt;/span&gt; &lt;span class="s2"&gt;"tenant_net"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tf-net"&lt;/span&gt;
  &lt;span class="nx"&gt;admin_state_up&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"openstack_networking_subnet_v2"&lt;/span&gt; &lt;span class="s2"&gt;"tenant_subnet"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tf-subnet"&lt;/span&gt;
  &lt;span class="nx"&gt;network_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;openstack_networking_network_v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenant_net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.10.0.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;ip_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="nx"&gt;dns_nameservers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"1.1.1.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"8.8.8.8"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Router bridging tenant &amp;lt;-&amp;gt; external&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"openstack_networking_router_v2"&lt;/span&gt; &lt;span class="s2"&gt;"router"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tf-router"&lt;/span&gt;
  &lt;span class="nx"&gt;external_network_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openstack_networking_network_v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;external&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"openstack_networking_router_interface_v2"&lt;/span&gt; &lt;span class="s2"&gt;"router_iface"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;router_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;openstack_networking_router_v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;openstack_networking_subnet_v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenant_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Security group + SSH/ICMP rules, keypair, VM, floating IP...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The “aha” moment
&lt;/h3&gt;

&lt;p&gt;Two things make this feel like magic the first time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependencies are automatic.&lt;/strong&gt; The subnet references tenant_net.id, the router references the subnet, the floating IP associates to the VM. Terraform reads these references, builds a dependency graph, and creates everything in the &lt;em&gt;correct order&lt;/em&gt; — I never specify it. Run apply and you watch the keypair, security group, network, subnet, router, and router-interface all materialize in seconds, in dependency order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Helper functions.&lt;/strong&gt; cidrhost("10.10.0.0/24", 1) computes .1 of the range. Change the CIDR once and the gateway and allocation pool follow. That's the power of code over clicking.&lt;/p&gt;

&lt;p&gt;A terraform plan showed the full picture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan: 11 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + ssh_command = (known after apply)
  + vm_floating_ip = (known after apply)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven resources — my entire tenant — defined, validated, changing nothing until I said so.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bugs I Hit Applying It
&lt;/h3&gt;

&lt;p&gt;Because I was working with a current provider version, a few things had drifted from older examples:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provider version drift.&lt;/strong&gt; The resource I first used to attach a floating IP didn’t exist anymore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The provider does not support resource type
"openstack_compute_floatingip_associate_v2".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the v3 provider, floating IP association moved to the &lt;em&gt;networking&lt;/em&gt; side. Swapped to openstack_networking_floatingip_associate_v2, which attaches via a port_id instead of an instance ID. &lt;strong&gt;Lesson:&lt;/strong&gt; provider resources get renamed across major versions; the errors are precise, so fixes are quick once you know the replacement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The floating IP wouldn’t attach.&lt;/strong&gt; Even after the swap, the association silently didn’t happen — the vm.network[0].port attribute came back empty. The robust fix was looking up the port explicitly with a data source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"openstack_networking_port_v2"&lt;/span&gt; &lt;span class="s2"&gt;"vm_port"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;device_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;openstack_compute_instance_v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;network_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;openstack_networking_network_v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenant_net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"openstack_networking_floatingip_associate_v2"&lt;/span&gt; &lt;span class="s2"&gt;"fip_assoc"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;floating_ip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;openstack_networking_floatingip_v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;
  &lt;span class="nx"&gt;port_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openstack_networking_port_v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vm_port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This queries “which port belongs to this VM on this network” — far more reliable than the array-index attribute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copy-paste mangling.&lt;/strong&gt; A couple of errors came from values losing their variable {} wrapper or a URL dropping a colon (192.168.0.250.9696 instead of :9696). The lesson there: when pasting multi-line config, heredocs (cat &amp;gt; file &amp;lt;&amp;lt;'EOF') preserve formatting that a terminal paste sometimes mangles.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Payoff: Idempotency
&lt;/h3&gt;

&lt;p&gt;The moment it all clicked was a partial failure. My first apply created 10 of 11 resources, then failed on the VM (an image-name mismatch). I fixed the name and ran apply again.&lt;/p&gt;

&lt;p&gt;Terraform didn’t recreate the 10 existing resources. It saw they already existed, left them alone, and built &lt;em&gt;only&lt;/em&gt; the missing VM. That’s &lt;strong&gt;idempotency&lt;/strong&gt;  — re-running converges to the desired state without redoing finished work. It’s what makes Terraform safe to run over and over.&lt;/p&gt;

&lt;p&gt;Final result: apply builds the whole tenant and prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh_command &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ssh ubuntu@192.168.0.233"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command, ~60 seconds, the entire manual process — reproducible, version-controlled, in a git repo. And terraform destroy cleanly removes only the tf-* resources, nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons Worth Keeping
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Public FQDN ≠ API endpoint.&lt;/strong&gt; Tunnels expose the dashboard; Terraform and the CLI use internal endpoints. Override them explicitly when the catalog defaults to public.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the service catalog&lt;/strong&gt; (openstack endpoint list) when connections hang — the URL Terraform actually uses comes from there, not just auth_url.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider versions drift.&lt;/strong&gt; Resources get renamed across major versions. Read the precise error, find the replacement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use data sources for reliable lookups&lt;/strong&gt;  — vm.network[0].port can be empty; an explicit port data source isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;.gitignore your state and credentials first.&lt;/strong&gt; Never commit *.tfstate or clouds.yaml.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;plan is free and safe.&lt;/strong&gt; Run it constantly. Name new resources distinctly (tf-*) so they sit beside, never replace, existing infra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency is the whole point.&lt;/strong&gt; Re-running apply fixes only what's missing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why This Was Worth It
&lt;/h3&gt;

&lt;p&gt;I went from “I can click together a cloud” to “I can declare a cloud in code, version it, reproduce it, and destroy it on demand.” That second skill is the one that transfers directly to any cloud platform and any real infrastructure job.&lt;/p&gt;

&lt;p&gt;The most valuable part wasn’t the working result — it was debugging the connection layer myself. Self-hosted OpenStack doesn’t hand you a clean managed endpoint like AWS does; you have to understand auth, the service catalog, and internal-vs-public routing to make Terraform work. That understanding is exactly what’s missing when you only ever consume someone else’s cloud.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What’s the trickiest thing you’ve had to wire Terraform into? Drop it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>openstack</category>
      <category>cloudopenstack</category>
      <category>privatecloud</category>
    </item>
    <item>
      <title>Building a Self-Hosted OpenStack Private Cloud on a Mini PC (Behind CGNAT, No Public IP)</title>
      <dc:creator>Sumit</dc:creator>
      <pubDate>Sat, 27 Jun 2026 08:54:07 +0000</pubDate>
      <link>https://dev.to/sumitga/building-a-self-hosted-openstack-private-cloud-on-a-mini-pc-behind-cgnat-no-public-ip-4p4m</link>
      <guid>https://dev.to/sumitga/building-a-self-hosted-openstack-private-cloud-on-a-mini-pc-behind-cgnat-no-public-ip-4p4m</guid>
      <description>&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why I Did This
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;cloud orchestration platform&lt;/strong&gt;  — 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.&lt;/p&gt;

&lt;p&gt;The goal was concrete:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A working OpenStack private cloud on a single mini PC.&lt;/li&gt;
&lt;li&gt;The dashboard reachable from the public internet.&lt;/li&gt;
&lt;li&gt;The ability to provision VMs and SSH into them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had one complication that turns out to be very common: my home internet is behind &lt;strong&gt;CGNAT&lt;/strong&gt;  — no public IP, no port forwarding possible. Spoiler: that didn’t stop anything. It just shaped the architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hardware
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GMKtec mini PC&lt;/strong&gt;  — Intel CPU with VT-x, &lt;strong&gt;32 GB RAM&lt;/strong&gt; , &lt;strong&gt;1 TB NVMe&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two onboard NICs&lt;/strong&gt; (one 2.5GbE, one 1GbE)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;JetKVM&lt;/strong&gt; for out-of-band access (KVM-over-IP) — this turned out to be a lifesaver&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;MacBook&lt;/strong&gt; as my workstation&lt;/li&gt;
&lt;li&gt;A domain managed in &lt;strong&gt;Cloudflare&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proxmox vs OpenStack: The Mental Model
&lt;/h3&gt;

&lt;p&gt;If you’re coming from Proxmox like I was, the key reframe is this: &lt;strong&gt;the hypervisor layer is identical (KVM/QEMU + libvirt). Everything above it is what’s different.&lt;/strong&gt; On Proxmox you click “create VM.” On OpenStack, that same action is decomposed across services that each own one job:&lt;/p&gt;

&lt;p&gt;What you did on Proxmox OpenStack equivalent Service Web UI / API Horizon dashboard / CLI Horizon Users &amp;amp; permissions Tokens, projects, roles Keystone ISO / templates &lt;strong&gt;Cloud images&lt;/strong&gt; (qcow2) Glance Create VM, size it Instance from a &lt;strong&gt;flavor&lt;/strong&gt; Nova Bridges, NAT, VLANs Virtual networks, routers, ports Neutron Manual per-VM firewall &lt;strong&gt;Security groups&lt;/strong&gt; Neutron Port-forward to a VM &lt;strong&gt;Floating IP&lt;/strong&gt; (1:1 NAT) Neutron&lt;/p&gt;

&lt;p&gt;Two things bit me coming from Proxmox, so internalize them early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud images, not ISOs.&lt;/strong&gt; OpenStack boots pre-built cloud images that self-configure on first boot via cloud-init. You do &lt;em&gt;not&lt;/em&gt; click through an installer. (This caused my single biggest time sink — more later.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security groups default to deny.&lt;/strong&gt; A new VM blocks all inbound until you explicitly open ports.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Architecture: Working Around CGNAT
&lt;/h3&gt;

&lt;p&gt;Since I had no public IP, the design splits cleanly into two planes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Admin plane&lt;/strong&gt;  — me reaching the dashboard and SSHing into VMs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service plane&lt;/strong&gt;  — public web traffic to services (future).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both reach the outside world through &lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; , which dials &lt;em&gt;outbound&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;Key realization: &lt;strong&gt;Cloudflare Tunnel is perfect for HTTP/HTTPS (the dashboard, web services), but it cannot give a VM raw arbitrary-port public access.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deployment Method: Why Kolla-Ansible
&lt;/h3&gt;

&lt;p&gt;Three real options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DevStack&lt;/strong&gt;  — dev/test only, explicitly not for anything you keep. Rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenStack-Ansible&lt;/strong&gt;  — production-grade but heavy and complex; wrong place to start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kolla-Ansible&lt;/strong&gt;  — production-grade, runs every service as a &lt;strong&gt;Docker container&lt;/strong&gt; , great single-node “all-in-one” support, real upgrade path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a container-comfortable beginner who wants something real that can grow, Kolla-Ansible is the clear pick. Debugging is just docker logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Build, Step by Step
&lt;/h3&gt;

&lt;h3&gt;
  
  
  1. Install Ubuntu Server (the right version!)
&lt;/h3&gt;

&lt;p&gt;This is where I hit my first real lesson. My instinct was “use the newest LTS.” &lt;strong&gt;Wrong.&lt;/strong&gt; OpenStack and Kolla only support specific, tested host OS + release combinations, and the matrix moves fast.&lt;/p&gt;

&lt;p&gt;After checking the &lt;strong&gt;live&lt;/strong&gt; support matrix (not a guide, not memory):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ubuntu 24.04 LTS&lt;/strong&gt; as the host&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kolla-Ansible / OpenStack 2025.1 “Epoxy”&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I initially installed 22.04 based on outdated guidance, then discovered 2024.1 had been retired to unmaintained/ and 2025.1 had &lt;strong&gt;dropped 22.04 support&lt;/strong&gt;. The fix without reinstalling everything: an &lt;strong&gt;in-place upgrade&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; update-manager-core
&lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="nt"&gt;-release-upgrade&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Always match your host OS to what the deployer officially tests &lt;em&gt;today&lt;/em&gt;. “Latest” is a liability in infrastructure, not a feature.&lt;/p&gt;

&lt;p&gt;BIOS prerequisites (do these first, via JetKVM):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable &lt;strong&gt;Intel VT-x / virtualization&lt;/strong&gt; (non-negotiable — VMs won’t run without it)&lt;/li&gt;
&lt;li&gt;Disable &lt;strong&gt;Secure Boot&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify virtualization after boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;egrep &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'(vmx|svm)'&lt;/span&gt; /proc/cpuinfo &lt;span class="c"&gt;# &amp;gt;0 means enabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Pin a Static IP
&lt;/h3&gt;

&lt;p&gt;Identify your NIC (modern names like eno1, not eth0):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set a static IP in netplan &lt;strong&gt;outside&lt;/strong&gt; your router’s DHCP range, with JetKVM open in case you lock yourself out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/netplan/00-installer-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;ethernets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;eno1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;dhcp4&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;192.168.0.200/24&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
          &lt;span class="na"&gt;via&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.0.1&lt;/span&gt;
      &lt;span class="na"&gt;nameservers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1.1.1.1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;8.8.8.8&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="s"&gt;sudo netplan apply&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Install Kolla-Ansible
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# host prep&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; git python3-dev libffi-dev gcc libssl-dev python3-venv python3-pip
&lt;span class="nb"&gt;sudo &lt;/span&gt;timedatectl set-ntp &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# isolated venv&lt;/span&gt;
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv ~/kolla-venv
&lt;span class="nb"&gt;source&lt;/span&gt; ~/kolla-venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; pip
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s1"&gt;'ansible-core&amp;gt;=2.16'&lt;/span&gt;

&lt;span class="c"&gt;# clone the EXACT supported branch with git (more reliable than pip's git+ shallow clone)&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~
git clone &lt;span class="nt"&gt;--branch&lt;/span&gt; stable/2025.1 https://opendev.org/openstack/kolla-ansible
pip &lt;span class="nb"&gt;install&lt;/span&gt; ./kolla-ansible
kolla-ansible install-deps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; pip install git+...&lt;a class="mentioned-user" href="https://dev.to/branch"&gt;@branch&lt;/a&gt; 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 .&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Configure globals.yml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/kolla &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt;:&lt;span class="nv"&gt;$USER&lt;/span&gt; /etc/kolla
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; ~/kolla-venv/share/kolla-ansible/etc_examples/kolla/&lt;span class="k"&gt;*&lt;/span&gt; /etc/kolla/
&lt;span class="nb"&gt;cp&lt;/span&gt; ~/kolla-venv/share/kolla-ansible/ansible/inventory/all-in-one ~/
kolla-genpwd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key settings (single-node, single-NIC NAT to start):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kollabasedistro&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ubuntu"&lt;/span&gt;
&lt;span class="na"&gt;openstack_release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2025.1"&lt;/span&gt;
&lt;span class="na"&gt;kollainternal_vipaddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.0.250"&lt;/span&gt; &lt;span class="c1"&gt;# a SPARE LAN IP, not the host's&lt;/span&gt;
&lt;span class="na"&gt;network_interface&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eno1"&lt;/span&gt;
&lt;span class="na"&gt;neutronexternalinterface&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dummy1"&lt;/span&gt; &lt;span class="c1"&gt;# see note below&lt;/span&gt;
&lt;span class="na"&gt;neutronpluginagent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ovn"&lt;/span&gt;
&lt;span class="na"&gt;enable_haproxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The single-NIC trick:&lt;/strong&gt; Neutron’s external interface must be a “naked” (no-IP) interface, and it must &lt;em&gt;not&lt;/em&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add dummy1 &lt;span class="nb"&gt;type &lt;/span&gt;dummy
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;dummy1 up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Bootstrap → Prechecks → Deploy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kolla-ansible bootstrap-servers &lt;span class="nt"&gt;-i&lt;/span&gt; ~/all-in-one &lt;span class="c"&gt;# installs Docker, etc.&lt;/span&gt;
kolla-ansible prechecks &lt;span class="nt"&gt;-i&lt;/span&gt; ~/all-in-one &lt;span class="c"&gt;# validates config&lt;/span&gt;
kolla-ansible deploy &lt;span class="nt"&gt;-i&lt;/span&gt; ~/all-in-one &lt;span class="c"&gt;# pulls images, starts everything (~30 min)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prechecks are your friend — they fail loudly and specifically. I hit a chain of them, each a quick fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No module named 'docker'&lt;/strong&gt; → pip install docker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No module named 'dbus'&lt;/strong&gt; → sudo apt install -y libdbus-1-dev libdbus-glib-1-dev pkg-config &amp;amp;&amp;amp; pip install dbus-python&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Please enable at least one backend for Cinder&lt;/strong&gt; → I set enable_cinder: "no" for the POC (VMs run fine on ephemeral disk; add block storage later)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also needed &lt;strong&gt;passwordless sudo&lt;/strong&gt; for the deploy user so Ansible can escalate non-interactively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt; ALL=(ALL) NOPASSWD: ALL"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sudoers.d/&lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful run ends with failed=0. Mine: ok=449 changed=273 failed=0. 449 tasks, a full cloud, in containers.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;python-openstackclient
kolla-ansible post-deploy &lt;span class="nt"&gt;-i&lt;/span&gt; ~/all-in-one
&lt;span class="nb"&gt;source&lt;/span&gt; /etc/kolla/admin-openrc.sh
openstack service list &lt;span class="c"&gt;# keystone, nova, neutron, glance, placement... all there&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Horizon dashboard: http:// — admin password via:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;keystoneadminpassword /etc/kolla/passwords.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Provisioning the First VM
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Image — CirrOS first to test the pipeline (tiny, 16MB)&lt;/span&gt;
wget http://download.cirros-cloud.net/0.6.2/cirros-0.6.2-x86_64-disk.img
openstack image create &lt;span class="s2"&gt;"cirros"&lt;/span&gt; &lt;span class="nt"&gt;--file&lt;/span&gt; cirros-0.6.2-x86_64-disk.img &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disk-format&lt;/span&gt; qcow2 &lt;span class="nt"&gt;--container-format&lt;/span&gt; bare &lt;span class="nt"&gt;--public&lt;/span&gt;

&lt;span class="c"&gt;# Flavor (CPU/RAM/disk)&lt;/span&gt;
openstack flavor create &lt;span class="nt"&gt;--vcpus&lt;/span&gt; 2 &lt;span class="nt"&gt;--ram&lt;/span&gt; 2048 &lt;span class="nt"&gt;--disk&lt;/span&gt; 20 m1.small

&lt;span class="c"&gt;# Private tenant network&lt;/span&gt;
openstack network create tenant-net
openstack subnet create tenant-subnet &lt;span class="nt"&gt;--network&lt;/span&gt; tenant-net &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-range&lt;/span&gt; 10.0.0.0/24 &lt;span class="nt"&gt;--gateway&lt;/span&gt; 10.0.0.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dns-nameserver&lt;/span&gt; 1.1.1.1 &lt;span class="nt"&gt;--allocation-pool&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.0.0.10,end&lt;span class="o"&gt;=&lt;/span&gt;10.0.0.200

&lt;span class="c"&gt;# Security group (open SSH + ping)&lt;/span&gt;
openstack security group create vm-access
openstack security group rule create vm-access &lt;span class="nt"&gt;--protocol&lt;/span&gt; tcp &lt;span class="nt"&gt;--dst-port&lt;/span&gt; 22 &lt;span class="nt"&gt;--remote-ip&lt;/span&gt; 0.0.0.0/0
openstack security group rule create vm-access &lt;span class="nt"&gt;--protocol&lt;/span&gt; icmp &lt;span class="nt"&gt;--remote-ip&lt;/span&gt; 0.0.0.0/0

&lt;span class="c"&gt;# SSH key + launch&lt;/span&gt;
openstack keypair create &lt;span class="nt"&gt;--public-key&lt;/span&gt; ~/.ssh/id_ed25519.pub mykey
openstack server create &lt;span class="nt"&gt;--image&lt;/span&gt; cirros &lt;span class="nt"&gt;--flavor&lt;/span&gt; m1.small &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt; tenant-net &lt;span class="nt"&gt;--security-group&lt;/span&gt; vm-access &lt;span class="nt"&gt;--key-name&lt;/span&gt; mykey my-first-vm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ACTIVE with a 10.0.0.x IP = a VM running on your own cloud. 🎉&lt;/p&gt;

&lt;h3&gt;
  
  
  Networking: The Keystone Concept (Router + Floating IPs)
&lt;/h3&gt;

&lt;p&gt;VMs on a private 10.0.0.x network can reach &lt;em&gt;out&lt;/em&gt; (via NAT) but aren't reachable &lt;em&gt;in&lt;/em&gt;. To SSH into them cleanly, you need a &lt;strong&gt;Neutron router&lt;/strong&gt; bridging the tenant network to an &lt;strong&gt;external network&lt;/strong&gt; , plus &lt;strong&gt;floating IPs&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# External (provider) network&lt;/span&gt;
openstack network create &lt;span class="nt"&gt;--external&lt;/span&gt; &lt;span class="nt"&gt;--provider-network-type&lt;/span&gt; flat &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provider-physical-network&lt;/span&gt; physnet1 external-net
openstack subnet create external-subnet &lt;span class="nt"&gt;--network&lt;/span&gt; external-net &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-range&lt;/span&gt; 192.168.0.0/24 &lt;span class="nt"&gt;--gateway&lt;/span&gt; 192.168.0.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allocation-pool&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.224,end&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.254 &lt;span class="nt"&gt;--no-dhcp&lt;/span&gt;

&lt;span class="c"&gt;# Router wiring both sides&lt;/span&gt;
openstack router create main-router
openstack router &lt;span class="nb"&gt;set &lt;/span&gt;main-router &lt;span class="nt"&gt;--external-gateway&lt;/span&gt; external-net
openstack router add subnet main-router tenant-subnet

&lt;span class="c"&gt;# Floating IP → VM&lt;/span&gt;
openstack floating ip create external-net
openstack server add floating ip my-first-vm &amp;lt;FLOATING-IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Single-NIC Limitation (and the real fix)
&lt;/h3&gt;

&lt;p&gt;With OVN, there’s &lt;strong&gt;no&lt;/strong&gt;  &lt;strong&gt;qrouter namespace&lt;/strong&gt; 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 &lt;strong&gt;second physical NIC&lt;/strong&gt; as the external interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# globals.yml&lt;/span&gt;
neutronexternalinterface: &lt;span class="s2"&gt;"enp3s0"&lt;/span&gt; &lt;span class="c"&gt;# real NIC, cabled, no IP&lt;/span&gt;

kolla-ansible reconfigure &lt;span class="nt"&gt;-i&lt;/span&gt; ~/all-in-one &lt;span class="nt"&gt;--tags&lt;/span&gt; neutron
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; 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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;openvswitch_vswitchd ovs-vsctl list-ports br-ex &lt;span class="c"&gt;# showed dummy1&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;openvswitch_vswitchd ovs-vsctl del-port br-ex dummy1
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;openvswitch_vswitchd ovs-vsctl add-port br-ex enp3s0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the floating IP pinged from the whole LAN. &lt;strong&gt;Lesson:&lt;/strong&gt; when you change neutron_external_interface, verify the physical port actually moved on br-ex — don't assume reconfigure did it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the Dashboard Public via Cloudflare Tunnel
&lt;/h3&gt;

&lt;p&gt;Two halves that must match: the tunnel &lt;strong&gt;and&lt;/strong&gt; telling OpenStack its external hostname.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tunnel:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb&lt;/span&gt;
&lt;span class="s"&gt;sudo dpkg -i cloudflared-linux-amd64.deb&lt;/span&gt;
&lt;span class="s"&gt;cloudflared tunnel login&lt;/span&gt;
&lt;span class="s"&gt;cloudflared tunnel create openstack&lt;/span&gt;
&lt;span class="s"&gt;cloudflared tunnel route dns openstack dashboard.example.com&lt;/span&gt;

&lt;span class="c1"&gt;# ~/.cloudflared/config.yml&lt;/span&gt;
&lt;span class="na"&gt;tunnel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openstack&lt;/span&gt;
&lt;span class="na"&gt;credentials-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/home/user/.cloudflared/&amp;lt;TUNNEL-ID&amp;gt;.json&lt;/span&gt;
&lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dashboard.example.com&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://192.168.0.250:80&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http_status:404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tell OpenStack the FQDN&lt;/strong&gt; (in globals.yml) and reconfigure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kollaexternalfqdn: &lt;span class="s2"&gt;"dashboard.example.com"&lt;/span&gt;

kolla-ansible reconfigure &lt;span class="nt"&gt;-i&lt;/span&gt; ~/all-in-one &lt;span class="nt"&gt;--tags&lt;/span&gt; horizon,haproxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The “Cookies may be turned off” fix:&lt;/strong&gt; Horizon behind an HTTPS-terminating proxy needs to trust the forwarded protocol. Create /etc/kolla/config/horizon/_9999-custom-settings.py:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SECUREPROXY_SSL_HEADER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP_X_FORWARDEDPROTO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;CSRFTRUSTEDORIGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://dashboard.example.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;CSRFCOOKIESECURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;SESSIONCOOKIESECURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then kolla-ansible reconfigure -i ~/all-in-one --tags horizon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the tunnel permanent&lt;/strong&gt; (systemd, survives reboots):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/cloudflared
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/.cloudflared/config.yml ~/.cloudflared/&lt;span class="k"&gt;*&lt;/span&gt;.json /etc/cloudflared/
&lt;span class="c"&gt;# point credentials-file in the copied config at /etc/cloudflared/&amp;lt;UUID&amp;gt;.json&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/system/cloudflared.service &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[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
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; cloudflared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dashboard now public at &lt;a href="https://dashboard.example.com" rel="noopener noreferrer"&gt;https://dashboard.example.com&lt;/a&gt;, over HTTPS, through CGNAT, surviving reboots.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bug That Cost Me the Most: Cloud Image vs Installer ISO
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The culprit, revealed only when I opened the &lt;strong&gt;noVNC console&lt;/strong&gt; : the VM was sitting at the &lt;strong&gt;Ubuntu installer’s language-selection screen.&lt;/strong&gt; I had registered an &lt;strong&gt;installer ISO&lt;/strong&gt; in Glance, not a &lt;strong&gt;cloud image.&lt;/strong&gt; An installer never runs cloud-init, never injects your key — it’s just waiting for someone to install an OS.&lt;/p&gt;

&lt;p&gt;The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The REAL cloud image — note "cloudimg" in the name, ~600MB .img&lt;/span&gt;
wget https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
openstack image create &lt;span class="s2"&gt;"ubuntu-24.04"&lt;/span&gt; &lt;span class="nt"&gt;--file&lt;/span&gt; ubuntu-24.04-server-cloudimg-amd64.img &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disk-format&lt;/span&gt; qcow2 &lt;span class="nt"&gt;--container-format&lt;/span&gt; bare &lt;span class="nt"&gt;--public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The keeper lesson:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud images&lt;/strong&gt; = cloudimg, ~500–700 MB .img (qcow2). Use these for OpenStack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installer ISOs&lt;/strong&gt; = live-server, ~2–3 GB .iso. These are for installing on bare metal. &lt;strong&gt;Never&lt;/strong&gt; for cloud VMs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After swapping to the real cloud image, cloud-init ran, injected the key, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-R&lt;/span&gt; &amp;lt;floating-ip&amp;gt; &lt;span class="c"&gt;# clear stale host key from a previous VM on that IP&lt;/span&gt;
ssh ubuntu@&amp;lt;floating-ip&amp;gt; &lt;span class="c"&gt;# straight into Ubuntu 24.04, passwordless&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(That REMOTE HOST IDENTIFICATION HAS CHANGED warning is expected when you recreate a VM on a reused IP — ssh-keygen -R clears it.)&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Ended Up With
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A bare-metal &lt;strong&gt;OpenStack private cloud&lt;/strong&gt; (Kolla-Ansible, 2025.1, Ubuntu 24.04), every service containerized&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Horizon publicly accessible&lt;/strong&gt; via Cloudflare Tunnel, no public IP, surviving reboots&lt;/li&gt;
&lt;li&gt;Full Neutron networking: tenant network, external network, router, floating IPs on a real NIC&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct, key-authenticated SSH from my laptop&lt;/strong&gt; into a real Ubuntu VM&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Lessons Worth Tattooing
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloud images, not installer ISOs.&lt;/strong&gt; cloudimg .img, never live-server .iso. This one cost the most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match versions to the live support matrix&lt;/strong&gt;  — newest ≠ supported. Check today, not from memory or an old blog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify OVS bridge changes actually applied&lt;/strong&gt;  — ovs-vsctl list-ports br-ex after changing the external interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prechecks are your friend&lt;/strong&gt;  — they fail specifically; read the error, fix, re-run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have out-of-band access&lt;/strong&gt; (JetKVM/IPMI). Networking changes &lt;em&gt;will&lt;/em&gt; eventually drop your SSH, and the console saves you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CGNAT isn’t a blocker&lt;/strong&gt;  — outbound tunnels (Cloudflare/Tailscale) sidestep it entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get it working simply first, then add the clean architecture.&lt;/strong&gt; Single-NIC NAT → then the router and second NIC.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Where to Go Next
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;bastion + ingress&lt;/strong&gt; architecture for secure multi-VM access&lt;/li&gt;
&lt;li&gt;Public web services on VMs via more Cloudflare tunnels (subdomain-per-service)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform&lt;/strong&gt; with the OpenStack provider to codify the whole tenant as reproducible infrastructure&lt;/li&gt;
&lt;li&gt;Re-enable &lt;strong&gt;Cinder&lt;/strong&gt; for persistent block storage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;em&gt;use&lt;/em&gt; a cloud; you understand every layer because you had to fight through each one yourself.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Got questions or hit a different wall? Drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudcomputing</category>
      <category>openstackprivateclou</category>
      <category>privatecloudhosting</category>
    </item>
  </channel>
</rss>
