DEV Community

Cover image for I Built a One-Click Azure CI/CD Pipeline with Terraform & GitHub Actions From git push to a Live Website
David Cletus
David Cletus

Posted on

I Built a One-Click Azure CI/CD Pipeline with Terraform & GitHub Actions From git push to a Live Website

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
Enter fullscreen mode Exit fullscreen mode

My GitHub repo

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 .
Enter fullscreen mode Exit fullscreen mode

Then I made an empty main.tf:

touch main.tf
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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 = "*"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Every value got its own variable block, like this:

variable "resource_group_name" {
  description = "hagital-rg"
  type        = string
  default     = "hagital-rg"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Git said no:

fatal: not a valid object name: 'master'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

The important parts:

  • on: says run when I push to main, or when I click the button myself (workflow_dispatch).
  • azure/login uses 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"
Enter fullscreen mode Exit fullscreen mode

In plain words:

  1. needs: provision means "don't start until the VM exists."
  2. 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.
  3. Once in, the script installs nginx and git, then swaps nginx's default page for the website. More on that just below.
  4. 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:

  1. 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).
  2. 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.
  3. 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."
  4. chown gives nginx permission to read the files, and systemctl restart nginx makes 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"
Enter fullscreen mode Exit fullscreen mode


Part 5: Push day (three errors in a row)

Error 2: GitHub rejected my push

git push -u origin main
Enter fullscreen mode Exit fullscreen mode
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.
Enter fullscreen mode Exit fullscreen mode

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.*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
Automatic merge failed; fix conflicts and then commit the result.
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

The prompt went back to (main).

)

Then the push finally went through:

git push -u origin main
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 .gitignore before the first git 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
Enter fullscreen mode Exit fullscreen mode

📦 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)