This project deploys a complete, CI/CD-ready environment on Microsoft Azure. It uses Terraform to build the infrastructure (VM, Firewall, Container Registry) and Ansible to provision the VM (installing Docker, Azure CLI, and deploy keys).
The goal of this repository is to create a "target" environment. You can then point your own application's GitHub Actions pipeline at this environment to achieve fully automated, push-to-deploy CI/CD.
This guide is in three parts:
- Part 1: Infrastructure Setup (This Repo): Deploy the VM and all supporting Azure infrastructure.
- Part 2: VM Provisioning (This Repo): Configure the VM by installing Docker and other tools.
- Part 3: Application Deployment (Your App Repo): A guide for connecting your application to this new infrastructure.
Before you start, you should have the following tools installed:
- Terraform (~> 1.5)
- Azure Account (create free account with $200 credit)
- Azure CLI (
az) - Ansible (for provisioning the VM)
- make
- An SSH Key Pair (usually at
~/.ssh/id_rsaand~/.ssh/id_rsa.pub) - (Optional for
make vm-ssh)jq(a command-line JSON processor)
This part creates all the Azure resources needed to host your application.
Clone this repository to your local machine and cd into it.
git clone <repository-url>
cd <repository-name>You need two SSH key pairs: one for you to log in as an Admin, and one for GitHub Actions to log in as a Deployer.
It will ask you for a passphrase/password, pressing Enter should be fine.
# This creates ~/.ssh/id_rsa_azure (private) and ~/.ssh/id_rsa_azure.pub (public)
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_azure# This creates ~/.ssh/github_deploy_key (private) and ~/.ssh/github_deploy_key.pub (public)
# The -N "" sets an empty passphrase, which is required for automation.
ssh-keygen -t rsa -b 4096 -f ~/.ssh/github_deploy_key -N ""This Service Principal (security identity/robot account) will be used by Terraform and GitHub Actions to manage resources.
az loginaz account set --subscription "YOUR_SUBSCRIPTION_ID"az ad sp create-for-rbac --role="Contributor" --scopes="/subscriptions/YOUR_SUBSCRIPTION_ID"This will output something like this:
{
"appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"displayName": "azure-cli-2025-10-28-14-30-00",
"password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}SAVE THE OUTPUT! This is the only time you will see the password. You will need this for your GitHub Secrets later.
Terraform needs to store its state file. This is usually done locally but best practice is somewhere secure, like in your Azure Storage Account. This is a manual one time thing.
- Choose a unique name for your storage account (e.g.
tfstate+ your initials + date) - Resource Group Name (e.g. tfstate-rg)
- Location (e.g. UKSouth)
Run these commands to create the backend resources
# 1. Create the resource group
az group create --name tfstate-rg --location "UK South"
# 2. Create the storage account (REPLACE THE NAME!)
az storage account create --name "tfstateYOURNAME123" --resource-group tfstate-rg --location "UK South" --sku Standard_LRS
# 3. Create the container
az storage container create --name tfstate --account-name "tfstateYOURNAME123"Open the versions.tf file. This file should be configured to use the backed, bu tthe nams are passed in by the Makefile, so no hardcoding is needed. It should look like this:
backend "azurerm" {
# This block is intentionally empty.
# 'make init' passes the configuration.
}- Note, this will be saved until you close the terminal (would have to re-enter after)
The
ARM_variables are for Terraform to log in. TheAZ_BACKEND_variables are formake initto find your new backend.
Run each line one by one, inserting the id from the output creating the service principal
# Get these from Step 3 (Service Principal)
export ARM_CLIENT_ID="<your-appId>"
export ARM_CLIENT_SECRET="<your-password>"
export ARM_TENANT_ID="<your-tenant>"
export ARM_SUBSCRIPTION_ID="<your-subscription-id>"
# Get these from Step 4 (Backend)
export AZ_BACKEND_RG="tfstate-rg"
export AZ_BACKEND_STORAGE_ACCOUNT="tfstateYOURNAME123"Company the example file. This file is listed in .gitignore and will never be commited to Git
cp secrets.auto.tfvars.example secrets.auto.tfvarsFill it with the 3 reqired values:
- To get
admin_public_key:cat ~/.ssh/id_rsa_azure.pub(Your admin key) - To get
my_public_ip:curl ifconfig.me(Your current home/office IP) - To get
deploy_public_key:cat ~/.ssh/github_deploy_key.pub(The GitHub key)
Your secrets.auto.tfvars file should look something like this:
admin_public_key = "ssh-rsa AAAA...[your ADMIN key]..."
my_public_ip = "89.123.45.67"
deploy_public_key = "ssh-rsa AAAA...[your GITHUB key]..."make initmake applyYou can simply register the provider through the Azure command line. The example below will register the Microsft.Network resource provider.
az provider register --namespace Microsoft.NetworkThe above commands runs Terraform and builds:
- A new Resource Group
- A Virtual Network and Subnet
- A secure Network Security Group (Firewall)
- A Public IP Address
- An Azure Container Registry (ACR)
- A Linux Virtual Machine (VM) with a Managed Identity
- Role Assignments to allow your VM to pull from the ACR
After make apply is successful, the VM will be running, but it's "empty". The enxt step is to provision it with Ansible to install all the software it needs.
This will configure your new VM.
make provisionHave a look at the Makefile and playbook.yml to see the command used, but it does the following:
- Waits for the VM's SSH port to be ready.
- Installs all system updates.
- Installs Docker, Docker Compose, and all prerequisites.
- Adds your
azureuserto thedockergroup (so you don't needsudo). - Installs the Azure CLI onto the VM (for Managed Identity login).
- Securely adds your GitHub Deploy Key to the VM's
authorized_keys.
Once make provision is done, you can verify everything is working by:
make vm-sshOnce inside the VM, check the version of Docker installed and your user has the right permissions:
docker psThis proves the Azure CLI is installed
az --versionYou can also run this Docker image to make sure its all working (https://hub.docker.com/_/hello-world)
docker run hello-worldThe infrastructure is now ready. This section allows you to configure your own application repository to deploy to this environment.
This uses GitHub Actions to automatically build a private Docker image and deploy it when you git push.
To avoid manually updating GitHub secrets every time you make destroy, you can have Terraform manage them for you.
- Create a GitHub Personal Access Token (PAT):
- Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic).
- Click Generate new token (classic).
- Give it a Note (e.g.,
terraform-infra-manager). - Set Expiration (e.g., 90 days).
- Check the
reposcope. - Click Generate token and copy the token.
- Set the Token Locally:
- In your infrastructure repo terminal, set this environment variable:
export GITHUB_TOKEN="ghp_YOUR_NEW_TOKEN_HERE"- Configure Terraform
- Add the GitHub provider to the
versions.tfand thegithub_actions_secretresources to themain.tffile. - This tells Terraform to find your application repo and update its secrets
- Add to
versions.tf:
terraform {
# ...
required_providers {
# ... (azurerm provider) ...
github = {
source = "integrations/github"
version = "~> 6.0"
}
}
}
provider "github" {}- Add to
main.tf:
# This resource will create/update secrets in your *app* repo
resource "github_actions_secret" "vm_ip" {
repository = "your-app-repo-name" # <-- CHANGE THIS
secret_name = "VM_IP"
plaintext_value = azurerm_public_ip.my_terraform_public_ip.ip_address
}
resource "github_actions_secret" "acr_name" {
repository = "your-app-repo-name" # <-- CHANGE THIS
secret_name = "ACR_NAME"
plaintext_value = azurerm_container_registry.my_acr.name
}
resource "github_actions_secret" "acr_login_server" {
repository = "your-app-repo-name" # <-- CHANGE THIS
secret_name = "ACR_LOGIN_SERVER"
plaintext_value = azurerm_container_registry.my_acr.login_server
}- Run
terraform init -upgradeto install the newgithubprovider ** - Run
make applyto create your infrastructure and update your application repo's GitHub Secrets automatically.
You still need to set the secrets that don't change, as well as your new deploy key. Do this in your application repo's GitHub settings (Settings > Secrets and variables > Actions):
AZURE_CLIENT_ID: Your Service PrincipalappId(from Part 1, Step 3).AZURE_CLIENT_SECRET: Your Service Principalpassword.AZURE_TENANT_ID: Your Service Principaltenant.AZURE_SUBSCRIPTION_ID: Your Subscription ID.DEPLOY_KEY_PRIVATE: The private key file contents (runcat ~/.ssh/github_deploy_key).VM_USER:azureuser(or youradmin_username).GH_PAT: (Optional, if your app repo is private) A GitHub PAT withreposcope to clone.
- Update
compose.yml(ordocker-compose.yml): Tell your app'swebservice to get its image name from a variable/
services:
web:
build: .
# This tells it to use the pipeline's image tag
image: ${IMAGE_NAME:-my-app-web:local}
# ...- Create
compose.prod.yml: This file disables local volumes, forcing the VM to use the pre-built code inside the image.
services:
web:
# This override disables the volume mount
volumes: []In your application repo, create .github/workflows/ci.yml. This file defines the full automated workflow.
name: CI/CD - Build, Push to ACR, and Deploy to VM
on:
push:
branches: [ "main" ] # Triggers on push to main
jobs:
#--------------------------------------
# JOB 1: Continuous Integration (CI)
#--------------------------------------
build-and-push:
runs-on: ubuntu-latest
steps:
- name: "Checkout Code"
uses: actions/checkout@v4
# (Optional) Add your test steps here
# - name: "Run Tests"
# run: docker compose run web your-test-command
- name: "Log in to Azure"
uses: azure/login@v1
with:
creds: >
{
"clientId": "${{ secrets.AZURE_CLIENT_ID }}",
"clientSecret": "${{ secrets.AZURE_CLIENT_SECRET }}",
"tenantId": "${{ secrets.AZURE_TENANT_ID }}",
"subscriptionId": "${{ secrets.AZURE_SUBSCRIPTION_ID }}"
}
- name: "Log in to Azure Container Registry"
uses: azure/docker-login@v1
with:
login-server: ${{ secrets.ACR_LOGIN_SERVER }}
username: ${{ secrets.AZURE_CLIENT_ID }}
password: ${{ secrets.AZURE_CLIENT_SECRET }}
- name: "Build and Push Docker image"
run: |
export IMAGE_NAME=${{ secrets.ACR_LOGIN_SERVER }}/your-app-name:latest
docker compose build
docker compose push
#--------------------------------------
# JOB 2: Continuous Deployment (CD)
#--------------------------------------
deploy-to-vm:
needs: build-and-push # Waits for Job 1 to succeed
runs-on: ubuntu-latest
steps:
- name: "SSH and Deploy to VM"
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VM_IP }}
username: ${{ secrets.VM_USER }}
key: ${{ secrets.DEPLOY_KEY_PRIVATE }}
script: |
# Set variables for the VM
export IMAGE_NAME=${{ secrets.ACR_LOGIN_SERVER }}/your-app-name:latest
export APP_DIR="/home/${{ secrets.VM_USER }}/my-app-folder"
export GIT_REPO_URL="[https://github.com/your-username/your-app-repo.git](https://github.com/your-username/your-app-repo.git)"
# 1. Get latest compose files
if [ -d "$APP_DIR" ]; then
cd $APP_DIR
git pull
else
git clone $GIT_REPO_URL $APP_DIR
cd $APP_DIR
fi
# 2. Log in using the VM's own Identity
az login --identity
# 3. Log Docker into ACR
az acr login --name ${{ secrets.ACR_NAME }}
# 4. Pull the new image from ACR
docker compose pull
# 5. Restart stack with the new image
docker compose -f compose.yml -f compose.prod.yml up -d(Remember to change your-app-name and the GIT_REPO_URL in the file above!)
Now, every git push to your app's main branch will automatically build and deploy it to your VM.
If your application is just a public image (like nginx or wordpress), the process is much simpler. You don't need a CI/CD pipeline, an ACR, or any of the application-side setup.
- SSH into your VM:
make vm-ssh- Create a
docker-compose.ymlfile on the VM:
# Make a new directory for your app
mkdir my-public-app
cd my-public-app
# Create the compose file
nano compose.yml- Paste the configuration for your public app. For example, to run Nginx:
# In compose.yml on the VM
services:
web:
image: "nginx:latest" # Pulls directly from Docker Hub
ports:
- "80:80" # Maps VM port 80 to container port 80- Run it:
docker compose up -dYour app is now running. This is simpler but is a manual process and doesn't use your own code.
All commands are run from the Infrastructure Repo (Part 1).
make all: (Recommended) Builds infrastructure (apply) and then provisions it (provision).make apply: Builds or updates the infrastructure (VM, ACR, etc.).make provision: Runs the Ansible playbook to install Docker, Azure CLI, etc.make destroy: Deletes all resources (VM, VNet, IP, ACR).make vm-stop: Stops (de-allocates) the VM to save money.make vm-start: Starts the VM up again.make vm-ssh: Helper to SSH into the running VM.make init: Initialises the Terraform backend (run this first).make validate: Validates Terraform syntax.make plan: Shows what changes Terraform will make.make help: Displays a list of all available commands.