I've been thinking a lot about how I want to approach this DevOps series moving forward, and I decided to switch things up. Instead of walking through a test pipeline build first, I'm jumping straight into one of the main pipelines in this environment. There's a lot to cover So let's get into it.
The Problem with Doing Everything by Hand
Up until this point, every time I needed a new server or container in Proxmox, I was creating it manually. Clicking through the UI, picking specs, waiting for it to boot, configuring it from scratch. It gets the job done, but it doesn't scale and it definitely doesn't belong in an environment where the whole goal is automation.
Before I can build anything else, I need an infrastructure as code strategy in place. Now that my repo is up and running, that's exactly what this video is about. The goal is simple: build a pipeline that automates VM deployment inside Proxmox. Everything else comes after. One piece at a time.
Setting Up the GitLab Structure
The first thing I did was get my GitLab structure in order, because structure matters a lot once you start scaling. Here's how I organized it. At the top level I have a main group that will eventually house everything in this environment. Inside that I created a subgroup specifically for infrastructure work. That's where the first repo lives, which I'm calling Proxmox IAC (Infrastructure as Code). This repo is going to hold all of the Terraform code and pipeline configuration for managing Proxmox resources.
The reason for this layered approach is pretty straightforward. As the environment grows, I'll be adding more repos under the infrastructure subgroup. Keeping things nested and labeled from the start means I'm not untangling a mess later. Subgroups in GitLab also inherit settings from their parent group, which comes in handy down the road.
The Tools That Make It All Work
Before we touch a single config file, I want to make sure the toolchain is clear. A lot of DevOps content talks about these tools in isolation, but the magic is really in how they connect. Here's what we're working with and why each piece matters.
- GitLab is the brain of the whole operation. It stores the code and is responsible for triggering and running the pipelines. If you haven't seen the previous video in this series where I set GitLab up from scratch, go watch that first. You'll need that context.
GitLab Runners are the hands that do the actual work. A runner is a service that lives on your Proxmox server and listens for jobs from GitLab. When the pipeline fires, GitLab tells the runner what to do and the runner goes and does it.
- Terraform (or OpenTofu) is what takes the infrastructure code we write and turns it into actual virtual machines. OpenTofu is an open source fork of Terraform and the two are essentially interchangeable for our purposes. You write the specs, Terraform builds the VM.
- Proxmox is where all the compute lives. But there are a few specific things that need to be set up inside Proxmox before GitLab can communicate with it and start building things. We'll walk through all of that.
- Docker is used by the pipeline to pull down and run the Terraform container on the runner. The pipeline handles the Docker installation automatically, so you don't have to set it up manually, but it's important to know it's part of the picture.
How Everything Flows Together
Here's the full picture from start to finish. It all begins inside GitLab. When you're ready to build, you kick off the pipeline, either manually through the GitLab UI or by pushing a code change from your local machine. GitLab then hands that job off to the runner sitting on your Proxmox server. The runner spins up a Docker container, pulls down the Terraform image, and Terraform gets to work. It uses an API token to authenticate with Proxmox, grabs the Cloud-Init template we set up, and provisions a brand new VM based on whatever specs we defined in our code. That means CPU, RAM, storage, and networking are all defined in code, not clicked through a UI. Once the build is done, the pipeline cleans up after itself. The container is destroyed and the runner returns to a clean state, ready for the next job.
That's the beauty of this whole setup. The next time you need a new VM, you update your Terraform code, run the pipeline, and the whole process repeats itself. No manual clicking required.
Creating the Cloud-Init Template
The Cloud-Init template is a reusable VM image that automatically configures itself on first boot. It handles things like user accounts, SSH keys, networking, and startup scripts. Every time Terraform needs to spin up a new VM, it clones this template. This is the foundation everything else builds on.
The process starts by downloading a cloud image. For this build I'm using Ubuntu, so I grabbed the appropriate cloud image from the official Ubuntu cloud images page. The specific file you want is the one labeled with cloudimage-amd64.img that also includes qcow2, uefi, and gpt in the label. Right click, copy the link.
Back in the Proxmox shell, I navigated to the ISO folder and used wget to pull that image down. From there, I created an empty VM shell in Proxmox. The hardware specs you set here don't really matter because they'll be defined by your pipeline code later. This VM is just a shell. Give it a name you'll remember, note down the VM ID (you'll need it), and skip the disk during creation since we're going to import the cloud image as the disk ourselves.
Once the VM is created, the next step is importing the cloud image into Proxmox-managed storage and attaching it to the VM. This is necessary because cloud images are standalone disk files that need to be imported before Proxmox can use them. The import command also handles any format conversion needed to make the image compatible with your storage backend.
With the disk imported and attached to the VM, there are a few more things to configure. First, add a Cloud-Init drive from the hardware menu. This is what allows you to set the username, password, SSH key, DNS, and networking configuration that gets applied to the VM on every boot.
Once that drive is added, head into the Cloud-Init tab and set your credentials and network config. I'm setting mine to DHCP for now, which means the VM will request an IP address automatically when it hits the network. You can change this to a static assignment later once your pipeline is handling IP provisioning.
The last thing to configure is boot order. Go to the Options tab, find Boot Order, and make sure your disk is checked and at the top of the list. That tells Proxmox to boot from the Cloud-Init drive first.
Once everything is configured, start the VM and verify it boots correctly and that you can log in with the credentials you set in Cloud-Init. If it works, you're good. Shut the VM down completely, right click on it, and select Convert to Template. That's it. You now have a reusable Cloud-Init template that Terraform will clone every time the pipeline runs. Make a note of the template's VM ID and name. You'll need both when we start writing the pipeline code.
Setting Up the Proxmox API Token
The API token is what gives Terraform permission to communicate with Proxmox and tell it what to build. Without this, GitLab and Proxmox can't talk to each other.
The first thing I did here was create a dedicated role with only the permissions this automation actually needs. Best practice is to avoid giving your automation user full admin access. A scoped role with the right privileges keeps things tight. In Proxmox, you can create a new role from the Datacenter menu under Permissions and then Roles. Give it a name that makes sense, something like GitLabProvision, and assign the specific privileges you want to grant.
Next I created a dedicated user for Terraform. Again, this is separate from any admin accounts. Automation tools should have their own scoped credentials. Create the user, select the Proxmox authentication server as the realm, set a password, and you're done.
With the user in place, generate the API token. Go to Datacenter, Permissions, API Tokens, and add a new token assigned to the Terraform user. When the token is generated, copy the secret immediately. You will not be able to see it again after you close that dialog. If you miss it, you'll have to generate a new one.
The last step is assigning permissions. You need to grant both the user and the token access to the appropriate path. I set this at the root level since this user will eventually need to create and manage resources across the environment. Select the user, assign the role you created, and make sure Propagate is checked so the permissions flow down through the resource tree. Then do the same for the token. When it's all done, both the user and the token should appear under Permissions with the correct role assigned.
That's everything on the Proxmox side. GitLab Runner installation is still coming, but I want to save that for the next video along with the actual pipeline build.
Command Reference Guide
This section is for when you're ready to build. Everything here assumes you've read through Part One and understand the context behind each step. Use this as your reference as you work through the setup in your own environment.
Download the Cloud Image
SSH into your Proxmox host shell and navigate to your ISO folder.
- cd /var/lib/vz/template/iso
Pull down the Ubuntu cloud image using the link you copied from the Ubuntu cloud images page.
- wget <paste-image-url-here>
The file you're looking for on the Ubuntu cloud images site is the noble/current folder. Grab the file that includes cloudimage-amd64.img, qcow2, uefi, and gpt in the label.
Create the VM Shell in Proxmox
Use the Proxmox UI to create a new VM with these settings:
- Skip the CD/DVD disk (select "Do not use any media")
- Set BIOS to UEFI
- Remove the default disk (we're importing our own)
- Set CPU and RAM to minimal values (specs will be defined by your pipeline)
- Assign it to the correct network bridge for your environment
Note down the VM ID before you finish
Import the Cloud Image as a Disk
Run this command on your Proxmox host. Replace <VMID>, <image-filename>, and <storage-name> with your actual values.
- qm importdisk <VMID> <image-filename> <storage-name>
Example:
- qm importdisk 1001 ubuntu-24.04-server-cloudimg-amd64.img local-ssd
After import, go to the VM's Hardware tab in the Proxmox UI, double-click the unused disk, and add it as the primary disk (Bus: virtio0 or scsi0 depending on your preference).
Add the Cloud-Init Drive
In the Proxmox UI, go to your VM's Hardware tab and click Add. Select Cloud-Init Drive and choose the same storage where your disk lives.
Once added, navigate to the Cloud-Init tab on the VM and configure:
- User: your preferred username
- Password: set a secure password
- SSH Keys: add your public key if you plan to use key-based auth
- DNS Domain / DNS Servers: set if applicable
- IP Config: set to DHCP for now (or static if your pipeline will handle IP assignment)
Upgrade packages: set to Yes
Set Boot Order
Go to the VM's Options tab and click Boot Order. Make sure your primary disk is checked and at the top of the list.
Test the VM
Start the VM, open the console, and verify it boots and that you can log in with the credentials you set in Cloud-Init.
- Log in with the username and password you configured in Cloud-Init
- Then confirm sudo access
- sudo su
Once confirmed, shut down the VM completely.
Convert to Template
In the Proxmox UI, right-click the VM (make sure it's fully stopped) and select Convert to Template. Confirm when prompted.
Note the template's VM ID and name. You'll reference both in your Terraform code.
Create a Scoped Role in Proxmox
In the Proxmox UI, go to Datacenter > Permissions > Roles > Create.
Give it a name (example: GitLabProvision) and assign the following privileges at minimum:
- Datastore.Allocate
- Datastore.AllocateSpace
- Datastore.Audit
- SDN.Use
- Sys.AccessNetwork
- Sys.Audit
- VM.Allocate
- VM.Audit
- VM.Backup
- VM.Clone
- VM.Config.CDROM
- VM.Config.CPU
- VM.Config.Cloudinit
- VM.Config.Disk
- VM.Config.HWType
- VM.Config.Memory
- VM.Config.Network
- VM.Config.Options
- VM.PowerMgmt
- VM.Snapshot
Adjust based on what you need your pipeline to be able to do.
Create a Dedicated Terraform User
In the Proxmox UI, go to Datacenter > Permissions > Users > Add.
- User: terraform (or whatever name you prefer)
- Realm: pve (Proxmox VE authentication server)
- Set a password
Generate the API Token
Go to Datacenter > Permissions > API Tokens > Add.
- User: select the Terraform user you just created
- Token ID: give it a name (example: gitlab-token)
- Uncheck "Privilege Separation" if you want the token to inherit the user's permissions directly
- When the token is generated, copy both the Token ID and the Secret immediately. The secret is shown only once.
Store them somewhere safe. You'll add them to GitLab as CI/CD variables in the next phase of the build.
Assign Permissions to the User and Token
Go to Datacenter > Permissions > Add > User Permission.
Path: / (root, for environment-wide access)
- User: your Terraform user
- Role: the role you created (example: GitLabProvision)
- Propagate: checked
- Repeat the same steps for the token. Click Add > Token Permission and set the same path, select your API token, assign the same role, and check Propagate.
When done, both entries should appear in the Permissions list with the correct role assigned to each.
That's everything you need to have in place before building the pipeline. In the next part of this series, we'll set up CI/CD variables in GitLab, install and register the GitLab Runner on the Proxmox host, and start writing the Terraform code that ties everything together.