DEV Community

Cover image for How I Deployed a Static Website with Azure DevOps, Terraform, Ansible, and Nginx
Vivian Chiamaka Okose
Vivian Chiamaka Okose

Posted on

How I Deployed a Static Website with Azure DevOps, Terraform, Ansible, and Nginx

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

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

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

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

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

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

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

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.


1

1

2

3

3

4

5

6

7

8

8


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)