Automating Your Homelab with GitLab CI/CD and Terraform: Part 1 - Foundation & Setup
📚 This is Part 1 of a 3-part series:
- Part 1: Foundation & Setup (you are here)
- Part 2: Practical Application
- Part 3: Production-Ready Practices
Automating Your Homelab with GitLab CI/CD and Terraform: A Production-Ready Pipeline
Introduction
I recently built a CI/CD pipeline for my homelab infrastructure and it’s been a great learning experience. Instead of manually running Terraform commands from my desktop, I now push my code to GitLab, and my infrastructure updates automatically.
This post walks through building a CI/CD pipeline for Terraform using GitLab and Proxmox. At the end, we’ll have automated our infrastructure validation, planning, and deployment—all triggered through Git commits.
Why GitLab CI/CD for Homelab?
Before diving in, let me explain why I chose GitLab specifically:
Self-hosted GitLab CE is free - No paying for build minutes or runner capacity. Your hardware, your rules. I have it running on an Ubuntu VM in Proxmox. The runner is also running in Proxmox as a LXC container with Docker installed.
State management included - GitLab has a built-in HTTP backend for Terraform state with locking. No need for S3 or separate state storage complexity. Although, HashiCorp’s Cloud Platform (HCP) that includes Terraform Cloud has a generous free tier if you want to check that out.
Production ready - This is exactly how many companies manage infrastructure. Learning this in your homelab should hopefully translate directly to work.
Full control - Everything runs locally on my proxmox host. No secrets leave the network, no rate limits, and no external dependencies.
The Pipeline We’re Building
Here’s the complete .gitlab-ci.yml that powers my homelab:
stages: - validate - plan - apply
variables: # These are variables we'll set up in GitLab later TF_ROOT: ${CI_PROJECT_DIR} TF_VAR_proxmox_api_url: ${PROXMOX_VE_ENDPOINT} TF_VAR_proxmox_api_token_id: ${PROXMOX_VE_USERNAME} TF_VAR_proxmox_api_token_secret: ${PROXMOX_VE_PASSWORD}
.terraform-base: image: hashicorp/terraform:latest tags: - docker before_script: - cd ${TF_ROOT} - terraform --version - terraform init
validate: extends: .terraform-base stage: validate script: - terraform fmt -check - terraform validate
plan: extends: .terraform-base stage: plan script: - terraform plan -out=tfplan artifacts: paths: - tfplan expire_in: 1 week
apply: extends: .terraform-base stage: apply script: - terraform apply -auto-approve tfplan dependencies: - plan when: manual # Requires manual approval in GitLab only: - mainThis three-stage pipeline validates my terraform code, generates execution plans, and deploys infrastructure—but only after manual approval. Let’s break down each piece.
Prerequisites
What you’ll need:
- A Proxmox server
- GitLab CE installed (on a Proxmox VM like me or separately)
- GitLab Runner configured with Docker
- Basic Terraform knowledge
Understanding the Pipeline Structure
Stage 1: Validate
validate: extends: .terraform-base stage: validate script: - terraform fmt -check - terraform validateWhat it does: Runs on every commit to every branch. It checks:
- Code formatting (
terraform fmt -check) - Ensures consistent style - Syntax validation (
terraform validate) - Catches configuration errors
Why it matters: We should hopefully catch mistakes before they reach the plan stage. It’s not bulletproof but will be our first line of defense against bad configurations.
Real-world benefit: This stage should help catch typos, mismatched brackets, and invalid resource references before they cause problems.
Stage 2: Plan
plan: extends: .terraform-base stage: plan script: - terraform plan -out=tfplan artifacts: paths: - tfplan expire_in: 1 weekWhat it does: Generates an execution plan showing exactly what Terraform will create, modify, or destroy.
The artifact magic: The artifacts section saves the plan file for one week (or a timeframe of your choosing). This is crucial—the apply stage uses this plan to help prevent drift between planning and applying.
Why it matters: You can review the plan in GitLab’s UI before approving the deployment. No surprises.
Stage 3: Apply
apply: extends: .terraform-base stage: apply script: - terraform apply -auto-approve tfplan dependencies: - plan when: manual only: - mainWhat it does: Executes the saved plan to actually modify infrastructure.
The safety features:
when: manual- Requires clicking “Run” in the GitLab UIonly: main- Only runs on the main branchdependencies: plan- Uses the plan we created in the previous stageterraform apply -auto-approve tfplan- Applies the pre-approved plan
Why it matters: This prevents accidental deployments while still maintaining automation. You get one-click deployments after reviewing the plan and clicking “run” in GitLab.
The Base Template Pattern
.terraform-base: image: hashicorp/terraform:latest tags: - docker before_script: - cd ${TF_ROOT} - terraform --version - terraform initThe .terraform-base template (note the leading dot) defines common configurations that other jobs inherit via extends.
Benefits:
- DRY principle - Define once, use everywhere
- Consistent Terraform version across all stages
- Automatic initialization before every job
- Super easy to update—change the template, all jobs update
The Docker tag: tags: - docker tells GitLab to use runners with the “docker” tag. This is how you control which runners execute your jobs.
Environment Variables and Secrets
variables: TF_ROOT: ${CI_PROJECT_DIR} TF_VAR_proxmox_api_url: ${PROXMOX_VE_ENDPOINT} TF_VAR_proxmox_api_token_id: ${PROXMOX_VE_USERNAME} TF_VAR_proxmox_api_token_secret: ${PROXMOX_VE_PASSWORD}This is where the magic happens. GitLab automatically injects these as environment variables when the pipeline executes. This should keep them secure and require us to only define them once.
Setting up secrets in GitLab:
- Navigate to your GitLab project → Settings → CI/CD → Variables
- Add these variables:
PROXMOX_VE_ENDPOINT = https://192.168.1.100:8006/api2/jsonPROXMOX_VE_USERNAME = terraform@pve!terraform-tokenPROXMOX_VE_PASSWORD = your-api-token-secret-hereImportant: Mark PROXMOX_VE_PASSWORD as:
- ✅ Protected (only available to protected branches)
- ✅ Masked (hidden in job logs and UI)
- ✅ Expanded (allows variable references)
The TF_VAR_ prefix: Terraform automatically reads any environment variable starting with TF_VAR_ as an input variable. So TF_VAR_proxmox_api_url becomes var.proxmox_api_url in our Terraform code.
Setting Up Proxmox API Tokens
GitLab needs API access to Proxmox. Here’s how to set it up securely:
# SSH into your Proxmox serverssh root@your-proxmox-ip
# Create a dedicated user for Terraformpveum user add terraform@pve
# Create an API token (more secure than passwords)pveum user token add terraform@pve terraform-token --privsep=0
# Save the output - you'll need the token ID and secret!# Format: terraform@pve!{terraform-token}Create a custom role with minimal permissions:
pveum role add TerraformProv -privs "VM.Allocate 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.Audit VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit Pool.Allocate Sys.Audit Sys.Console Sys.Modify"
# Assign the rolepveum aclmod / -user terraform@pve -role TerraformProvWhy API tokens over passwords? Simplicity!
- Tokens are easily revoked without changing user passwords
- Tokens can have restricted privileges
- Tokens don’t expire (unless you set an expiration)
- More of an audit trail for visibility
Your Terraform Configuration
Here’s how your Terraform code connects to this pipeline:
terraform { required_version = ">= 1.0"
# GitLab HTTP backend for state management backend "http" { address = "https://gitlab.yourdomain.com/api/v4/projects/YOUR_PROJECT_ID/terraform/state/production" lock_address = "https://gitlab.yourdomain.com/api/v4/projects/YOUR_PROJECT_ID/terraform/state/production/lock" unlock_address = "https://gitlab.yourdomain.com/api/v4/projects/YOUR_PROJECT_ID/terraform/state/production/lock" username = "your-gitlab-username" password = "your-gitlab-personal-access-token" }
required_providers { proxmox = { source = "telmate/proxmox" version = "~> 2.9.14" } }}
# providers.tfprovider "proxmox" { pm_api_url = var.proxmox_api_url pm_api_token_id = var.proxmox_api_token_id pm_api_token_secret = var.proxmox_api_token_secret pm_tls_insecure = true # This is ok for our Homelab but in Production you'd want proper SSL certificates}
# variables.tfvariable "proxmox_api_url" { description = "Proxmox API URL" type = string}
variable "proxmox_api_token_id" { description = "Proxmox API token ID" type = string}
variable "proxmox_api_token_secret" { description = "Proxmox API token secret" type = string sensitive = true}
# main.tfresource "proxmox_vm_qemu" "test_vm" { name = "test-vm" target_node = "proxmox" clone = "ubuntu-2204-template"
cores = 2 memory = 2048
network { bridge = "vmbr0" model = "virtio" }
disk { storage = "local-lvm" type = "virtio" size = "20G" }}Finding your GitLab project ID: Go to your project in GitLab → Settings → General → Project ID (near the top).
While I’m keeping everything local in this example, HashiCorp’s Terraform Cloud is another great option for remote state management with built-in CI/CD integration. They also have a generous free tier for homelab use or smaller teams. I’ve tested it with this exact set up and had no issues.
Deploying GitLab CE on Proxmox
Before you can use GitLab CI/CD, you need GitLab itself. I know we’re trying to automate things, but to keep it simple to start we’ll manually install and configure GitLab. Make sure to adjust the commands below for your Proxmox enviroment:
# Create Ubuntu VM in Proxmox UI# SSH into it or use the VM Console in Proxmox UI, then:
sudo apt-get updatesudo apt-get install -y curl openssh-server ca-certificates perl
# Add GitLab repositorycurl -fsSL https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.deb.sh | sudo bash
# Install GitLab (replace with your domain/IP)sudo EXTERNAL_URL="http://192.168.1.50" apt-get install -y gitlab-ce
# Wait 5-10 minutes for installation to completesudo gitlab-ctl statusAccess GitLab at your configured URL. The initial root password can be found in /etc/gitlab/initial_root_password.
Setting Up GitLab Runner
Next, GitLab needs a runner to execute our CI/CD jobs. I run mine on an Ubuntu LXC in Proxmox with Docker installed. I chose this for simplicity but this could be set up on a VM, or somewhere else entirely, as long as GitLab can access it.
# Install Docker on Ubuntu LXC (if not already installed)apt-get updateapt-get install -y docker.io
# Run GitLab Runnerdocker run -d \ --name gitlab-runner \ --restart always \ -v /srv/gitlab-runner/config:/etc/gitlab-runner \ -v /var/run/docker.sock:/var/run/docker.sock \ gitlab/gitlab-runner:latest
# Register the runnerdocker exec -it gitlab-runner gitlab-runner registerDuring registration:
- GitLab URL:
http://your-gitlab-ip - Registration token: Found in GitLab → Admin → CI/CD → Runners
- Description:
homelab-docker-runner - Tags:
docker(important—this should match your.gitlab-ci.yml) - Executor:
docker - Default Docker image:
alpine:latest
Verify registration: In GitLab → Admin → CI/CD → Runners, you should see your new runner with a green status indicator.
The Complete Workflow
Now that we’ve got everything set up, here’s what happens when you push code:
1. Initial Setup
# Clone your GitLab repositorygit clone http://gitlab.yourdomain.com/youruser/terraform-homelab.gitcd terraform-homelab
# Add your Terraform files# (terraform.tf, providers.tf, variables.tf, main.tf, .gitlab-ci.yml)
# Commit and pushgit add .git commit -m "Initial Terraform configuration"git push origin main2. Pipeline Triggers
The moment you push the repo, GitLab will:
- Detect the
.gitlab-ci.ymlfile - Schedule jobs on available runners
- Create a new pipeline, visible in GitLab → CI/CD → Pipelines
3. Validate Stage Executes
The runner:
- Pulls
hashicorp/terraform:latestimage - Runs
terraform init - Executes
terraform fmt -check - Executes
terraform validate - Shows results in GitLab UI (✅ or ❌)
4. Plan Stage Executes
If validation passes:
- Runs
terraform plan -out=tfplan - Saves the plan file as an artifact
- Shows the plan output in job logs
- You can download the plan file from the GitLab UI
5. Manual Approval Required
The apply stage should show with a “Run” button in GitLab. Before clicking it, review:
- The plan output (what will be created/changed/destroyed)
- The commit that triggered the pipeline
- Any related merge requests
6. Apply Stage Executes
When you click the “Run” button:
- Downloads the saved plan artifact
- Runs
terraform apply -auto-approve tfplan - Creates/modifies/destroys resources on our Proxmox host
- Shows apply output in job logs
7. Profit!
Your infrastructure should be deployed now. Check the Proxmox UI to see your new VMs!
What’s Next?
In Part 2 of this series, we’ll walk through a real-world example of deploying infrastructure with this pipeline, explore advanced features like environment-based deployments and notifications, and cover common troubleshooting scenarios you might encounter.
📚 Continue the series:
- Part 1: Foundation & Setup (you just finished this!)
- Part 2: Practical Application - Deploy real infrastructure and add advanced features
- Part 3: Production-Ready Practices - Security, real projects, and professional skills
← Back to blog