Part 1: From a Fresh Workstation to Automated Deployments with Azure DevOps
Three Practical Projects in Self-Hosted Agents, CI/CD, and Web Application Deployment
A deployment pipeline is easy to admire when it is already working. The more useful question is: what does it take to build one from scratch, understand each component, and troubleshoot it when something breaks?
That question sits at the heart of DevOps.
A successful deployment is not simply a matter of writing YAML and clicking Run pipeline. It requires an understanding of source control, build agents, cloud infrastructure, authentication, deployment tools, networking, and application verification.
This guide walks through three practical projects that build on one another:
Project 1: Set Up a Self-Hosted Azure DevOps Agent on Ubuntu
Project 2: Deploy the Mini Finance Application Through an Azure DevOps Pipeline
Project 3: Deploy a React Application Through an Azure DevOps Pipeline
Each project introduces a different part of the delivery process. The first establishes the execution environment. The second connects pipeline automation to application deployment. The third develops the workflow further by deploying a static web application to an Nginx server.
The examples are designed for beginners, but the engineering principles apply to more advanced environments as well.
Important: Replace every placeholder with a value from your own environment. The examples explain the intended configuration and verification process. A command or configuration shown here is not proof that a deployment has succeeded in your environment.
Project 1: Set Up a Self-Hosted Azure DevOps Agent on Ubuntu
1. Understand the Objective
Before creating resources, understand what you are building.
Azure DevOps provides services for source control, work tracking, and automated delivery. Its pipeline service needs an execution environment to run jobs.
That environment is called an agent.
An agent receives a pipeline job, executes its steps, collects the results, and reports the outcome to Azure DevOps.
There are two common agent models.
Agent model — How it works
Main consideration — Microsoft-hosted agent
Microsoft provides a temporary execution environment for a job.
Convenient, with limited control over the underlying machine.
Self-hosted agent — You install and maintain the agent on infrastructure you control.
More control, but you are responsible for updates, security, availability, and maintenance.
A self-hosted agent is useful when a pipeline needs access to specific software, internal systems, custom networking, or a controlled execution environment.
For this project, the components are:
- An Ubuntu virtual machine.
- An Azure DevOps organization.
- An Azure DevOps project.
- An agent pool.
- A self-hosted agent installed on the VM.
- A test pipeline that executes on that agent.
The workflow is:
Create the VM → Connect through SSH → Install the agent → Register the agent → Verify its status → Run a test pipeline.
2. Understand the Resources Before Creating Them
Use the following placeholders throughout this project.
Place holder Meaning
<AZURE_DEVOPS_ORG>Azure DevOps organization name
<AZURE_DEVOPS_PROJECT>Azure DevOps project name
<AGENT_POOL>Name of the agent pool
<VM_NAME>Name of the Ubuntu virtual machine
<RESOURCE_GROUP>Azure resource group containing the VM
<VM_PUBLIC_IP>Public IP address of the VM
<SSH_USERNAME>Administrative username configured when the VM was created
<PRIVATE_KEY_PATH>Path to the private SSH key on your computer
<AGENT_VERSION>Version of the Azure Pipelines agent downloaded for the installation
A resource group is a logical container for related Azure resources. The VM and its networking resources may belong to the same resource group, depending on how they were created.
The Azure DevOps agent pool is different from the Azure resource group. One belongs to Azure DevOps; the other belongs to Azure Resource Manager.
Confusing the two can make troubleshooting unnecessarily difficult.
3. Prepare Your Workstation
You need a workstation that can connect to the VM and administer the Azure DevOps organization.
The examples below use Git Bash on Windows for SSH commands. PowerShell or a Linux terminal can also be used, but file paths differ.
Step 3.1: Open Git Bash
On Windows:
- Open the Start menu.
- Search for Git Bash.
- Open it.
- Wait for the terminal prompt.
Check that SSH is available:
ssh -VThis prints the installed SSH client version.
Next, check whether curl is available:
curl --versionThe command should display version information. If the command is not recognized, install or repair the required command-line tools before proceeding.
Step 3.2: Locate your SSH private key
SSH uses a key pair:
- The private key stays on your workstation.
- The public key is installed on the VM or otherwise authorized for access.
List files in the default SSH directory:
ls -la ~/.sshLook for the private key associated with the VM.
Do not assume that every key in this directory belongs to the same server. Also, do not share a private key with anyone or paste it into a pipeline log.
If the VM was created with a particular key, use that matching private key.
Step 3.3: Protect the private key
On Git Bash, set restrictive permissions on the private key if SSH reports that its permissions are too open:
chmod 600 "<PRIVATE_KEY_PATH>"Replace the placeholder with the real path.
For example, a Windows path such as:
C:\Users\<WINDOWS_USER>\.ssh\<KEY_FILE>
may be represented in Git Bash as:
/c/Users/<WINDOWS_USER>/.ssh/<KEY_FILE>
Use the path that actually exists on your workstation.
4. Create the Azure DevOps Organization and Project
If you already have an organization and project, use them rather than creating duplicates.
Step 4.1: Open Azure DevOps
- Open your browser.
- Navigate to the official Azure DevOps website: https://dev.azure.com/
- Sign in.
- Select your organization.
The organization URL should follow this pattern:
https://dev.azure.com/<AZURE_DEVOPS_ORG>
Step 4.2: Create a project
If the project does not already exist:
- Open the organization.
- Select New project.
- Enter
<AZURE_DEVOPS_PROJECT>as the project name. - Add a description if required.
- Select the visibility appropriate for the project.
- Choose Git as the version-control system if the creation form asks.
- Select the process template required by the project.
- Select Create.
Wait for Azure DevOps to create the project.
Open it and confirm that you can access the project overview.
Why this matters: The organization establishes the administrative boundary, while the project holds repositories, pipelines, boards, and other project-level resources.
5. Create an Agent Pool
An agent pool organizes the agents available to run pipeline jobs.
Step 5.1: Open the agent pool settings
- Open the Azure DevOps organization.
- Select Organization settings.
- Under the Pipelines section, select Agent pools.
- Select Add pool or the equivalent pool-creation action displayed in your interface.
- Choose a self-hosted pool.
- Enter
<AGENT_POOL>as the pool name. - Save the pool.
The exact labels may vary slightly with Azure DevOps interface updates.
Step 5.2: Check the pool
Open the new pool.
Confirm that it appears in the agent pool list.
At this stage, the pool may contain no agents. That is expected because the Ubuntu VM has not yet registered.
Do not confuse creating a pool with registering an agent. The pool is the destination into which the agent will register.
6. Create the Ubuntu Virtual Machine
The agent needs a Linux machine on which to execute jobs.
Step 6.1: Open the Azure Portal
- Navigate to https://portal.azure.com/
- Sign in.
- Search for Virtual machines.
- Open the Virtual machines service.
- Select Create.
- Select Azure virtual machine.
Step 6.2: Configure the VM
Use the following as a configuration checklist. Adapt the values to the project’s requirements.
Setting Example value
Subscription<AZURE_SUBSCRIPTION>
Resource group<RESOURCE_GROUP>
Virtual machine name<VM_NAME>
Region<AZURE_REGION>
Image — A supported Ubuntu LTS image
Size — A size appropriate for the pipeline workload
Authentication type — SSH public key
Username<SSH_USERNAME>
Public inbound ports — Prefer SSH restricted to your trusted source IP
Select a VM size that meets the workload’s CPU and memory requirements. A small VM may be enough for a simple test pipeline, but larger builds can require additional resources.
Step 6.3: Configure networking securely
If the VM requires SSH access from the public internet, restrict inbound SSH traffic to your trusted public IP address whenever possible.
Avoid opening port 22 to every source merely because it makes initial connectivity easier.
For a lab, an inbound rule may allow:
- Protocol: TCP
- Destination port: 22
- Source: Your trusted public IP address or approved network range
Do not assume the VM’s public IP will remain unchanged forever. Record the IP shown in the Azure Portal after deployment.
Step 6.4: Review and create
- Select Review + create.
- Resolve any validation errors.
- Review the configuration.
- Select Create.
- Wait for deployment to finish.
- Open the deployed VM.
- Find its public IP address under the networking information.
Record the following:
- VM name
- Resource group
- Public IP
- SSH username
- Private-key file location
Do not store the private-key contents in your project documentation.
7. Connect to Ubuntu Through SSH
SSH provides a secure remote terminal.
Open Git Bash and execute:
ssh -i "<PRIVATE_KEY_PATH>" <SSH_USERNAME>@<VM_PUBLIC_IP>Replace all three placeholders.
For example, the command structure is:
ssh -i "/path/to/private-key" ubuntu@203.0.113.10The IP address in this example is illustrative. Use the actual VM IP.
On the first connection, SSH may ask whether you trust the server’s host key.
Verify the host identity through a trusted channel before accepting it. Do not blindly approve unexpected host-key changes.
After a successful connection, your prompt should change to indicate that you are operating on the remote Ubuntu machine.
Step 7.1: Verify the remote machine
Run:
whoamiThis prints the Linux username for the current session.
Next:
hostnameThis prints the machine hostname.
Check the operating system:
cat /etc/os-releaseInspect available disk space:
df -hThese commands confirm that you are connected to the expected machine and help identify basic resource limitations.
Troubleshooting SSH authentication
If SSH returns:
Permission denied (publickey)
check the following in order:
- Is the VM’s administrative username correct?
- Are you using the private key that matches the public key configured on the VM?
- Does the private-key path point to a real file?
- Does the VM’s SSH configuration allow the expected authentication method?
- Is the VM running?
- Is inbound TCP port 22 allowed from your current public IP?
- Has your public IP changed since the networking rule was created?
Use verbose SSH output to identify where authentication fails:
ssh -v -i "<PRIVATE_KEY_PATH>" <SSH_USERNAME>@<VM_PUBLIC_IP>Verbose output can reveal which key SSH attempted to use and whether the server accepted it.
Do not publish the output without reviewing it for sensitive details.
If the network connection times out rather than returning an authentication error, investigate the VM state, public IP, network security rules, and routing before changing keys.
8. Install the Dependencies Required by the Agent
Once connected to Ubuntu, update the package index:
sudo apt-get updateThis refreshes the list of packages available from the configured repositories.
Install common utilities:
sudo apt-get install -y curl tar git ca-certificatesThe packages serve different purposes:
curldownloads files and makes HTTP requests.tarextracts the agent archive.gitsupports repository operations.ca-certificatesprovides trusted certificate authorities for TLS connections.
Confirm that the tools are available:
curl --version
tar --version
git --versionIf a package installation fails, check the network connection and the error returned by apt-get. Do not continue as though the installation succeeded.
9. Download and Extract the Azure Pipelines Agent
The Azure Pipelines agent package is distributed as a compressed archive.
Step 9.1: Find the agent download instructions
In Azure DevOps:
- Open Organization settings.
- Select Agent pools.
- Open
<AGENT_POOL>. - Select New agent.
- Choose Linux.
- Review the installation instructions and download link.
Use the current download instructions displayed by Azure DevOps. Agent versions change, so avoid relying on an old version copied from a tutorial.
The downloaded package is typically a .tar.gz archive.
Step 9.2: Create an installation directory
On Ubuntu:
mkdir -p ~/azure-pipelines-agentChange into the directory:
cd ~/azure-pipelines-agentThe ~ character represents the current user's home directory.
Step 9.3: Download the archive
Use the current Linux download URL supplied by Azure DevOps:
curl -fL "<AGENT_DOWNLOAD_URL>" -o agent.tar.gzReplace <AGENT_DOWNLOAD_URL> with the actual URL from the agent installation instructions.
The options mean:
-f: return an error for an unsuccessful HTTP response.-L: follow redirects.-o: save the response to the specified file.
Confirm that the archive exists:
ls -lh agent.tar.gzInspect its contents:
tar -tzf agent.tar.gz | headThis lists the first few paths contained in the archive.
Step 9.4: Extract the archive
Run:
tar -xzf agent.tar.gzThis extracts the package into the current directory.
Check that files were extracted:
ls -laImportant: Downloading the archive is not the same as installing the agent. The files must be extracted before the configuration script can be run.
A common mistake is to attempt to execute config.sh while it still exists only inside the compressed archive.
If you receive a file-not-found error, confirm that you are in the correct directory and that extraction completed successfully.
10. Register the Agent with Azure DevOps
Registration connects the Linux agent to the Azure DevOps organization and pool.
Step 10.1: Understand the authentication requirement
The agent configuration process requires an authentication method accepted by the organization.
For a personal access token, or PAT:
- Create the token from the appropriate Azure DevOps user settings.
- Grant only the permissions required for agent registration and operation.
- Set a limited expiry.
- Store it securely.
- Never commit it to source control or paste it into public documentation.
Use the permissions and authentication method recommended by the current Azure DevOps agent instructions.
A PAT is a credential, not a harmless configuration string.
Step 10.2: Run the configuration script
From the extracted agent directory:
./config.shThe script asks a series of questions.
Follow the prompts in the order presented by the version you downloaded. The precise wording may vary by agent version.
Typical configuration values include:
Prompt Value
Server URLhttps://dev.azure.com/<AZURE_DEVOPS_ORG>
Authentication type
The supported method selected for the setup
Authentication credential
The credential entered securely when prompted
Agent pool<AGENT_POOL>
Agent name<AGENT_NAME>
Work folder
The default or a chosen working directory
Run as a service
Depends on the operating model
If a prompt expects a value and you press Enter accidentally, stop and review the configuration rather than assuming the agent was registered correctly.
When entering a secret at a hidden prompt, the terminal may not display the characters you type. That is normal.
Do not place the PAT directly into a command that will be saved in shell history.
Step 10.3: Check the configuration result
A successful configuration should complete without an authentication or registration error.
Check the directory for the agent’s startup script:
ls -l run.shIf the file exists, the package was extracted and the expected script is available.
If registration fails, verify:
- The organization URL is correct.
- The pool exists.
- The account has permission to register an agent.
- The authentication credential is valid.
- The token has not expired.
- The token has not been revoked.
If a credential was accidentally exposed, revoke it and create a replacement. Do not merely delete the visible text and continue using the compromised credential.
11. Start the Agent
There are two common operating approaches:
- Run the agent interactively for a temporary test.
- Install it as a service for a persistent environment.
Step 11.1: Run it interactively
From the agent directory:
./run.shKeep the terminal session open while testing.
The agent should attempt to connect to Azure DevOps and wait for jobs.
An interactive process normally stops when the session ends, so it is not a replacement for a properly managed service in a persistent environment.
Step 11.2: Install it as a service
If the environment requires the agent to start automatically, use the service installation instructions provided for the agent version.
The agent’s configuration process can support service installation on Linux. Follow the documented process and account requirements rather than inventing a service definition.
After installation, check the service status using the service-management instructions for the installed agent.
Do not assume that a service was installed simply because run.sh worked in an interactive session.
12. Verify the Agent in Azure DevOps
Return to Azure DevOps.
- Open Organization settings.
- Select Agent pools.
- Open
<AGENT_POOL>. - Select Agents.
Find <AGENT_NAME>.
The agent should show Online when it is connected and available.
If it is offline, check the Ubuntu terminal or service logs, outbound connectivity, the agent configuration, and the authentication credential.
An online agent proves that registration and connectivity are functioning. It does not yet prove that a pipeline can execute successfully.
That requires a test job.
13. Create a Test Pipeline
A small test pipeline is the fastest way to prove that the agent can receive and execute jobs.
Step 13.1: Create a Git repository
In Azure DevOps:
- Open
<AZURE_DEVOPS_PROJECT>. - Select Repos.
- Open Files.
- If the repository has not been initialized, select the option to initialize or create it.
- Add a README file if the interface offers that option.
A pipeline needs a repository or another supported source configuration. An empty project without an initialized repository may not have a suitable source to check out.
Step 13.2: Create the YAML file
In the repository, create a file named:
azure-pipelines.yml
Use this basic test pipeline:
trigger: nonepool:
name: '<AGENT_POOL>'steps:
- script: |
echo "Testing the self-hosted agent"
whoami
hostname
uname -a
df -h
displayName: 'Verify agent execution environment'
Replace <AGENT_POOL> with the actual pool name.
This pipeline is intentionally simple:
trigger: noneprevents automatic runs from source changes.pooldirects the job to the selected self-hosted pool.stepsdefines the commands to execute.displayNameprovides a readable name in the pipeline interface.
Step 13.3: Create the pipeline
- Open Pipelines.
- Select New pipeline.
- Select Azure Repos Git if the YAML file is stored in Azure Repos.
- Select the repository.
- Choose the existing YAML file when prompted.
- Review the pipeline configuration.
- Save it.
The interface may present a YAML review screen before saving.
Step 13.4: Run the pipeline
- Open the pipeline.
- Select Run pipeline.
- Confirm the branch and YAML path.
- Start the run.
- Open the run details.
- Select the job to view its logs.
A successful run should show the output of the test commands.
For example, the logs should contain a Linux kernel description from uname -a, the execution username from whoami, the hostname, and disk-space information.
The pipeline result should be Succeeded if all steps complete successfully.
Common pipeline errors
Error: No agent found in pool
Check that:
- The pool name in YAML matches the actual pool.
- The agent is online.
- The pipeline has permission to use the pool.
- The agent’s capabilities satisfy any demands imposed by the job.
Error: The agent is offline
Check the agent process or service, connectivity to Azure DevOps, and the agent logs.
Error: Repository not found
Check that the repository exists, the pipeline points to the correct repository, and the pipeline identity has access.
Error: Permission denied
Identify which operation was denied. Agent registration, repository access, pool authorization, and operating-system permissions are separate permission boundaries.
Do not solve every permission problem by granting administrator access.
14. Security and Maintenance Checklist
Before treating the agent as a reusable execution environment, review these points:
- The VM is accessible only through approved management paths.
- SSH access is restricted to trusted sources where possible.
- The private key remains private.
- The agent authentication credential is valid and not exposed.
- The agent pool has appropriate permissions.
- The agent is online.
- A test pipeline has completed successfully.
- The agent can be updated and restarted.
- The VM has sufficient disk space and memory.
- Pipeline jobs do not receive unnecessary credentials or privileges.
A self-hosted agent is part of the software delivery trust boundary. A compromised agent can expose source code, pipeline credentials, deployment permissions, and other sensitive resources.
Do not use a powerful production agent as an unrestricted execution environment for untrusted code.
15. Project 1: What Success Looks Like
The project is ready for the next stage when:
- The Ubuntu VM is running.
- SSH access works with the correct username and private key.
- The agent package has been downloaded and extracted.
- The agent has been configured against the intended organization and pool.
- The agent appears online.
- A test pipeline executes on the self-hosted agent and succeeds.
The key lesson is simple: an agent is not merely installed software. It is the execution bridge between your pipeline definition and the machine that performs the work.
Project 2: Deploy the Mini Finance Application Through an Azure DevOps Pipeline
1. Understand the Deployment Goal
The second project moves from testing the agent to using pipeline automation to deploy a web application.
The source repository for the example is:
https://github.com/pravinmishraaws/mini_finance.git
The application contains web assets such as HTML, CSS, JavaScript, fonts, and images. A web server can serve these files to visitors.
The deployment workflow combines several tools:
- Azure DevOps for pipeline orchestration.
- Git for source control.
- Terraform for infrastructure provisioning.
- Ansible for server configuration and application deployment.
- Ubuntu for the target server.
- Nginx for serving the application.
An important distinction: Azure DevOps is the pipeline platform; it does not mean the application must be hosted on Azure. A pipeline can deploy to an approved target in another cloud when the required networking and credentials are configured.
For this project, the deployment environment described in the implementation notes uses AWS EC2. The steps below preserve that distinction rather than relabeling an AWS VM as an Azure VM.
2. Understand the Architecture
The intended deployment flow is:
Azure DevOps pipeline → deployment tools → Ubuntu EC2 instance → Nginx → browser
Terraform provisions the infrastructure. Ansible configures the server and deploys the files. Nginx serves the application.
These tools solve different problems.
Tool Responsibility
Azure DevOps — Runs and coordinates pipeline jobs.
Git — Stores source code and tracks changes.
Terraform — Describes and provisions infrastructure.
Ansible — Configures the server and deploys application files.
Nginx — Serves the deployed web content.
Keeping these responsibilities separate makes the system easier to maintain.
For example, changing a page’s CSS should not require rebuilding an entire cloud network. Likewise, changing the VM’s subnet should not require rewriting the application’s HTML.
3. Prepare the Prerequisites
Before starting, confirm that you have:
- Access to the Azure DevOps organization and project.
- Access to the GitHub repository.
- An AWS account with the permissions required for the infrastructure.
- AWS CLI configured if you use it to inspect or administer resources.
- Terraform installed.
- Ansible available in the environment where configuration tasks will run.
- SSH access to the target VM.
- A private key that matches the target VM’s configured public key.
- Permission to create and run the required Azure DevOps pipeline.
The exact installation commands depend on the operating system. Verify the tools before running the deployment.
For Terraform:
terraform versionFor Git:
git --versionFor AWS CLI:
aws --versionFor Ansible:
ansible --versionIf a command is not found, install the tool in the environment where the corresponding task will run.
Ansible availability on your Windows workstation does not automatically imply that Ansible is installed on the Linux agent, and vice versa.
4. Inspect the Application Repository
Start by opening the source repository in a browser:
https://github.com/pravinmishraaws/mini_finance.git
Review the repository structure before configuring deployment.
A typical static website repository may contain:
mini_finance/
├── index.html
├── css/
├── js/
├── fonts/
└── images/The actual repository may contain additional files or use a different structure.
The purpose of inspection is to understand what needs to be deployed and where the application expects its assets to live.
Step 4.1: Clone the repository locally
Open a terminal and move to your preferred workspace:
mkdir -p ~/projects
cd ~/projectsClone the repository:
git clone https://github.com/pravinmishraaws/mini_finance.gitEnter the directory:
cd mini_financeInspect the files:
ls -laInspect the repository state:
git statusThe repository should be present locally, and Git should report the current branch and working-tree status.
If cloning fails, check the repository URL, network connectivity, and access permissions.
5. Provision the Web Server with Terraform
Terraform allows the infrastructure to be described as code instead of relying entirely on manual cloud-console operations.
The example implementation uses:
- AWS region:
eu-north-1 - Ubuntu 24.04
- Instance type:
t3.micro - An encrypted 8 GB
gp3root volume - VPC CIDR:
10.20.0.0/16 - Public subnet CIDR:
10.20.1.0/24 - Availability Zone:
eu-north-1a
These values describe the example environment, not a universal production configuration. Check current cloud availability, quotas, costs, and assignment requirements before creating resources.
Step 5.1: Configure AWS access
If you are using AWS CLI, configure credentials through an approved method.
Run:
aws configureEnter the requested values interactively if this is the intended credential setup.
Do not paste access keys into a repository, YAML file, or article. For longer-lived environments, prefer short-lived credentials and role-based access wherever possible.
Confirm the active identity:
aws sts get-caller-identityThe command should return the account and identity associated with the current credentials.
If it returns an unexpected account, stop before creating infrastructure.
Step 5.2: Initialize Terraform
Change into the directory containing the Terraform configuration:
cd "<TERRAFORM_PROJECT_DIRECTORY>"Replace the placeholder with the actual directory.
Initialize the working directory:
terraform initTerraform downloads the required provider plugins and initializes the project.
Format the configuration:
terraform fmt -recursiveValidate it:
terraform validateA successful validation confirms that the configuration is syntactically valid and internally consistent. It does not guarantee that the cloud provider will accept every resource or that the infrastructure is secure.
Step 5.3: Review the plan
Run:
terraform planRead the plan before applying it.
Terraform indicates whether resources will be created, updated, or destroyed. Pay particular attention to destructive changes.
Get Nikky Juwe’s stories in your inbox
Join Medium for free to get updates from this writer.
For example:
+indicates a resource will be added.~indicates a resource will be changed.-indicates a resource will be destroyed.
Do not apply a plan simply because Terraform produced one.
If infrastructure already exists and the plan reports no changes, that may be correct. It does not mean the application has been deployed or that the server is healthy.
Step 5.4: Apply only after reviewing the plan
If the plan matches the intended change:
terraform applyReview the proposed actions and confirm when prompted.
After the operation completes, inspect the outputs:
terraform outputIf the configuration defines a public IP output, use the actual returned value as <VM_PUBLIC_IP>.
If the apply fails, read the specific provider error. Common causes include:
- Instance-type capacity restrictions.
- Incorrect region or availability zone.
- Missing permissions.
- Invalid subnet or security-group references.
- Resource quotas.
- Incorrect credentials.
Fix the underlying issue and run a fresh plan. Do not blindly repeat a failed apply without understanding the error.
6. Verify the Target Server
Before configuring Ansible, confirm that the VM is reachable.
Connect using the correct private key and administrative username:
ssh -i "<PRIVATE_KEY_PATH>" ubuntu@<VM_PUBLIC_IP>This example uses ubuntu because the example environment uses Ubuntu. If your image uses a different administrative username, replace it.
Run:
whoami
hostname
cat /etc/os-releaseConfirm that the machine is the expected Ubuntu server.
Exit the remote shell when finished:
exitIf SSH fails, check the VM’s state, public IP, key pair, username, routing, and security-group rules.
A successful Terraform apply is not a substitute for testing connectivity.
7. Configure the Server with Ansible
Ansible automates operating-system configuration and application deployment.
The inventory identifies the target host. The playbook defines what should happen on that host.
Step 7.1: Create an inventory
Create a project directory for the deployment configuration if one does not already exist:
mkdir -p ~/projects/mini-finance-deployment
cd ~/projects/mini-finance-deploymentCreate an inventory file named inventory.ini.
Example:
[web]
<VM_PUBLIC_IP> ansible_user=ubuntu ansible_ssh_private_key_file=<PRIVATE_KEY_PATH>Replace the placeholders with the actual target IP and key path.
The inventory group is named web.
The playbook’s hosts value must match this group name. If the inventory uses [webserver] but the playbook targets web, Ansible will not find the intended host group.
Step 7.2: Test the connection
Run:
ansible -i inventory.ini web -m pingThe Ansible ping module tests Ansible connectivity and Python execution on the target. It is not the same as sending an ICMP ping packet.
A successful response typically contains:
"ping": "pong"If the connection fails, verify the inventory syntax, SSH username, private-key path, server reachability, and Python availability on the target.
Step 7.3: Create the playbook
Create a file named site.yml.
A basic example is:
---
- name: Configure and deploy the Mini Finance website
hosts: web
become: truetasks:
- name: Install Nginx, Git, and rsync
ansible.builtin.apt:
name:
- nginx
- git
- rsync
state: present
update_cache: true - name: Ensure Nginx is running
ansible.builtin.service:
name: nginx
state: started
enabled: true - name: Clone the Mini Finance repository
ansible.builtin.git:
repo: https://github.com/pravinmishraaws/mini_finance.git
dest: /opt/mini-finance-project
version: main
update: true - name: Deploy website files
ansible.posix.synchronize:
src: /opt/mini-finance-project/
dest: /var/www/html/
archive: true
delete: false
recursive: true
delegate_to: "{{ inventory_hostname }}"
Review this example before using it. The synchronization task must be adapted to the execution model, because the source path must exist on the machine performing the transfer. If the repository is cloned on the remote host and the source and destination are on that same host, a remote file-copy task such as ansible.builtin.copy may be more appropriate than synchronize.
A clearer single-host deployment can use the following task instead:
- name: Copy website files into the Nginx document root
ansible.builtin.copy:
src: /opt/mini-finance-project/
dest: /var/www/html/
remote_src: true
owner: root
group: root
mode: preserveUse one appropriate deployment approach rather than running both tasks without a reason.
The example uses /var/www/html/ as the Nginx document root. Confirm that the target Nginx configuration actually serves content from this path.
Step 7.4: Check the playbook syntax
Run:
ansible-playbook -i inventory.ini site.yml --syntax-checkA successful syntax check means Ansible can parse the playbook. It does not prove that every task will succeed.
Step 7.5: Execute the playbook
Run:
ansible-playbook -i inventory.ini site.ymlRead the output for each task.
At the end, Ansible prints a recap containing counts such as ok, changed, unreachable, and failed.
If a task fails, address that task before treating the deployment as complete.
For example:
UNREACHABLEusually points to connectivity or authentication.- A package-installation failure may indicate a repository or network issue.
- A missing source path may indicate a repository or path problem.
- A permission error may indicate that the task requires privilege escalation.
Step 7.6: Check idempotency
Run the playbook a second time:
ansible-playbook -i inventory.ini site.ymlAnsible is designed to converge the server toward the declared configuration.
If the desired state is already present, many tasks should report ok rather than changed.
Some changes may still occur if the playbook intentionally updates the repository or if external state has changed. Investigate unexpected changes instead of assuming every changed result is an error.
8. Test the Web Server
Connect to the server or use an authorized remote command.
Check Nginx:
sudo systemctl status nginxIf Nginx is not running, inspect its logs and configuration before restarting it.
Test the local HTTP endpoint:
curl -I http://localhostA healthy HTTP response should show a status such as 200 OK.
Check the deployed files:
ls -la /var/www/htmlVerify that the expected application files are present.
From a workstation that can reach the server, open:
http://<VM_PUBLIC_IP>
Confirm that the website renders and that its styles, scripts, fonts, and images load correctly.
A page that returns HTTP 200 but displays incorrectly may have broken asset paths, missing files, or a browser-side JavaScript error.
Security note
If the server is public, restrict inbound access to the required ports. SSH should not be open to the entire internet merely for convenience.
For production use, configure HTTPS, monitoring, logging, patching, and appropriate access controls.
9. Connect the Deployment to Azure DevOps
The next step is to orchestrate the deployment through a pipeline.
The precise implementation depends on whether Terraform and Ansible run on the self-hosted agent, another approved agent, or a separate deployment environment.
Before creating the YAML, establish the execution model.
Ask:
- Where will Terraform run?
- Where will Ansible run?
- Which machine has access to the target server?
- Where is the SSH key stored?
- Which identity is authorized to deploy?
- How will the pipeline install or access its required tools?
A pipeline cannot execute Ansible merely because Ansible is installed on the target VM. The machine running the Ansible command must have the Ansible executable, inventory, playbook, and required access to the target.
Step 9.1: Store deployment credentials securely
In Azure DevOps, open the project and navigate to the pipeline library or the appropriate secure-resource settings.
Use the secure mechanism appropriate for the credential type, such as a protected variable group or a supported secure-file resource.
Do not commit:
- Private SSH keys.
- Cloud access keys.
- PATs.
- Passwords.
- Unencrypted secret files.
Restrict access to the pipeline and secure resources. Authorize only the pipelines that require them.
Step 9.2: Create the pipeline YAML
Create or update azure-pipelines.yml in the repository.
A deployment pipeline should have a clear sequence:
- Check out the repository.
- Install or verify the required tooling.
- Validate the infrastructure configuration, if the pipeline manages infrastructure.
- Review the infrastructure plan.
- Apply only approved changes.
- Run the Ansible playbook.
- Test the deployed application.
- Publish useful logs or test results.
Do not put every operation into one long shell command. Separate steps make failures easier to locate.
A simplified structure might look like this:
trigger:
branches:
include:
- mainpool:
name: '<AGENT_POOL>'steps:
- checkout: self - script: |
git --version
terraform version
ansible --version
displayName: 'Verify required tools'
This is a starting point, not a complete infrastructure deployment pipeline. The Terraform and Ansible steps must reference the actual configuration directories, secure credentials, and deployment strategy used in your project.
Do not insert a private key or cloud credential directly into the YAML.
Step 9.3: Authorize protected resources
A pipeline may need explicit authorization to use an agent pool, service connection, secure file, or variable group.
If the pipeline reports that access has not been authorized:
- Open the failed run.
- Read the resource named in the error.
- Navigate to that resource’s settings.
- Review which pipelines are permitted to use it.
- Authorize the intended pipeline only.
- Run the pipeline again.
Avoid granting broad permissions to every pipeline simply to clear the error.
10. Verify the End-to-End Deployment
A green pipeline is necessary but not sufficient.
Use several layers of verification.
Check What it proves
Pipeline job succeeded
The configured steps completed without a reported failure.
Terraform state and outputs reviewed
The infrastructure configuration has a recorded deployment state.Ansible recap reviewed.
The configuration tasks executed and their results were reported.
Nginx service checkedThe web server is running.
HTTP request succeeds
The server responds to a request.
Website opened in a browser
The deployed interface is accessible and renders.
Asset paths checked
Supporting CSS, JavaScript, fonts, and images load as expected.
The strongest verification combines automated checks with a direct inspection of the deployed application.
If a pipeline reports success but the website is unavailable, investigate the target IP, security rules, service status, Nginx configuration, and deployment path.
Do not change Terraform, Ansible, and Nginx simultaneously. Isolate the failing layer first.
11. Common Problems and How to Diagnose Them
Terraform says there are no changes
This means Terraform sees no differences requiring changes under the current configuration and state.
It does not prove that the application is healthy.
Check the infrastructure outputs and test the target independently.
Ansible reports that no hosts match
Compare the inventory group with the playbook’s hosts value.
For example, [web] must match hosts: web.
Nginx is running but the website is incorrect
Check:
ls -la /var/www/htmlThen inspect the Nginx configuration and verify that the document root matches the deployment destination.
The pipeline cannot connect to the VM
Check:
- The VM’s public IP.
- The SSH username.
- The matching private key.
- The source IP allowed by the security group.
- The network route.
- The permissions on the key file.
- The location where the pipeline executes.
A developer’s workstation and a self-hosted agent may have different network paths. A connection that works locally may fail from the agent.
The deployment works manually but fails in the pipeline
This is one of the most important DevOps troubleshooting scenarios.
Manual and automated execution may differ in:
- Environment variables.
- Operating-system user.
- Working directory.
- Installed software.
- SSH key availability.
- File permissions.
- Network access.
- Secret injection.
- Tool versions.
Record the failing command and compare the two environments. Reproduce the failure using the same execution identity and working directory when possible.
12. Project 2: Final Review
Before calling the project complete, confirm:
- The source repository is accessible.
- Terraform configuration has been validated.
- Infrastructure changes were reviewed before application.
- The target VM is reachable through SSH.
- Ansible can reach the target.
- The playbook syntax is valid.
- Deployment tasks completed without unexplained failures.
- Nginx is running.
- The expected files are deployed.
- HTTP requests return the expected status.
- The application renders in a browser.
- Pipeline credentials are protected.
- The pipeline has been tested in the intended execution environment.
The central lesson is that automation should make a deployment repeatable, not merely make it possible to run the same commands from a different screen.
Project 3: Deploy a React Application Through an Azure DevOps Pipeline
1. Understand the Goal
The third project applies pipeline automation to a React-based static website.
The source repository is:
https://github.com/pravinmishraaws/Azure-Static-Website.git
The repository includes a simple website structure, with files such as index.html and README.md in the version described by the project guide. Inspect the current repository before assuming that its contents have not changed.
The target web server uses Ubuntu and Nginx.
The deployment process is:
Import source → Provision target infrastructure → Configure Nginx → Establish SSH access → Configure the pipeline agent → Copy website files → Verify the deployment.
This project introduces an important distinction: a React project may be built into static assets, or it may be configured to run through a Node.js server. The deployment strategy depends on the actual application.
Do not assume that every React repository can be deployed by copying its source files directly into Nginx’s document root.
For the static-site workflow described here, first confirm that the repository contains the deployable static files expected by the project.
2. Create an Azure DevOps Project
Step 2.1: Open Azure DevOps
- Navigate to https://dev.azure.com/
- Sign in.
- Open your organization.
- Select New project.
Step 2.2: Configure the project
Enter:
<AZURE_DEVOPS_PROJECT>
Set the project visibility according to the required access model. A private project is appropriate when the source and pipeline configuration should not be publicly accessible.
Choose Git as the version-control system if prompted.
Select Create and wait for the project to become available.
3. Import the GitHub Repository
The application source must be available to Azure DevOps.
Step 3.1: Open Repos
- Open the new project.
- Select Repos.
- Open Files.
- Find the repository-import option in the interface.
If the project already has a repository, inspect it before importing another one.
Step 3.2: Import the source
Use the repository URL:
https://github.com/pravinmishraaws/Azure-Static-Website.git
Select the option to import an existing Git repository.
Paste the repository URL into the import form.
If the source is public, authentication may not be required. If it is private, provide an approved authentication method.
Start the import and wait for it to complete.
Step 3.3: Verify the imported repository
Open Repos → Files.
Confirm that the repository contains the expected source files and that the intended branch is available.
The project guide uses main as the branch.
If the imported repository uses a different default branch, align the pipeline configuration with the actual repository rather than assuming that main exists.
4. Provision the Web Server
The project guide permits a separate Ubuntu VM and describes both AWS EC2 and Azure VM approaches. The example values in the guide are AWS-oriented.
That distinction matters: select the cloud platform required by the project and use its corresponding provisioning and security configuration.
For an Azure VM deployment:
- Open the Azure Portal.
- Navigate to Virtual machines.
- Select Create → Azure virtual machine.
- Select the appropriate subscription and resource group.
- Choose the required Ubuntu image and region.
- Configure the VM size.
- Configure SSH-key authentication.
- Configure networking.
- Review and create the VM.
For an AWS EC2 deployment, use the EC2 workflow and its corresponding VPC, subnet, security group, and key-pair configuration.
Do not mix AWS security groups with Azure network security groups. They are separate cloud services.
Step 4.1: Record the connection details
Record the values needed by the deployment:
<VM_PUBLIC_IP><SSH_USERNAME><PRIVATE_KEY_PATH>
Confirm that the VM is running and that SSH is permitted from the machine that will perform the deployment.
If the agent is in a different network from your workstation, test connectivity from the agent environment as well.
5. Configure Nginx on the Target VM
Nginx will serve the static website.
Step 5.1: Connect to Ubuntu
From an authorized terminal:
ssh -i "<PRIVATE_KEY_PATH>" <SSH_USERNAME>@<VM_PUBLIC_IP>Use the username configured for the selected Ubuntu image.
Step 5.2: Install Nginx
On Ubuntu:
sudo apt-get update
sudo apt-get install -y nginxThe first command updates the package index. The second installs Nginx.
Step 5.3: Start and enable Nginx
Run:
sudo systemctl enable --now nginxThis enables the service to start automatically and starts it immediately.
Check its status:
sudo systemctl status nginxIf it is running, the status should indicate that the service is active.
Step 5.4: Test the default web page
On the VM:
curl -I http://localhostA working Nginx installation should return an HTTP response.
If the request fails, inspect the service:
sudo journalctl -u nginx --no-pager -n 50This displays recent Nginx service logs.
Also check that the VM’s cloud firewall or network security rules permit inbound HTTP traffic on port 80 from the intended clients.
6. Use Ansible to Automate Configuration
Manual installation is useful for learning, but the project should move toward repeatable configuration.
Ansible can install Nginx, ensure the service is running, and prepare the document root.
Step 6.1: Create the inventory
Create an inventory file:
[web]
<VM_PUBLIC_IP> ansible_user=<SSH_USERNAME> ansible_ssh_private_key_file=<PRIVATE_KEY_PATH>The inventory group is web.
Use the same group name in the playbook.
Step 6.2: Test connectivity
Run:
ansible -i inventory.ini web -m pingResolve any SSH or inventory errors before executing configuration tasks.
Step 6.3: Create a configuration playbook
Create site.yml:
---
- name: Configure the static website server
hosts: web
become: truetasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true - name: Create the Nginx document root
ansible.builtin.file:
path: /var/www/html
state: directory
owner: root
group: root
mode: "0755" - name: Ensure Nginx is enabled and running
ansible.builtin.service:
name: nginx
enabled: true
state: started
Check syntax:
ansible-playbook -i inventory.ini site.yml --syntax-checkExecute:
ansible-playbook -i inventory.ini site.ymlThe playbook establishes the web server’s baseline. It does not yet deploy the application files.
Step 6.4: Verify the document root
The standard path used by this project is:
/var/www/html
Check it:
sudo ls -la /var/www/htmlThe directory may contain Nginx’s default page at this stage.
7. Prepare the Azure DevOps Deployment Agent
The pipeline needs an execution environment with the tools required to copy files and connect to the web server.
The project guide uses a self-hosted agent pool named <AGENT_POOL> and an agent named <AGENT_NAME>.
These are placeholders. Use the names configured in your own Azure DevOps organization.
Step 7.1: Open the agent pool
- Open the Azure DevOps organization.
- Select Organization settings.
- Select Agent pools.
- Open the pool intended for this deployment.
- Select New agent.
- Choose Linux.
- Follow the current installation instructions.
Confirm that the agent is online before creating the deployment pipeline.
Step 7.2: Understand the difference between the two VMs
This project can involve two separate machines:
- The agent VM, which executes the pipeline.
- The web VM, which hosts Nginx and serves the application.
They perform different roles.
The agent needs access to the source repository and deployment credentials. The web VM needs to accept the deployment connection and serve the resulting files.
Do not assume that the agent and web server are the same machine.
8. Configure SSH Access for the Pipeline
The pipeline needs a secure method to authenticate to the target VM.
The project guide uses an Azure DevOps SSH service connection named:
ubuntu-nginx-ssh
Step 8.1: Open project settings
- Open the Azure DevOps project.
- Select Project settings.
- Locate Service connections.
- Select New service connection.
- Choose the SSH connection type.
Step 8.2: Enter the target details
Use:
- Host:
<VM_PUBLIC_IP> - Port:
22 - Username:
<SSH_USERNAME> - Authentication: the supported private-key method
- Service connection name:
ubuntu-nginx-ssh
Provide the private key using the secure input provided by Azure DevOps.
Do not paste the key into a YAML file or commit it to the repository.
If the connection form supports a verification or test step, use it. Review the connection details before saving.
Step 8.3: Understand the trust boundary
An SSH service connection grants the pipeline the ability to connect to a server.
The account and key should have only the permissions needed for deployment. A deployment account that can write the website directory does not automatically need unrestricted administrative access to the entire server.
Where the deployment requires elevated permissions, use a controlled mechanism and review its scope.
9. Protect the Agent’s Authentication Configuration
The project guide also describes registering a self-hosted agent through Ansible and Ansible Vault.
Ansible Vault encrypts sensitive variables stored in Ansible files. It does not automatically protect every copy of a secret in the environment.
A secure workflow should:
- Keep secret values out of plain-text inventory files.
- Store encrypted variables in the designated Vault file.
- Store the Vault password securely.
- Restrict access to the encrypted files and password.
- Avoid printing credentials in logs.
- Rotate any credential that is exposed.
Do not reuse sample passwords from a tutorial.
If a project requires a Vault password file for automation, protect it with appropriate file permissions and ensure it is excluded from source control.
Before using Vault, verify that Ansible Vault is available:
ansible-vault --versionThe exact Vault variables and agent-registration tasks should follow the current agent installation instructions and the project guide. Do not invent a variable name and assume the playbook will recognize it.
10. Create the Deployment Pipeline
The deployment pipeline copies the website files to the Nginx document root and verifies that the files exist.
The project guide uses:
- YAML pipeline file:
azure-pipelines.yml - Branch:
main - Agent pool:
<AGENT_POOL> - Target directory:
/var/www/html
Step 10.1: Create the YAML file
In Azure Repos, create azure-pipelines.yml.
The following is a structural example using the Azure DevOps SSH copy and remote-command tasks.
Important: The task inputs must match the actual service connection, source directory, and repository layout. Confirm the task version and required inputs in the current Azure DevOps task documentation before running the pipeline.
trigger:
branches:
include:
- mainpool:
name: '<AGENT_POOL>'steps:
- checkout: self - task: CopyFilesOverSSH@0
displayName: 'Copy website files to Nginx'
inputs:
sshEndpoint: 'ubuntu-nginx-ssh'
sourceFolder: '$(Build.SourcesDirectory)'
contents: |
**/*
targetFolder: '/var/www/html'
cleanTargetFolder: false
overwrite: true
failOnEmptySource: true - task: SSH@0
displayName: 'Verify deployed website files'
inputs:
sshEndpoint: 'ubuntu-nginx-ssh'
runOptions: 'commands'
commands: |
ls -la /var/www/html
failOnStdErr: false
This example illustrates the workflow, but it may need adjustment for a real React build.
If the repository contains source code that must be compiled, the pipeline should build the application first and copy the build output, rather than copying the entire source repository into the document root.
Also ensure that repository metadata and development-only files are not unintentionally deployed.
Step 10.2: Understand the important YAML sections
Trigger
trigger:
branches:
include:
- mainThis enables pipeline runs when changes are pushed to main, subject to the pipeline's configured trigger behavior.
Agent pool
pool:
name: '<AGENT_POOL>'This selects the self-hosted pool.
Checkout
- checkout: selfThis downloads the repository contents into the pipeline workspace.
Copy Files Over SSH
This transfers the selected files from the agent to the remote server.
SSH
This executes a remote command to verify that the deployment directory contains files.
The final SSH step is useful because it checks the target machine rather than merely confirming that the copy task ran.
11. Authorize the Pipeline to Use Protected Resources
The pipeline may require authorization for:
- The agent pool.
- The SSH service connection.
- A secure file.
- A variable group.
If a run reports that a resource has not been authorized:
- Open the failed run.
- Identify the resource named in the error.
- Open the resource’s settings.
- Review the permitted pipelines.
- Authorize the intended pipeline.
- Run the pipeline again.
Do not grant organization-wide permissions simply to avoid a resource-authorization error.
12. Run and Verify the Pipeline
Step 12.1: Start a run
- Open Pipelines.
- Select the deployment pipeline.
- Select Run pipeline.
- Confirm the branch is
main. - Start the run.
- Open the job logs.
Review each task rather than checking only the overall result.
Step 12.2: Verify the remote files
The SSH verification step should list the contents of:
/var/www/html
Confirm that the expected deployment files appear.
If the task succeeds but the files are missing, check the copy task’s source folder, file filters, target directory, and SSH connection.
Step 12.3: Verify HTTP
Open a terminal on the web VM and run:
curl -I http://localhostFrom an authorized external client, test:
curl -I http://<VM_PUBLIC_IP>A successful HTTP response confirms that the server responds to the request. It does not, by itself, prove that every React component or asset works.
Step 12.4: Test in the browser
Open:
http://<VM_PUBLIC_IP>
Check that the page renders correctly.
If the browser displays the Nginx default page, the application may not have been copied to the correct document root or the Nginx configuration may point somewhere else.
If the page loads without styling, inspect the browser’s developer tools for failed CSS, JavaScript, or image requests.
If the project requires HTTPS, configure a valid TLS certificate and the corresponding Nginx configuration. Do not assume HTTP is sufficient for a production website.
13. Troubleshoot Common Static-Site Deployment Problems
Problem: The pipeline cannot find the agent
Check that the pool name is correct, the agent is online, and the pipeline is authorized to use the pool.
Problem: The SSH connection fails
Check the host, port, username, private key, network access, and SSH service connection configuration.
Problem: The copy task succeeds, but the website does not update
Verify that the correct files were copied into the actual Nginx document root.
Also check browser caching and whether Nginx serves a different directory.
Problem: The page loads, but assets are missing
Inspect the deployed directory and the asset paths referenced by the HTML or compiled application.
A website may load its HTML successfully while returning 404 errors for CSS, JavaScript, fonts, or images.
Problem: The pipeline copied source files instead of the production build
Inspect the repository’s package.json and build configuration.
If the application requires a build, add the required Node.js version, dependency installation, and build steps. Then deploy the actual output directory produced by the build.
Do not assume the output directory is always named build or dist. Check the project's build configuration.
Problem: The pipeline works manually but fails automatically
Compare the environment used for manual deployment with the pipeline environment.
Check tool versions, working directories, SSH key access, network routes, and permissions. Avoid hardcoding paths that exist only on one developer’s workstation.
14. Project 3: Final Review
Before marking the project complete, confirm:
- The Azure DevOps project exists.
- The GitHub repository has been imported successfully.
- The intended branch is available.
- The target VM is running.
- Nginx is installed and active.
- Ansible can configure the target.
- The deployment agent is online.
- The SSH service connection works.
- The pipeline is authorized to use the required resources.
- The YAML file points to the correct source and target paths.
- The deployment task completes successfully.
- The expected files are present under
/var/www/html. - HTTP requests return the expected status.
- The website and its assets render correctly.
- Credentials are protected.
Conclusion: What These Three Projects Teach
These projects build a practical foundation for automated delivery.
Project 1 establishes the execution environment.
Project 2 demonstrates how infrastructure provisioning and server configuration can be incorporated into a deployment workflow.
Project 3 applies those concepts to a static website deployed through SSH to an Nginx server.
The larger lesson is that a reliable deployment depends on more than YAML.
It depends on understanding where a pipeline runs, which identity it uses, what infrastructure it can reach, which files it deploys, and how the result is verified.
Once these boundaries are clear, troubleshooting becomes more systematic. Instead of changing several components at once, you can isolate the failing layer, inspect the evidence, and make a controlled correction.
That is the foundation for the next stage: managing infrastructure and application deployments through separate pipelines, then using AI-assisted tools to diagnose pipeline failures without giving automation uncontrolled authority over production systems.




