There is something about pushing code and watching a website update itself on a live server that never gets old. No manual uploads. No FTP. No SSH copying. Just a commit, and the pipeline takes care of everything else.
This is Project 2 in my Azure DevOps series. In this one, I deployed a static finance website to an Ubuntu VM running Nginx, fully automated with a CI/CD pipeline that triggers on every push to main. Terraform handled the infrastructure. Ansible handled the server configuration. Azure DevOps handled the deployment. And one permissions error tried very hard to ruin my day.
Here is exactly how I built it, what broke, and how I fixed it.
The Stack
Before we dive in, here is everything I used:
- Azure DevOps for the pipeline and code repository
- Terraform to provision the Ubuntu VM on Azure
- Ansible to install and configure Nginx on the server
- SSH Service Connection in Azure DevOps for secure file transfer
- YAML pipeline to automate the full deployment flow
Step 1: Import the Repository into Azure Repos
The first thing I did was import the finance website codebase into Azure Repos directly from GitHub:
https://github.com/pravinmishraaws/Azure-Static-Website
Once imported, I confirmed that index.html was visible in the repo. Simple step, but worth verifying before anything else.
Step 2: Provision the VM with Terraform
Next, I wrote a Terraform configuration to spin up the infrastructure on Azure. The config created:
- A Resource Group
- A Virtual Network and Subnet
- A Network Security Group (NSG) with ports 22 and 80 open
- A static Public IP address
- An Ubuntu 22.04 LTS VM
After running terraform apply, I got the VM's public IP:
vm_public_ip = "20.124.184.86"
That IP is what the pipeline would later deploy to, and what the final site would be accessible from.
One thing worth noting: always use a static public IP when setting up a deployment target. If you use a dynamic IP, it changes every time the VM restarts and your pipeline will lose its connection.
Step 3: Configure Nginx with Ansible
With the VM up, I used Ansible to install and configure Nginx. The playbook was straightforward:
- name: Install Nginx
apt:
name: nginx
state: present
update_cache: yes
- name: Start Nginx
systemd:
name: nginx
state: started
enabled: yes
But there is a step most tutorials skip, and it is the one that will save you a headache later. After installing Nginx, I immediately ran these two commands:
sudo chown -R azureuser:azureuser /var/www/html
sudo chmod -R 755 /var/www/html
Here is why this matters: Nginx creates the /var/www/html directory owned by root. When the Azure DevOps pipeline tries to copy files into that folder, it runs as azureuser, not root. That user does not have write access. The pipeline fails. I will come back to this in a moment because even with this step in Ansible, I still hit the error the first time around.
Step 4: Create the SSH Service Connection in Azure DevOps
This is the connection that allows the pipeline to communicate with the VM over SSH.
In Azure DevOps, go to Project Settings, then Service Connections, then click New Service Connection. Choose SSH and fill in:
- Host: your VM's public IP
- Port: 22
- Username: azureuser
- Password (or private key)
-
Name:
ubuntu-nginx-ssh
One thing that confused me at first: SSH service connections do not show a green verified badge the way other service connections do. That is completely normal. The only real test is whether the pipeline runs successfully. Do not let the missing badge throw you off.
Step 5: Write the Pipeline
With the service connection in place, I wrote the YAML pipeline. It has two tasks: copy the website files to the server, then verify the deployment ran correctly.
trigger:
branches:
include:
- main
pool:
name: SelfHostedPool
stages:
- stage: Deploy
jobs:
- job: DeployJob
steps:
- checkout: self
- task: CopyFilesOverSSH@0
inputs:
sshEndpoint: 'ubuntu-nginx-ssh'
sourceFolder: '$(Build.SourcesDirectory)'
contents: '**'
targetFolder: '/var/www/html'
cleanTargetFolder: true
- task: SSH@0
inputs:
sshEndpoint: 'ubuntu-nginx-ssh'
runOptions: 'inline'
inline: 'ls /var/www/html'
The cleanTargetFolder: true setting tells the pipeline to wipe the target directory before copying new files. This is what caused the permission error on the first run, because it tries to run rm -rf on /var/www/html, and if root still owns that folder, the pipeline user gets blocked.
The Permission Error (And How to Fix It)
The first pipeline run failed with this:
rm: cannot remove '/var/www/html/index.nginx-debian.html': Permission denied
Failed to clean the target folder. Command rm -rf '/var/www/html'/* exited with code 1.
Even though I had added the chown command to the Ansible playbook, the error still appeared on this run because the VM had been provisioned before I added that step. The fix was simple: SSH into the VM manually and run:
sudo chown -R azureuser:azureuser /var/www/html
sudo chmod -R 755 /var/www/html
Then I reran the pipeline. It went fully green.
If you are following this setup from scratch and you run the Ansible playbook before the pipeline, you should not hit this error at all. But if you do, this is exactly what to check first.
Result
The site went live at http://20.124.184.86. Finance dashboard loading in the browser. Pipeline completed in 46 seconds from push to live.
That moment of opening the browser and seeing the site running on my own VM, deployed by a pipeline I built myself, is one of those moments where the learning stops feeling abstract.
What I Took Away From This
A few things I want to flag for anyone building something similar:
Permissions matter more than the pipeline. You can write perfect YAML and still fail because the server will not allow the pipeline user to write to a folder. Always check file ownership on your deployment path before you wire anything up.
Static IPs are not optional. A dynamic IP will break your service connection silently. Fix the IP before you configure anything that references it.
SSH service connections without a verified badge are fine. The badge not turning green does not mean the connection is broken. The pipeline run is the real test.
Run your Ansible playbook before your first pipeline run. The order matters. Server should be fully configured before the pipeline ever tries to touch it.
This is Project 2 of 4 in the Azure DevOps series. Two down, two more to go.
Vivian Chiamaka Okose is a DevOps Engineer documenting her hands-on learning journey through real projects.










Top comments (0)