A few weeks ago I built an Azure VM with Terraform from my laptop. It worked, but I still had to SSH in, install nginx and copy my website over by hand.
This time I wanted all of that to happen on its own. I run git push from Git Bash, and a live website shows up on a brand new VM. The website is a copy of a public GitHub project called Omnifood, and the workflow clones it onto the server for me. No portal clicking, no SSH from me.
It took me a few hours and a lot of red error messages. In this post I'll show you what worked, what broke, and how I destroyed everything at the end so Azure doesn't charge me for a VM I forgot about.
(The name "Hagital" comes from my school, Hagital Tech Institute. Use your own names when you follow along.)
What we're building
git push from my laptop
-> GitHub Actions wakes up
-> Terraform builds the VM on Azure
-> GitHub logs into the VM over SSH
-> nginx is installed, then the website is cloned from GitHub over nginx's default page
-> You open the IP address and see a live site
And there's a second path for cleanup: click Run workflow, and a destroy job deletes everything.
Words I'll use
- Terraform: a tool where you describe your cloud stuff in files, and it builds it for you.
- State file: Terraform's notebook of what it has built so far.
- Service principal: a robot account. GitHub uses it to log into Azure.
- Secret: a hidden value in GitHub (passwords, keys) that your workflow can use but nobody can read.
- Workflow: a YAML file that tells GitHub Actions what to run and when.
What you need
- An Azure account and the Azure CLI
- Terraform installed
- VS Code and Git Bash (I'm on Windows)
- A GitHub account
- An SSH key pair
My GitHub repo
Part 1: Build the Terraform files on my laptop
Make a folder and open it
mkdir Hagital-Project1
cd Hagital-Project1
code .
Then I made an empty main.tf:
touch main.tf
Give Terraform a safe place for its notebook
If the state file sits on my laptop, GitHub's computer can't see it. Then it wouldn't know what already exists and couldn't delete it later. So the state lives in an Azure storage account. I made three things with the CLI:
az group create \
--name terraform-state-rg \
--location eastus
az storage account create \
--name hagitaldavid2026 \
--resource-group terraform-state-rg \
--sku Standard_LRS
az storage container create \
--name tfstate \
--account-name hagitaldavid2026 \
--auth-mode login
Storage account names must be unique across all of Azure, so yours can't be hagitaldavid2026. When you see "created": true, you're good.
Provider and backend
This goes at the top of main.tf. The backend block tells Terraform where the notebook lives.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.4.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "YOUR-SUBSCRIPTION-ID"
}
terraform {
backend "azurerm" {
resource_group_name = "terraform-state-rg"
storage_account_name = "hagitaldavid2026"
container_name = "tfstate"
key = "terraform-project.tfstate"
}
}
The actual resources
Resource group, network, subnet and public IP:
resource "azurerm_resource_group" "hagital_rg" {
name = "hagital-rg"
location = "East US"
}
resource "azurerm_virtual_network" "hagital_vnet" {
name = "hagital-vnet"
resource_group_name = azurerm_resource_group.hagital_rg.name
location = azurerm_resource_group.hagital_rg.location
address_space = ["10.0.0.0/16"]
}
resource "azurerm_subnet" "hagital_subnet" {
name = "hagital-subnet"
resource_group_name = azurerm_resource_group.hagital_rg.name
virtual_network_name = azurerm_virtual_network.hagital_vnet.name
address_prefixes = ["10.0.0.0/24"]
}
resource "azurerm_public_ip" "hagital_ip" {
name = "hagital-ip"
resource_group_name = azurerm_resource_group.hagital_rg.name
location = azurerm_resource_group.hagital_rg.location
allocation_method = "Static"
sku = "Standard"
}
Network security group. Think of it as a door guard. I opened port 22 for SSH and port 80 for the website:
resource "azurerm_network_security_group" "hagital_nsg" {
name = "hagital-nsg"
resource_group_name = azurerm_resource_group.hagital_rg.name
location = azurerm_resource_group.hagital_rg.location
security_rule {
name = "Allow-SSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "Allow-HTTP"
priority = 101
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
Network card, tied to the NSG:
resource "azurerm_network_interface" "hagital_nic" {
name = "hagital-nic"
resource_group_name = azurerm_resource_group.hagital_rg.name
location = azurerm_resource_group.hagital_rg.location
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.hagital_subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.hagital_ip.id
}
}
resource "azurerm_network_interface_security_group_association" "hagital_nic_nsg" {
network_interface_id = azurerm_network_interface.hagital_nic.id
network_security_group_id = azurerm_network_security_group.hagital_nsg.id
}
The Linux VM (Ubuntu 22.04, small and cheap):
resource "azurerm_linux_virtual_machine" "hagital_vm" {
name = "hagital-vm"
resource_group_name = azurerm_resource_group.hagital_rg.name
location = azurerm_resource_group.hagital_rg.location
size = "Standard_B1s"
admin_username = "adminuser"
network_interface_ids = [
azurerm_network_interface.hagital_nic.id,
]
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-LTS"
version = "latest"
}
admin_ssh_key {
username = "adminuser"
public_key = file("~/.ssh/id_rsa.pub")
}
}
Look at that last line, file("~/.ssh/id_rsa.pub"). It reads the key from a path on my laptop. Remember it, because it causes trouble later.
Init and plan
terraform init
terraform plan
init set up the Azure backend and downloaded the provider. It also created a .terraform folder and a .terraform.lock.hcl file. The plan said 8 to add, 0 to change, 0 to destroy.
Variables and outputs
Hard-coded names are fine for a first try, but I wanted them in a variables.tf file:
touch variables.tf
Every value got its own variable block, like this:
variable "resource_group_name" {
description = "hagital-rg"
type = string
default = "hagital-rg"
}
I did the same for location, network name, subnet name, public IP name, NSG name, NIC name and VM name. Two variables have no default on purpose, so they must be given a value when Terraform runs:
variable "subscription_id" {
description = "Azure subscription ID"
type = string
}
variable "public_key" {
description = "admin user ssh public key"
type = string
}
Then I changed main.tf to use them, for example subscription_id = var.subscription_id and name = var.resource_group_name.
Next, outputs.tf. It prints the VM's public IP and username after the build:
touch outputs.tf
output "hagital_vm_public_ip" {
description = "Public IP address of the Hagital virtual machine"
value = azurerm_public_ip.hagital_ip.ip_address
}
output "hagital_vm_username" {
description = "Username of the Hagital virtual machine"
value = azurerm_linux_virtual_machine.hagital_vm.admin_username
}
I ran terraform fmt to tidy the files. It listed the two files it fixed.
Then I ran terraform plan again. Because subscription_id and public_key have no defaults, Terraform stopped and asked me to type them in.
I typed the subscription ID and got another clean plan: 8 to add, plus my two outputs.
Keep that typing prompt in mind. It comes back later.
Part 2: Let GitHub talk to Azure
GitHub's computer needs permission to build things in my Azure account. I made a service principal (the robot account) with the Contributor role:
az ad sp create-for-rbac `
--name "hagital-cicd" `
--role contributor `
--scopes /subscriptions/YOUR-SUBSCRIPTION-ID `
--sdk-auth
It printed a JSON block with the login details. Azure warned me that --sdk-auth is deprecated. It still worked for me.
Then I made a private repo on GitHub with a README:
Adding the secrets
In the repo I went to Settings > Secrets and variables > Actions > New repository secret and added four secrets:
| Secret name | What I pasted in |
|---|---|
HAGITAL_AZURE_CREDENTIALS |
The whole JSON from the command above |
HAGITAL_SUBSCRIPTION_ID |
My subscription ID |
HAGITAL_SSH_PUBLIC_KEY |
Output of cat ~/.ssh/id_rsa.pub
|
HAGITAL_SSH_PRIVATE_KEY |
Output of cat ~/.ssh/id_rsa, including the BEGIN and END lines |
If you don't have a key pair yet, ssh-keygen -t rsa -b 4096 makes one.
Last, I made an environment for the workflow to deploy into:
One small thing I noticed later. I named it "Hagital Production" here, but my YAML says hagital-production. GitHub doesn't complain, it just makes a new environment from the YAML. Keep the names identical so you don't end up with a stray one.
Part 3: Git and GitHub (where the fun started)
Error 1: "not a valid object name: 'master'"
I ran git init, and Git put me on a branch called master. I wanted main, so I typed:
git branch main
Git said no:
fatal: not a valid object name: 'master'
The reason is simple. A branch is a pointer to a commit, and I had zero commits. There was nothing to point at. So I did git status, git add . and git commit first.
After that commit, the same commands worked:
git branch main
git switch main
(Notice what was inside that first commit. It matters in Part 5.)
.gitignore
I made a .gitignore and added the .terraform/ folder to it.
touch .gitignore
Creating the workflow file on GitHub
To make the workflow file, I used the GitHub website. Add file > Create new file. When I typed .github/workflows/deploy.yaml in the name box, GitHub made both folders for me.
Then I copied the repo's HTTPS link:
And connected my laptop folder to it:
git remote add origin https://github.com/4thman/Hagital-project.git
git pull origin main --allow-unrelated-histories
The --allow-unrelated-histories part is there because GitHub already had its own first commit (the README and my new YAML file), and my laptop had a totally separate history. Git refuses to join two histories that don't share a parent unless you say it's okay.
Part 4: Write the workflow
The workflow has two jobs. provision builds the VM. configure waits for provision, then sets up the website.
Job 1: provision
name: Hagital VM Deploy
on:
push:
branches: ["main"]
workflow_dispatch:
jobs:
provision:
runs-on: ubuntu-latest
environment: hagital-production
outputs:
vm_ip: ${{ steps.tf_output.outputs.vm_ip }}
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
creds: ${{ secrets.HAGITAL_AZURE_CREDENTIALS }}
- uses: hashicorp/setup-terraform@v3
- name: Prepare SSH Public Key for Terraform
run: |
mkdir -p ~/.ssh
echo "${{ secrets.HAGITAL_SSH_PUBLIC_KEY }}" > ~/.ssh/id_rsa.pub
- name: Terraform Init
run: terraform init
- name: Terraform Plan
run: |
terraform plan \
-var="subscription_id=${{ secrets.HAGITAL_SUBSCRIPTION_ID }}" \
-out=hagital.tfplan
- name: Terraform Apply
run: terraform apply hagital.tfplan
- name: Get VM Public IP
id: tf_output
run: echo "vm_ip=$(terraform output -raw hagital_vm_public_ip)" >> "$GITHUB_OUTPUT"
The important parts:
-
on:says run when I push tomain, or when I click the button myself (workflow_dispatch). -
azure/loginuses my robot account secret. - The last step reads the VM's IP from Terraform and saves it as a job output. That's how the second job learns where the VM is.
Job 2: configure
configure:
needs: provision
runs-on: ubuntu-latest
steps:
- name: Prepare SSH Private Key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.HAGITAL_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Wait for SSH to become Available
run: |
for i in $(seq 1 10); do
ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa \
adminuser@${{ needs.provision.outputs.vm_ip }} echo ready && break
echo "VM not Ready Yet, Waiting 15 Seconds..."
sleep 15
done
- name: Install and configure the website
run: |
ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa adminuser@${{ needs.provision.outputs.vm_ip }} '
sudo apt update && sudo apt upgrade -y
sudo apt install nginx git -y
cd /var/www/html
sudo rm -f index.nginx-debian.html
sudo git clone https://github.com/MettaSurendhar/omnifood.git .
sudo chown -R www-data:www-data /var/www/html
sudo systemctl restart nginx
'
- name: Show the live Website URL
run: |
echo "Hagital Website is Live and was deployed using CI/CD." >> "$GITHUB_STEP_SUMMARY"
echo "Visit: http://${{ needs.provision.outputs.vm_ip }}" >> "$GITHUB_STEP_SUMMARY"
In plain words:
-
needs: provisionmeans "don't start until the VM exists." - A brand new VM isn't ready for SSH right away. So the wait step tries to connect up to 10 times, 15 seconds apart.
- Once in, the script installs nginx and git, then swaps nginx's default page for the website. More on that just below.
- The last step writes the site's URL into the run summary, so I don't have to dig for the IP.
Where the website comes from
I didn't build the website myself. I used a public GitHub project called Omnifood (https://github.com/MettaSurendhar/omnifood), and the workflow copies it onto the VM. Here's how that works:
- nginx is a web server. It shows whatever files sit in
/var/www/html. Right after you install it, that folder only holds nginx's default "Welcome to nginx" page (index.nginx-debian.html). - The script deletes that page with
sudo rm -f index.nginx-debian.html. Git can only clone into an empty folder, so the default page has to go first. - Then
sudo git clone https://github.com/MettaSurendhar/omnifood.git .downloads the Omnifood files straight into that folder. The dot at the end means "put the files here, don't make a new folder." -
chowngives nginx permission to read the files, andsystemctl restart nginxmakes it pick them up.
So when someone opens the VM's IP address, they see Omnifood, not the nginx welcome page. Want to show your own site? Change that one clone URL.
Back in VS Code, I saved the file and committed:
git status
git add .
git commit -m "wrote the Hagital deploy & configure script on the deploy.yaml file"
Part 5: Push day (three errors in a row)
Error 2: GitHub rejected my push
git push -u origin main
remote: error: File .terraform/providers/.../terraform-provider-azurerm_v5.4.0_x5.exe is 219.00 MB;
this exceeds GitHub's file size limit of 100.00 MB
remote: error: GH001: Large files detected.
The provider file that Terraform downloads is 219 MB, and GitHub's limit is 100 MB. I had put .terraform/ in .gitignore, so what happened?
Remember my very first commit in Part 3? I ran git add . before I made the .gitignore. Git had already saved that giant file. A .gitignore only stops Git from picking up new files. It does nothing about files that were already committed.
I added the state files to .gitignore too, then committed:
.terraform/
*.tfstate
*tfstate.*
The push failed again, with the same error. Deleting the file in a new commit doesn't help, because the old commit still carries it. The big file lives in my history.
While I was fixing things, I also changed the VM's key line in main.tf. The path ~/.ssh/id_rsa.pub only exists on my laptop, so I switched to the variable:
public_key = var.public_key
The fix that worked: this repo was brand new and had nothing worth saving, so I deleted Git's history and started over:
rm -rf .git
git init
git add .
git commit -m "removed git and readded git then re staged an commit"
This time .terraform wasn't in the commit. Seven small files went in.
⚠️ Don't do this on a real project. rm -rf .git wipes your whole history. I could do it because I had exactly two commits and both were disposable.
Error 3: the merge conflict
Same trick as before: git branch main failed until after the first commit. Once I had a commit, I made main and tried to join my history with GitHub's:
git merge origin/main --allow-unrelated-histories
Automatic merge failed; fix conflicts and then commit the result.
Both sides had a deploy.yaml, and Git couldn't pick one. My prompt changed to (main|MERGING). I wanted my local version, so:
git checkout --ours .github/workflows/deploy.yaml
git add .github/workflows/deploy.yaml
git commit -m "Merge GitHub main into local main"
The prompt went back to (main).
)
Then the push finally went through:
git push -u origin main
Your turn: look at your own repo's file list right now. Is anything in there that shouldn't be? Check before you push, not after.
Part 6: Watching GitHub Actions do the work
I opened the Actions tab and my workflow was already running.
Error 4: the workflow that waited forever
The provision job sat on Terraform Plan. Two minutes. Five minutes. Twenty minutes.
Quick guess before you scroll: what do you think it was waiting for?
Remember the prompt from Part 1, where Terraform asked me to type the public key? That's what happened. My main.tf now used var.public_key, but my workflow only passed subscription_id. So Terraform asked for the key and waited for someone to type it. Nobody types in GitHub Actions. It just sat there until the run gave up after 19 minutes and 50 seconds.
The fix is one extra line in the plan step:
- name: Terraform Plan
run: |
terraform plan \
-var="subscription_id=${{ secrets.HAGITAL_SUBSCRIPTION_ID }}" \
-var="public_key=${{ secrets.HAGITAL_SSH_PUBLIC_KEY }}" \
-out=hagital.tfplan
I also changed the apply line to terraform apply -auto-approve hagital.tfplan.
Error 5: my commit message broke Bash
When I committed the fix, I wrote a commit message that had ${{ secrets... }} inside double quotes. Bash tried to read it as a command and said:
bash: ... : bad substitution
Nothing got committed. I wrote a normal message instead (Add public key variable to Terraform plan) and it worked. Keep commit messages plain.
Then I pushed:
git push origin main
It works
That git push is the trigger. The on: push lines in the YAML tell GitHub to start the workflow every time new code lands on main.
A new run started. provision finished in 35 seconds. configure began.
The "Install and configure the website" step took a while, because apt was updating a whole Ubuntu machine.
The log gets long and ends with warnings about a pending kernel upgrade and services to restart. That's normal. Ubuntu is saying "a reboot would be nice." The job still finished green.
Here's the run list. Run #4 is green (4 minutes 19 seconds). Run #2 is the stuck one from before.
And here's my favourite part, the summary page, with the link my last step wrote:
I clicked the IP address. No "Welcome to nginx" page. It was the Omnifood website, cloned from GitHub onto a server that didn't exist 5 minutes earlier:
I did nothing by hand except push code.
Part 7: Delete everything with one click
A VM that runs forever keeps costing money. I wanted a way to tear it all down from GitHub, so I added a third job:
destroy:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
environment: hagital-production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v2
with:
creds: ${{ secrets.HAGITAL_AZURE_CREDENTIALS }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
- name: Terraform Destroy
run: |
terraform destroy \
-auto-approve \
-var="subscription_id=${{ secrets.HAGITAL_SUBSCRIPTION_ID }}" \
-var="public_key=${{ secrets.HAGITAL_SSH_PUBLIC_KEY }}"
The if line is the switch. This job only runs when I click the button (workflow_dispatch), never on a normal push. So my code pushes can't accidentally delete my server.
But then the other two jobs needed the opposite switch. Without it, clicking the button would try to build the VM and delete it in the same run. So I added this line to both provision and configure:
if: github.event_name == 'push'
Then the usual routine, with commit message "Added Manual Terraform Destroy Workflow":
git status
git add .github/workflows/deploy.yaml
git commit -m "Added Manual Terraform Destroy Workflow"
git push origin main
That push counts as a normal push, so provision and configure ran again and destroy was skipped (the grey circle). The site stayed up.
Proof the site was still alive:
Pulling the plug
Then I went to Actions > Hagital VM Deploy > Run workflow, picked the main branch, and clicked the green button.
This time provision and configure were skipped and only destroy ran. The log ends with the line I wanted to see:
Destroy complete! Resources: 8 destroyed.
Eight things built, eight things gone.
Why does this work on a fresh GitHub computer that has never seen my VM? Because of the remote state from Part 1. Terraform reads its notebook from Azure storage, sees what it built, and deletes exactly that.
One thing is still there. The state storage account (terraform-state-rg) was made by hand with the CLI, not by this Terraform code. If you want a zero bill, delete it yourself when you're done.
Every mistake in one place
| # | What went wrong | Why | Fix |
|---|---|---|---|
| 1 |
git branch main said "not a valid object name" |
No commits yet, so the branch had nothing to point at | Make a commit first |
| 2 | GitHub rejected my push (219 MB file) | I ran git add . before making .gitignore, so the provider file was already in history |
Create .gitignore first; I restarted the repo with rm -rf .git
|
| 3 | Merge failed on deploy.yaml
|
GitHub and my laptop each had a different deploy.yaml
|
git checkout --ours, then add and commit |
| 4 | Terraform plan hung for 19+ minutes |
public_key had no value and nobody can type in CI |
Pass it with -var from a secret |
| 5 |
bad substitution on commit |
${{ }} inside a double-quoted commit message |
Use a plain message |
Things I'd do differently
-
Make
.gitignorebefore the firstgit add .and list.terraform/in it. - Don't leave SSH open to the world. My NSG allows port 22 from anywhere. I tried limiting SSH to GitHub's IP ranges at one point and backed it out, so this lab stays open. That's okay for something I destroy in a day.
Wrap up
And that’s it from a simple git push to a live website running on Azure, with Terraform and GitHub Actions handling the heavy lifting.
The biggest lesson for me wasn’t just learning Terraform or GitHub Actions. It was learning how to troubleshoot, fix mistakes, and keep going when things don’t work the first time.
git push → GitHub Actions → Terraform → Azure VM → Live Website
↓
One-click Destroy
📦 Full source code at my github repo: github.com/4thman/Hagital-project
Now I want to hear from you:
Where should the journey go next Docker containers, secure HTTPS, custom domains, OIDC authentication, or a fully automated production CI/CD pipeline?
And most importantly, what’s the biggest DevOps mistake you’ve ever made? 😄
Drop it in the comments. Let’s learn from each other!
One git push at a time. 🚀
































































Top comments (0)