Using Ansible with GitHub Actions to Deploy Web Services to Azure (Without Dedicated Servers)
Using Ansible with GitHub Actions to Deploy Web Services to Azure
In this comprehensive guide, you’ll learn how to use Ansible with GitHub Actions to deploy a simple web service to Azure without needing any dedicated Ansible control servers. GitHub Actions runners themselves will act as Ansible control nodes, making this a serverless approach to infrastructure automation.
Why Ansible with GitHub Actions?
Benefits of this approach:
- No dedicated servers: GitHub Actions runners serve as ephemeral Ansible control nodes
- Cost-effective: No need to maintain separate automation servers
- Version-controlled: All playbooks and workflows stored in Git
- Integrated CI/CD: Deployment automation as part of your development workflow
- Scalable: GitHub Actions handles scaling automatically
- Secure: Uses GitHub secrets for credentials and Azure service principals
Prerequisites
Before starting, ensure you have:
- GitHub Account with access to GitHub Actions
- Azure Account with an active subscription
- Azure Service Principal for authentication
- Basic knowledge of Ansible playbooks and YAML
- SSH key pair for connecting to Azure VMs (if using VMs)
Architecture Overview
Here’s how the components work together:
1
GitHub Repository → GitHub Actions Workflow → Ansible Playbook → Azure Resources
Flow:
- Developer pushes code to GitHub
- GitHub Actions workflow triggers
- Workflow installs Ansible on the runner
- Ansible playbook executes from the runner
- Ansible provisions/configures Azure resources
- Web service deployed to Azure
Part 1: Setting Up Azure Prerequisites
Create Azure Service Principal
First, create a service principal for GitHub Actions to authenticate with Azure:
1
2
3
4
5
6
7
8
# Login to Azure CLI
az login
# Create a service principal
az ad sp create-for-rbac --name "github-actions-ansible" \
--role contributor \
--scopes /subscriptions/{subscription-id} \
--sdk-auth
Output (save this - you’ll need it for GitHub secrets):
1
2
3
4
5
6
{
"clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"clientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
Generate SSH Keys
Generate an SSH key pair for connecting to Azure VMs:
1
2
3
4
5
6
7
8
# Generate SSH key pair (don't use a passphrase for automation)
ssh-keygen -t rsa -b 4096 -f ~/.ssh/azure_vm_key -N ""
# Display public key (save this)
cat ~/.ssh/azure_vm_key.pub
# Display private key (save this securely)
cat ~/.ssh/azure_vm_key
Part 2: Configure GitHub Repository Secrets
Add the following secrets to your GitHub repository (Settings → Secrets and variables → Actions):
- AZURE_CREDENTIALS: Paste the entire JSON output from service principal creation
- AZURE_CLIENT_ID: The
clientIdfrom the JSON - AZURE_CLIENT_SECRET: The
clientSecretfrom the JSON - AZURE_SUBSCRIPTION_ID: The
subscriptionIdfrom the JSON - AZURE_TENANT_ID: The
tenantIdfrom the JSON - SSH_PRIVATE_KEY: The content of
~/.ssh/azure_vm_key(private key) - SSH_PUBLIC_KEY: The content of
~/.ssh/azure_vm_key.pub(public key)
Part 3: Create Ansible Inventory
Create an inventory file for Azure dynamic inventory:
File: ansible/inventory/azure_rm.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Azure dynamic inventory configuration
plugin: azure.azcollection.azure_rm
auth_source: env
# Filter by resource group
include_vm_resource_groups:
- "ansible-demo-rg"
# Group by various attributes
keyed_groups:
- key: tags.environment
prefix: env
- key: tags.role
prefix: role
# Conditional groups
conditional_groups:
webservers: "'web' in tags.role"
databases: "'db' in tags.role"
Part 4: Create Ansible Configuration
File: ansible/ansible.cfg
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[defaults]
inventory = ./inventory/azure_rm.yml
host_key_checking = False
retry_files_enabled = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_cache
fact_caching_timeout = 7200
[inventory]
enable_plugins = azure.azcollection.azure_rm
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no
pipelining = True
Part 5: Create Ansible Playbook for Azure VM and Web Service
File: ansible/playbooks/deploy_web_service.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
---
- name: Deploy Web Service Infrastructure to Azure
hosts: localhost
connection: local
gather_facts: no
vars:
resource_group: "ansible-demo-rg"
location: "eastus"
vm_name: "webserver-vm"
admin_username: "azureuser"
tasks:
- name: Create Resource Group
azure.azcollection.azure_rm_resourcegroup:
name: ""
location: ""
tags:
environment: production
project: ansible-demo
- name: Create Virtual Network
azure.azcollection.azure_rm_virtualnetwork:
resource_group: ""
name: "-vnet"
address_prefixes_cidr:
- "10.0.0.0/16"
tags:
environment: production
- name: Create Subnet
azure.azcollection.azure_rm_subnet:
resource_group: ""
virtual_network_name: "-vnet"
name: "-subnet"
address_prefix_cidr: "10.0.1.0/24"
- name: Create Public IP Address
azure.azcollection.azure_rm_publicipaddress:
resource_group: ""
name: "-public-ip"
allocation_method: Static
sku: Standard
tags:
environment: production
register: public_ip
- name: Create Network Security Group
azure.azcollection.azure_rm_securitygroup:
resource_group: ""
name: "-nsg"
tags:
environment: production
rules:
- name: AllowSSH
protocol: Tcp
destination_port_range: 22
access: Allow
priority: 1000
direction: Inbound
- name: AllowHTTP
protocol: Tcp
destination_port_range: 80
access: Allow
priority: 1001
direction: Inbound
- name: AllowHTTPS
protocol: Tcp
destination_port_range: 443
access: Allow
priority: 1002
direction: Inbound
- name: Create Network Interface
azure.azcollection.azure_rm_networkinterface:
resource_group: ""
name: "-nic"
virtual_network: "-vnet"
subnet_name: "-subnet"
public_ip_name: "-public-ip"
security_group: "-nsg"
tags:
environment: production
- name: Create Virtual Machine
azure.azcollection.azure_rm_virtualmachine:
resource_group: ""
name: ""
vm_size: Standard_B1s
admin_username: ""
ssh_password_enabled: false
ssh_public_keys:
- path: "/home//.ssh/authorized_keys"
key_data: ""
network_interfaces: "-nic"
image:
offer: 0001-com-ubuntu-server-jammy
publisher: Canonical
sku: '22_04-lts-gen2'
version: latest
os_disk_caching: ReadWrite
managed_disk_type: Standard_LRS
tags:
environment: production
role: web
register: vm
- name: Display VM Public IP
debug:
msg: "VM created with public IP: "
- name: Add VM to inventory
add_host:
name: ""
groups: webservers
ansible_user: ""
ansible_ssh_private_key_file: /tmp/ssh_key
- name: Wait for SSH to be available
wait_for:
host: ""
port: 22
delay: 10
timeout: 300
state: started
- name: Configure Web Server
hosts: webservers
become: yes
gather_facts: yes
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install required packages
apt:
name:
- nginx
- python3-pip
- git
state: present
- name: Create web application directory
file:
path: /var/www/myapp
state: directory
owner: www-data
group: www-data
mode: '0755'
- name: Deploy simple HTML page
copy:
content: |
<!DOCTYPE html>
<html>
<head>
<title>Ansible Deployed Web Service</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
background: rgba(255, 255, 255, 0.1);
padding: 30px;
border-radius: 10px;
backdrop-filter: blur(10px);
}
h1 { font-size: 2.5em; }
.info { background: rgba(0,0,0,0.2); padding: 15px; border-radius: 5px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 Successfully Deployed!</h1>
<p>This web service was deployed using:</p>
<ul>
<li><strong>GitHub Actions</strong> for CI/CD automation</li>
<li><strong>Ansible</strong> for configuration management</li>
<li><strong>Azure</strong> for cloud infrastructure</li>
</ul>
<div class="info">
<p><strong>Deployment Time:</strong> </p>
<p><strong>Server:</strong> </p>
<p><strong>OS:</strong> </p>
</div>
</div>
</body>
</html>
dest: /var/www/myapp/index.html
owner: www-data
group: www-data
mode: '0644'
- name: Configure Nginx site
copy:
content: |
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/myapp;
index index.html;
server_name _;
location / {
try_files $uri $uri/ =404;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
dest: /etc/nginx/sites-available/myapp
owner: root
group: root
mode: '0644'
notify: Restart Nginx
- name: Enable Nginx site
file:
src: /etc/nginx/sites-available/myapp
dest: /etc/nginx/sites-enabled/myapp
state: link
notify: Restart Nginx
- name: Remove default Nginx site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Restart Nginx
- name: Ensure Nginx is running
service:
name: nginx
state: started
enabled: yes
handlers:
- name: Restart Nginx
service:
name: nginx
state: restarted
Part 6: Create GitHub Actions Workflow
File: .github/workflows/deploy-to-azure.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
name: Deploy Web Service to Azure with Ansible
on:
push:
branches:
- main
- develop
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: true
default: 'deploy'
type: choice
options:
- deploy
- destroy
env:
ANSIBLE_VERSION: '2.15.0'
AZURE_COLLECTION_VERSION: '2.1.0'
jobs:
deploy:
name: Deploy to Azure
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Ansible and Azure dependencies
run: |
python -m pip install --upgrade pip
pip install ansible==$
pip install ansible[azure]
ansible-galaxy collection install azure.azcollection:$
pip install -r ~/.ansible/collections/ansible_collections/azure/azcollection/requirements-azure.txt
- name: Verify Ansible installation
run: |
ansible --version
ansible-galaxy collection list
- name: Set up SSH key
run: |
echo "$" > /tmp/ssh_key
chmod 600 /tmp/ssh_key
eval $(ssh-agent -s)
ssh-add /tmp/ssh_key
- name: Set Azure credentials
run: |
echo "AZURE_CLIENT_ID=$" >> $GITHUB_ENV
echo "AZURE_SECRET=$" >> $GITHUB_ENV
echo "AZURE_SUBSCRIPTION_ID=$" >> $GITHUB_ENV
echo "AZURE_TENANT=$" >> $GITHUB_ENV
echo "SSH_PUBLIC_KEY=$" >> $GITHUB_ENV
- name: Run Ansible playbook
working-directory: ./ansible
run: |
ansible-playbook playbooks/deploy_web_service.yml \
-e "ansible_ssh_private_key_file=/tmp/ssh_key" \
-v
- name: Clean up SSH key
if: always()
run: |
rm -f /tmp/ssh_key
- name: Deployment summary
if: success()
run: |
echo "### ✅ Deployment Successful!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Web service deployed to Azure successfully." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Next Steps:**" >> $GITHUB_STEP_SUMMARY
echo "1. Check Azure Portal for resource group: ansible-demo-rg" >> $GITHUB_STEP_SUMMARY
echo "2. Access the web service using the public IP address" >> $GITHUB_STEP_SUMMARY
echo "3. Monitor the deployment in Azure Monitor" >> $GITHUB_STEP_SUMMARY
- name: Deployment failed
if: failure()
run: |
echo "### ❌ Deployment Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Please check the logs above for error details." >> $GITHUB_STEP_SUMMARY
Part 7: Optional - Cleanup Playbook
File: ansible/playbooks/cleanup_azure.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
---
- name: Cleanup Azure Resources
hosts: localhost
connection: local
gather_facts: no
vars:
resource_group: "ansible-demo-rg"
tasks:
- name: Delete Resource Group
azure.azcollection.azure_rm_resourcegroup:
name: ""
state: absent
force_delete_nonempty: yes
register: rg_delete
- name: Display cleanup status
debug:
msg: "Resource group has been deleted"
when: rg_delete.changed
Part 8: Repository Structure
Your repository should have this structure:
1
2
3
4
5
6
7
8
9
10
11
12
.
├── .github/
│ └── workflows/
│ └── deploy-to-azure.yml
├── ansible/
│ ├── ansible.cfg
│ ├── inventory/
│ │ └── azure_rm.yml
│ └── playbooks/
│ ├── deploy_web_service.yml
│ └── cleanup_azure.yml
└── README.md
Testing the Deployment
1. Local Testing (Optional)
Test Ansible playbook syntax locally:
1
2
3
cd ansible
ansible-playbook playbooks/deploy_web_service.yml --syntax-check
ansible-lint playbooks/deploy_web_service.yml
2. Trigger GitHub Actions Workflow
Option A: Push to main branch
1
2
3
git add .
git commit -m "Add Ansible deployment workflow"
git push origin main
Option B: Manual trigger via GitHub UI
- Go to Actions tab in your repository
- Select “Deploy Web Service to Azure with Ansible”
- Click “Run workflow”
- Select branch and action (deploy)
- Click “Run workflow”
3. Monitor Deployment
Watch the workflow execution in the Actions tab:
- Click on the running workflow
- Click on the “Deploy to Azure” job
- Expand each step to see detailed logs
- Wait for completion (typically 5-10 minutes)
4. Verify Deployment
Once complete, verify the deployment:
1
2
3
4
5
# Get the public IP from Azure Portal or CLI
az vm show -d -g ansible-demo-rg -n webserver-vm --query publicIps -o tsv
# Test the web service
curl http://<public-ip>
You should see the deployed HTML page in your browser.
Best Practices
1. Security
- Never commit secrets: Always use GitHub Secrets
- Use service principals: Don’t use personal credentials
- Rotate credentials: Regularly rotate Azure service principal secrets
- Limit permissions: Use least-privilege access for service principals
- Enable Azure Key Vault: Store sensitive data in Azure Key Vault
2. Idempotency
- Design for reruns: Ensure playbooks can run multiple times safely
- Use check mode: Test with
ansible-playbook --checkbefore applying - Handle errors gracefully: Use
ignore_errorsandfailed_whenappropriately
3. Version Control
- Tag releases: Use Git tags for playbook versions
- Document changes: Maintain a CHANGELOG
- Use branches: Develop in feature branches, deploy from main
- Code review: Require PR reviews before merging
4. Performance
- Use caching: Enable fact caching to speed up runs
- Parallel execution: Use
serialorforksfor controlled parallelism - Optimize tasks: Combine tasks where possible
- Use dynamic inventory: Avoid static inventory files
5. Monitoring
- Enable logging: Configure Ansible logging
- Use Azure Monitor: Set up monitoring and alerts
- Check workflow status: Monitor GitHub Actions workflow runs
- Cost tracking: Tag resources for cost allocation
Troubleshooting
Common Issues
1. Authentication Failures
1
Error: Unable to authenticate with Azure
Solution: Verify Azure credentials in GitHub Secrets
1
2
3
4
5
# Test credentials locally
az login --service-principal \
-u $AZURE_CLIENT_ID \
-p $AZURE_CLIENT_SECRET \
--tenant $AZURE_TENANT_ID
2. SSH Connection Failures
1
Error: Failed to connect to host
Solution: Check NSG rules and SSH key configuration
1
2
3
4
5
# Verify NSG allows SSH
az network nsg rule show -g ansible-demo-rg --nsg-name webserver-vm-nsg -n AllowSSH
# Test SSH connection
ssh -i /tmp/ssh_key azureuser@<public-ip>
3. Ansible Module Not Found
1
Error: The module azure.azcollection.azure_rm_resourcegroup was not found
Solution: Install Azure collection
1
2
ansible-galaxy collection install azure.azcollection
pip install -r requirements-azure.txt
4. Timeout Errors
1
Error: Timeout waiting for SSH
Solution: Increase wait timeout or check Azure region availability
5. Quota Exceeded
1
Error: Quota exceeded for VM size
Solution: Request quota increase or use smaller VM size
Advanced Scenarios
1. Deploy Multiple Environments
Use variables to deploy to dev/staging/prod:
1
2
3
4
5
6
7
8
# In workflow
- name: Deploy to environment
env:
ENVIRONMENT: $
run: |
ansible-playbook playbooks/deploy_web_service.yml \
-e "environment=$ENVIRONMENT" \
-e "resource_group=ansible-$ENVIRONMENT-rg"
2. Blue-Green Deployment
Implement blue-green deployment strategy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- name: Blue-Green Deployment
tasks:
- name: Deploy to green environment
include_tasks: deploy_green.yml
- name: Verify green environment
uri:
url: "http://"
status_code: 200
- name: Switch traffic to green
include_tasks: switch_traffic.yml
- name: Cleanup blue environment
include_tasks: cleanup_blue.yml
3. Ansible Vault for Secrets
Use Ansible Vault for additional secrets:
1
2
3
4
5
6
7
8
# Create vault password file
echo "$" > .vault_pass
# Encrypt sensitive files
ansible-vault encrypt vars/secrets.yml --vault-password-file .vault_pass
# Use in playbook
ansible-playbook playbooks/deploy.yml --vault-password-file .vault_pass
4. Dynamic Scaling
Scale based on load:
1
2
3
4
5
6
- name: Scale web servers
azure.azcollection.azure_rm_virtualmachine:
resource_group: ""
name: "webserver-"
# ... other params
loop: ""
Cost Optimization
Tips to minimize Azure costs:
- Use B-series VMs: Burstable VMs for dev/test (Standard_B1s)
- Auto-shutdown: Schedule VM shutdown during off-hours
- Reserved instances: Commit for discounts on production
- Spot instances: Use for non-critical workloads
- Cleanup resources: Delete resources when not needed
Cleanup command:
1
2
# Delete all resources
ansible-playbook ansible/playbooks/cleanup_azure.yml
Extending the Solution
1. Add Application Deployment
Deploy a Python Flask app instead of static HTML:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- name: Install Python application
tasks:
- name: Clone application repository
git:
repo: https://github.com/yourusername/flask-app
dest: /opt/myapp
- name: Install Python dependencies
pip:
requirements: /opt/myapp/requirements.txt
- name: Configure systemd service
template:
src: myapp.service.j2
dest: /etc/systemd/system/myapp.service
2. Add Database Support
Include Azure Database for PostgreSQL:
1
2
3
4
5
6
7
8
9
10
- name: Create PostgreSQL server
azure.azcollection.azure_rm_postgresqlserver:
resource_group: ""
name: ""
sku:
name: B_Gen5_1
tier: Basic
storage_mb: 5120
version: '11'
enforce_ssl: true
3. Add Monitoring
Include Azure Monitor and Application Insights:
1
2
3
4
5
- name: Create Log Analytics workspace
azure.azcollection.azure_rm_loganalyticsworkspace:
resource_group: ""
name: ""
location: ""
4. Add Load Balancer
Use Azure Load Balancer for high availability:
1
2
3
4
5
6
7
- name: Create Load Balancer
azure.azcollection.azure_rm_loadbalancer:
resource_group: ""
name: ""
frontend_ip_configurations:
- name: frontendIPConfig
public_ip_address: ""
Conclusion
You’ve successfully learned how to use Ansible with GitHub Actions to deploy web services to Azure without maintaining dedicated automation servers. This approach provides a powerful, cost-effective, and scalable solution for infrastructure automation.
Key Takeaways:
- Serverless automation: GitHub Actions runners act as Ansible control nodes
- No infrastructure overhead: No need for dedicated Ansible servers
- Version-controlled IaC: All automation code in Git
- Azure integration: Native Azure module support in Ansible
- CI/CD pipeline: Automated deployment on code changes
- Secure: Uses service principals and GitHub Secrets
- Scalable: Handles multiple environments and resources
Next Steps:
- Implement role-based access control (RBAC) in Azure
- Add comprehensive monitoring and alerting
- Create reusable Ansible roles for common tasks
- Implement automated testing with Molecule
- Explore Terraform + Ansible hybrid approach
- Add container orchestration with Azure Kubernetes Service
This pattern works for any cloud provider (AWS, GCP, DigitalOcean) with appropriate Ansible modules and service authentication. The principles remain the same: use ephemeral CI/CD runners as control nodes for agentless infrastructure automation.