Automating Your Homelab with GitLab CI/CD and Terraform: Part 1 - Foundation & Setup Automating Your Homelab with GitLab CI/CD and Terraform: Part 1 - Foundation & Setup

Automating Your Homelab with GitLab CI/CD and Terraform: Part 1 - Foundation & Setup

📚 This is Part 1 of a 3-part series:

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:
- main

This 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 validate

What 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 week

What 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:
- main

What it does: Executes the saved plan to actually modify infrastructure.

The safety features:

  • when: manual - Requires clicking “Run” in the GitLab UI
  • only: main - Only runs on the main branch
  • dependencies: plan - Uses the plan we created in the previous stage
  • terraform 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 init

The .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:

  1. Navigate to your GitLab project → Settings → CI/CD → Variables
  2. Add these variables:
PROXMOX_VE_ENDPOINT = https://192.168.1.100:8006/api2/json
PROXMOX_VE_USERNAME = terraform@pve!terraform-token
PROXMOX_VE_PASSWORD = your-api-token-secret-here

Important: 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:

Terminal window
# SSH into your Proxmox server
ssh root@your-proxmox-ip
# Create a dedicated user for Terraform
pveum 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:

Terminal window
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 role
pveum aclmod / -user terraform@pve -role TerraformProv

Why 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.tf
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.tf
provider "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.tf
variable "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.tf
resource "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:

Terminal window
# Create Ubuntu VM in Proxmox UI
# SSH into it or use the VM Console in Proxmox UI, then:
sudo apt-get update
sudo apt-get install -y curl openssh-server ca-certificates perl
# Add GitLab repository
curl -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 complete
sudo gitlab-ctl status

Access 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.

Terminal window
# Install Docker on Ubuntu LXC (if not already installed)
apt-get update
apt-get install -y docker.io
# Run GitLab Runner
docker 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 runner
docker exec -it gitlab-runner gitlab-runner register

During 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

Terminal window
# Clone your GitLab repository
git clone http://gitlab.yourdomain.com/youruser/terraform-homelab.git
cd terraform-homelab
# Add your Terraform files
# (terraform.tf, providers.tf, variables.tf, main.tf, .gitlab-ci.yml)
# Commit and push
git add .
git commit -m "Initial Terraform configuration"
git push origin main

2. Pipeline Triggers

The moment you push the repo, GitLab will:

  1. Detect the .gitlab-ci.yml file
  2. Schedule jobs on available runners
  3. Create a new pipeline, visible in GitLab → CI/CD → Pipelines

3. Validate Stage Executes

The runner:

  • Pulls hashicorp/terraform:latest image
  • 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:


← Back to blog