CI/CD Pipeline with GitHub Actions
Build a pipeline that automatically tests and deploys a Python app to Azure.
Introduction
30 Second Summary
Every time you finish writing code, you face the same question: how do I get this to my users? Copying files to a server by hand is slow, error-prone, and something you will forget at 5pm on a Friday.
In this project, you will build a CI/CD pipeline that automatically tests your code and deploys it to the cloud every time you push to GitHub. No manual uploads, no forgotten steps.
What You'll Build
You will push code to your repository, watch automated checks run in real time, and then visit a live public URL where your app is already running.
By the end of this project, you'll have:
- A GitHub Actions pipeline that automatically lints, tests, and deploys your code every time you push.
- A live Python app running on Azure App Service that you can share with anyone by sending them the URL.
- A visible pipeline history showing a failed build you deliberately caused and then fixed, proving to hiring managers that you understand the CI/CD feedback loop.
- Secret Mission: Add a staging environment with manual approval gates before production deployment.
Are there any prerequisites?
You will need an Azure subscription (the free tier works perfectly), a GitHub account, and Azure CLI installed on your machine. Basic familiarity with Git (pushing and pulling code) is helpful but not required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal before any hands-on work begins.
Set Up the GitHub Repository and Python App
Every CI/CD pipeline needs something to build, test, and ship. The pipeline is the star of this project, but it needs a working app to prove its value.
In this step, you'll create a simple Python Flask app with a test, run it locally to prove it works, and push everything to GitHub. Starting with a running app locally means you'll know the code is solid before any automation touches it.
In this step, get ready to:
- Create a public GitHub repository and clone it to your machine.
- Build a Flask app with routes, dependencies, and a test file.
- Run the app and tests locally, then push everything to GitHub.
Create the repository and app files
You need a public GitHub repository so that GitHub Actions can run pipelines on your code for free. Public repos get unlimited CI/CD minutes.
- Open your browser and go to github.com/new.
- Enter cicd-pipeline-azure in the Repository name field.
- Select Public for visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why an empty repository?
Starting with an empty repo means you control every file from the beginning. Pre-generated files like a README or .gitignore can cause merge conflicts when you push your local code.
GitHub shows a Quick Setup page with your repository URL. You'll use this URL to clone the repo to your machine.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your repository and move into it by running these commands:
git clone [[YOUR_REPO_URL="https://github.com/your-username/cicd-pipeline-azure.git"]]
cd cicd-pipeline-azure
You should see a message like Cloning into 'cicd-pipeline-azure'... confirming the clone worked.
Clone failed or permission denied?
Make sure you copied the HTTPS URL from the Quick Setup page (it starts with https://). If you get a credentials prompt, sign in with your GitHub username and a personal access token.
Help me troubleshoot my git clone error.
Now set up a Python virtual environment to keep your project dependencies isolated.
- Create and activate a virtual environment by running:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
Your terminal prompt now shows (venv) at the beginning, confirming the virtual environment is active.
Don't see (venv) in your prompt?
On Windows, you may need to run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned first, then try the activate command again. On macOS, confirm you're using python3 (not python).
Help me activate my Python virtual environment.
Time to create the app. You'll build a minimal Flask web app with two routes: a home page and a health check endpoint.
- Create a file called app.py in the cicd-pipeline-azure folder (the one you just cloned) and paste this code:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello from CI/CD Pipeline!"
@app.route("/health")
def health():
return {"status": "healthy"}, 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
What does this code do?
- `app = Flask(__name__)` creates the Flask application instance.
- The / route returns a simple greeting. This is what you'll see when you visit the live deployed app.
- The /health route returns a JSON status. Deployment platforms use health checks like this to verify an app is alive.
- The if __name__ block lets you run the app directly with python app.py during development.
- Save app.py.
- Create a file called requirements.txt in the same folder and paste these pinned dependencies:
flask==3.1.1
gunicorn==23.0.0
pytest==8.3.5
ruff==0.11.12
What are these packages?
- `flask` is the web framework for your app.
- `gunicorn` is the production web server Azure App Service will use to serve your app.
- `pytest` is the testing framework that runs your test file.
- `ruff` is a fast Python linter that checks your code style. Your pipeline will use it to catch issues.
- Save requirements.txt and install the dependencies by running:
pip install -r requirements.txt
You should see pip downloading and installing the four packages. It ends with a line confirming all packages were successfully installed.
Seeing a pip error?
Make sure your virtual environment is active (you see (venv) in your prompt). If pip itself is outdated, run pip install --upgrade pip first, then try again.
Help me fix my pip install error.
Next, create a test file. The pipeline will run this test on every push to verify your app still works.
- Create a folder called tests inside your project by running:
mkdir tests
- Create a file called tests/test_app.py and paste this code:
from app import app
def test_home():
client = app.test_client()
response = client.get("/")
assert response.status_code == 200
assert b"Hello from CI/CD Pipeline!" in response.data
def test_health():
client = app.test_client()
response = client.get("/health")
assert response.status_code == 200
assert response.get_json() == {"status": "healthy"}
What does this test do?
- `app.test_client()` creates a fake browser that can send requests to your app without starting a real server.
- `test_home` sends a GET request to / and checks it returns a 200 status with the expected greeting text.
- `test_health` checks the /health endpoint returns the JSON health status.
- Save tests/test_app.py.
- Confirm all your files exist by running:
ls
You should see app.py, requirements.txt, tests, and venv listed.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is the full app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello from CI/CD Pipeline!"
@app.route("/health")
def health():
return {"status": "healthy"}, 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Here is requirements.txt:
flask==3.1.1
gunicorn==23.0.0
pytest==8.3.5
ruff==0.11.12
Here is tests/test_app.py:
from app import app
def test_home():
client = app.test_client()
response = client.get("/")
assert response.status_code == 200
assert b"Hello from CI/CD Pipeline!" in response.data
def test_health():
client = app.test_client()
response = client.get("/health")
assert response.status_code == 200
assert response.get_json() == {"status": "healthy"}
Run the app and tests locally
Before you trust a pipeline to test your code, prove it works with your own eyes. You'll run the Flask app to see the greeting, then run the tests to confirm they pass.
- Start the Flask development server by running:
python app.py
What to expect
This command starts a local web server that occupies your terminal. You'll see output including a line that says the server is running on a local address. The terminal stays busy until you stop the server.
Before you check the browser, predict what you'll see when you visit http://127.0.0.1:5000 based on the home() function you wrote.
- Open your browser and go to http://127.0.0.1:5000.
You see "Hello from CI/CD Pipeline!" displayed in the browser. Your app is working.
- Go back to your terminal and press Ctrl+C to stop the server.
Don't see the greeting in your browser?
Check your terminal for error messages. If you see ModuleNotFoundError: No module named 'flask', your virtual environment is not active. Activate it again and rerun pip install -r requirements.txt.
Help me get my Flask app running locally.
Now confirm the automated tests pass. Think about what you expect to see given two test functions that should both succeed.
- Run the test suite by running:
pytest tests/ -v
You should see both test_home and test_health marked as PASSED, with a summary line showing 2 passed.
Tests failing?
If you see an import error, make sure you are running pytest from the cicd-pipeline-azure folder (the folder containing app.py). The test file imports app from app.py, so pytest needs to run from the same directory.
Help me fix my failing pytest tests.
Push your code to GitHub
Your app runs and your tests pass. Time to push everything to GitHub so the code lives in the cloud, ready for a pipeline to pick up.
- Create a .gitignore file to keep your virtual environment out of the repository by running:
echo "venv/" > .gitignore
- Stage, commit, and push all your files by running these commands:
git add .
git commit -m "Add Flask app with tests"
git push origin main
What do these commands do?
- `git add .` stages all new and changed files in the current directory.
- `git commit -m` saves a snapshot of your staged files with a message describing the change.
- `git push origin main` uploads your commit to the main branch on GitHub.
- Open your browser and go to your repository page on GitHub (github.com/your-username/cicd-pipeline-azure).
You should see app.py, requirements.txt, .gitignore, and the tests/ folder listed in your repository.
Push rejected or branch error?
If Git says the remote has no branch called main, try git push -u origin main. If your default branch is master, rename it first with git branch -M main and push again.
Help me push my code to GitHub.
Your app is live on GitHub with passing tests. Next up, you'll create a GitHub Actions workflow that runs those tests automatically on every push.
Create Your First GitHub Actions Workflow
Your Flask app runs locally and your tests pass. You have a solid foundation to build on.
The problem is that checking code quality is still a manual process. You have to remember to run pytest and ruff every single time you change something. A CI pipeline automates this check on every push, so bugs get caught before they reach production.
In this step, get ready to:
- Write a GitHub Actions workflow that lints and tests your code automatically.
- Push the workflow to GitHub and see it execute in the Actions tab.
- Read the workflow logs to understand what GitHub did behind the scenes.
Create the workflow file
GitHub Actions looks for workflow files inside a special .github/workflows directory in your repository. Any .yml file you place there becomes a workflow that GitHub can run.
- Create the workflow directory by running this command from the root of your repository:
mkdir -p .github/workflows
- Create a new file called ci-cd.yml inside .github/workflows/ and add the following content:
name: CI/CD Pipeline
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
id-token: write
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
What does this code do?
- name gives the workflow a human-readable label that appears in the Actions tab.
- on defines the triggers. This workflow runs on every push to main and on every pull request targeting main.
- permissions grants the workflow the ability to request an OIDC token (needed later for Azure deployment) and read your code.
- runs-on: ubuntu-latest tells GitHub to spin up a fresh Ubuntu virtual machine for the job.
- actions/checkout@v4 clones your repository onto that machine so the remaining steps can access your code.
- Now add the pipeline steps directly below the checkout step in the same file:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint with ruff
run: |
ruff check .
- name: Run tests
run: |
pytest tests/ -v
What do these steps do?
- Set up Python installs Python 3.12 on the runner using the official actions/setup-python@v5 action.
- Install dependencies upgrades pip and installs everything from requirements.txt (Flask, pytest, ruff, gunicorn).
- Lint with ruff checks your code for style issues and common errors. If ruff finds a problem, the job fails immediately.
- Run tests executes pytest in verbose mode. If any test fails, the job fails.
- Save ci-cd.yml.
- Confirm the file exists by running:
ls .github/workflows/
You should see ci-cd.yml listed in the output.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your ci-cd.yml file should contain both sections in order:
- The workflow header (name, triggers, permissions, and job definition through the actions/checkout@v4 step).
- The four pipeline steps (Set up Python, Install dependencies, Lint with ruff, Run tests).
Scroll up and confirm each chunk is present in the correct order. The indentation under steps: must be consistent (6 spaces for each - uses: or - name: entry).
File not showing up?
- Make sure you ran mkdir -p .github/workflows from your repository's root folder, not from a subfolder.
- Check the file extension is .yml (not .yaml or .txt).
Help me debug my workflow file
Push the workflow to GitHub
The workflow file only runs once it exists on GitHub. Pushing it to main triggers the workflow automatically because of the on: push trigger you just defined.
- Push your workflow file to GitHub by running these commands:
git add .github/workflows/ci-cd.yml
git commit -m "Add CI/CD workflow"
git push
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of the repository page.
Before you look at the result, what do you expect to see? Do you think the workflow will pass or fail, given that your tests and linting worked locally?
You should see a workflow run labelled CI/CD Pipeline with a green checkmark next to it. The build-and-test job completed successfully.
Seeing a red X or no workflow at all?
- If you see no workflow runs, confirm the file is at exactly .github/workflows/ci-cd.yml in your repo (not github/workflows/ without the leading dot).
- If the run failed, click on the failed run to see the logs. A common cause is a YAML indentation error. Every - name: must align at the same level under steps:.
- Make sure you pushed to the main branch, since the workflow only triggers on pushes to main.
My workflow is failing or not appearing
Read the workflow logs
- Click the green-checkmark workflow run (it is labelled with your commit message, Add CI/CD workflow).
- Click the build-and-test job in the left sidebar to expand its steps.
- Click each step name to expand its log output and read what happened.
What just happened behind the scenes?
When you pushed to main, GitHub spun up a fresh Ubuntu virtual machine, installed Python 3.12, downloaded your dependencies, ran ruff against your code, and executed pytest. All of this happened without you logging in to any server.
This is Continuous Integration in action. Every push triggers the exact same quality checks, so you never accidentally ship broken code.
Your pipeline catches lint errors and test failures on every single push. Next up, you will deliberately break it to see what a failed pipeline looks like and why that is the most valuable part of CI/CD.
Break the Pipeline (and Fix It)
Your pipeline is green. Every test passed, the linter is happy, and your Actions tab shows a reassuring checkmark.
But a pipeline that only shows green is not proving anything. The real value of CI/CD is catching bugs before they reach users. In this step, you will deliberately break a test, watch the pipeline reject the change, and then fix it. This is the exact workflow every DevOps team relies on daily.
In this step, get ready to:
- Break a test deliberately and see the pipeline reject it.
- Read failure logs to understand CI/CD debugging.
- Fix the test and confirm the pipeline recovers with a green checkmark.
Break a test deliberately
Right now your test_home function asserts the correct response from your app. You are going to change that assertion to expect the wrong text, simulating a real bug that a teammate might accidentally introduce.
- In tests/test_app.py, find this line inside the test_home function:
assert b"Hello from CI/CD Pipeline!" in response.data
- Replace it with a deliberately wrong assertion, so the line now looks like this:
assert b"Welcome to my app!" in response.data
Why this wrong assertion?
Your app returns "Hello from CI/CD Pipeline!" but the test now expects "Welcome to my app!" instead. This mismatch guarantees the test will fail, just like a real bug where someone changes an endpoint's response without updating the tests.
- Save tests/test_app.py.
- Push the broken test to GitHub by running these commands:
git add .
git commit -m "Break test deliberately"
git push
Before you check the Actions tab, what do you think will happen to the pipeline this time?
- Open your repository on GitHub.
- Click the Actions tab.
You see a red X next to the latest workflow run. The pipeline caught your broken test and refused to pass. This is exactly what CI/CD is designed to do.
Read the failure logs
A red X alone is not enough. You need to find out which test failed and why. This is what debugging in CI/CD looks like.
- Click on the failed workflow run (the one with the red X).
- Click the build-and-test job in the left sidebar.
- Expand the Run tests step to see the full output.
What should I see in the logs?
The logs show a FAILURES section with a traceback pointing to your test_home function. You will see an AssertionError because the response data does not contain the text your broken test expected.
At the bottom, a short test summary shows 1 failed test and 1 passed test. This tells you exactly where to look when fixing the issue.
Fix the test and confirm the pipeline recovers
You have identified the problem from the logs. The assertion expects the wrong text. Time to fix it and push again.
- In tests/test_app.py, find the broken assertion line:
assert b"Welcome to my app!" in response.data
- Replace it with the correct assertion that matches what your app actually returns:
assert b"Hello from CI/CD Pipeline!" in response.data
- Save tests/test_app.py.
- Push the fix to GitHub by running these commands:
git add .
git commit -m "Fix broken test"
git push
What do you expect to see in the Actions tab this time?
- Return to the Actions tab in your GitHub repository.
- Wait for the latest workflow run to complete.
You see a green checkmark on the new run. Your Actions tab now tells a story: a red X followed by a green checkmark. This is visible proof that your pipeline catches bugs and recovers when they are fixed.
Still seeing a red X on the new run?
- Open tests/test_app.py and confirm the assertion reads assert b"Hello from CI/CD Pipeline!" in response.data. Any typo or extra space causes a mismatch.
- Make sure you saved the file before committing. Run git status to check for uncommitted changes.
- Check the logs again to see which test is still failing.
Still stuck? Help me figure out why my pipeline is still failing after fixing the test.
✔️ Awesome, I've got everything!
Great. Double check you have saved tests/test_app.py with the correct assertions.
ⓧ I'd like to double check the full code
Here is the full tests/test_app.py as it should look now:
from app import app
def test_home():
client = app.test_client()
response = client.get("/")
assert response.status_code == 200
assert b"Hello from CI/CD Pipeline!" in response.data
def test_health():
client = app.test_client()
response = client.get("/health")
assert response.status_code == 200
assert response.get_json() == {"status": "healthy"}
You just experienced the core CI/CD feedback loop: break, catch, fix, confirm. Your pipeline does not just run tests. It protects your users from bugs reaching production. Next up, you will connect this pipeline to Azure so it can deploy your app automatically.
Connect GitHub Actions to Azure with OIDC
Your pipeline can test code, but it cannot deploy it anywhere yet. To ship your app to Azure automatically, GitHub Actions needs permission to access your Azure subscription.
The secure, modern way to grant this access is OIDC (OpenID Connect). Instead of storing a password or secret key, your workflow requests a short-lived token from Azure each time it runs. Nothing sensitive is ever stored in your repository.
In this step, get ready to:
- Create a Microsoft Entra app registration and service principal using Azure CLI.
- Configure a federated identity credential that trusts your GitHub repository.
- Add the three required secrets to your GitHub repository.
Sign in to Azure CLI
All the Azure resources in this step are created through the command line. You need to authenticate your terminal session with Azure before running any commands.
- Sign in to Azure by running this command:
az login
What does this command do?
This opens a browser window where you sign in with your Azure account. Once authenticated, your terminal can run Azure CLI commands against your subscription.
After signing in, your terminal displays a JSON list of your subscriptions. You should see at least one subscription listed.
Not seeing any subscriptions?
Make sure you signed in with the same Microsoft account that has your Azure subscription attached. If you have multiple accounts, run az login again and choose the correct one in the browser.
help me sign in to Azure CLI
Create the app registration and service principal
A Microsoft Entra app registration is an identity that represents your GitHub Actions workflow inside Azure. A service principal is the object that Azure uses to grant permissions to that identity.
- Create a resource group for your project by running:
az group create --name cicd-pipeline-rg --location eastus
What does this command do?
This creates a resource group called cicd-pipeline-rg in the East US region. A resource group is a container that holds related Azure resources together for easy management and cleanup.
You should see JSON output with "provisioningState": "Succeeded" confirming the group was created.
- Create the app registration by running:
az ad app create --display-name "github-actions-deploy"
What does this command do?
This registers an application named github-actions-deploy in Microsoft Entra. The output contains an appId field. This is your Client ID, and you will need it for the next several commands.
- Copy the appId value from the JSON output.
- Create the service principal by running this command, replacing the placeholder with your actual appId:
az ad sp create --id [[APP_ID="your appId from the previous command"]]
What does this command do?
This creates a service principal linked to your app registration. The output includes an id field (the assignee object ID) and an appOwnerOrganizationId field (your tenant ID).
Copy both of these values. The id is needed for the role assignment, and the tenant ID is used later as a GitHub secret.
- Assign the Contributor role to the service principal by running this command, replacing the placeholders with your values:
az role assignment create --role Contributor --assignee-object-id [[SP_OBJECT_ID="your service principal id"]] --assignee-principal-type ServicePrincipal --scope /subscriptions/[[SUBSCRIPTION_ID="your subscription ID"]]/resourceGroups/cicd-pipeline-rg
What does this command do?
This grants the Contributor role to your service principal, scoped to the cicd-pipeline-rg resource group. Contributor allows creating and managing resources within that group, which is everything your pipeline needs for deployment.
You should see JSON output with "roleDefinitionName": "Contributor" confirming the assignment.
Find your subscription ID
If you are not sure what your subscription ID is, run az account show --query id -o tsv to print it.
Seeing a permission error?
Make sure you are signed in with an account that has Owner or User Access Administrator permissions on the subscription. Free-tier subscriptions grant this automatically to the account owner.
help me fix an Azure role assignment permission error
Create the federated identity credential
The federated credential tells Azure to trust tokens that come from your specific GitHub repository's main branch. When your workflow runs, GitHub sends a short-lived token to Azure proving it is your repo, and Azure grants access without any stored secret.
- Create a file called credential.json anywhere on your machine (your Desktop is fine) with the following content, replacing the subject with your GitHub username and repository name:
{
"name": "github-actions-deploy-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:[[GITHUB_OWNER="your-github-username"]]/[[REPO_NAME="your-repo-name"]]:ref:refs/heads/main",
"description": "GitHub Actions deployment from main branch",
"audiences": [
"api://AzureADTokenExchange"
]
}
What do these fields mean?
- issuer: The URL of GitHub's OIDC token provider.
- subject: The exact identity Azure will trust. This must match your GitHub username/repo and branch name. Case-sensitive.
- audiences: The intended recipient of the token. api://AzureADTokenExchange is the standard value for Azure OIDC.
Subject values are case-sensitive
The subject field must exactly match your GitHub username and repository name, including capitalisation. If your GitHub username is MyUser (not myuser), you must use MyUser in the subject. A mismatch causes silent authentication failures in the pipeline.
- Create the federated credential by running this command, replacing the app ID placeholder:
az ad app federated-credential create --id [[APP_ID="your appId"]] --parameters credential.json
You should see JSON output containing the name field github-actions-deploy-main, confirming the credential was created.
Seeing a file not found error?
Make sure your terminal is in the same directory where you saved credential.json. Use cd to navigate there, or provide the full path to the file in the command.
help me fix federated credential creation
Add the secrets to your GitHub repository
Your workflow needs three values to authenticate with Azure: the Client ID, Tenant ID, and Subscription ID. These go into GitHub repository secrets so the workflow can access them without exposing them in code.
- Open your repository on GitHub.
- Click **Settings** in the repository navigation bar.
- In the left sidebar under **Security**, select **Secrets and variables**, then click **Actions**.
- Click the **Secrets** tab.
- Click **New repository secret**.
- In the **Name** field, enter AZURE_CLIENT_ID.
- In the **Secret** field, paste the appId value from when you created the app registration.
- Click **Add secret**.
- Repeat the process to add a second secret with the name AZURE_TENANT_ID and the tenant ID value from the service principal output.
- Repeat once more to add a third secret with the name AZURE_SUBSCRIPTION_ID and your Azure subscription ID.
You should now see all three secrets listed on the repository secrets page: AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID.
Why OIDC instead of a stored password?
Traditional service principal authentication stores a client secret (essentially a password) in your GitHub secrets. That secret can expire, leak, or be stolen.
With OIDC, there is no password to steal. GitHub generates a unique, short-lived token for each workflow run, and Azure verifies it came from your specific repository. Microsoft recommends this as the standard approach for GitHub Actions deployments.
Not sure where to find each value?
- AZURE_CLIENT_ID: The appId from az ad app create output.
- AZURE_TENANT_ID: The appOwnerOrganizationId from az ad sp create output. You can also run az account show --query tenantId -o tsv.
- AZURE_SUBSCRIPTION_ID: Run az account show --query id -o tsv to print it.
help me find my Azure tenant and subscription IDs
Before you move on, do you think the command below will return results, or will it come back empty?
- Verify your app registration exists by running:
az ad app list --display-name "github-actions-deploy" --query "[].{Name:displayName, AppId:appId}" -o table
You should see a table with one row showing github-actions-deploy and your Client ID. Your federated credential is configured and ready for the pipeline.
Your GitHub Actions workflow now has a secure, passwordless path to Azure. Next up, you will create the Azure App Service and add the deployment job so your pipeline ships code live on every push.
Deploy to Azure App Service
Your pipeline can now test every push automatically. But when tests pass, what happens next? Right now, nothing. You still have to deploy the app by hand.
Adding a deployment job means every push to main that passes tests goes live automatically. This is the "CD" in CI/CD. It is the skill that separates a developer from a DevOps engineer.
In this step, get ready to:
- Create an Azure App Service as your deployment target.
- Add a deploy job to your GitHub Actions workflow.
- Push a code change and watch the full pipeline deploy your app live.
Create the Azure App Service
Your pipeline needs somewhere to deploy to. Azure App Service is a managed hosting platform that runs your Flask app without you configuring servers.
- Create a resource group to hold all your Azure resources by running this command:
az group create --name cicd-pipeline-rg --location eastus
- Create a Linux App Service Plan on the F1 free tier by running:
az appservice plan create --name cicd-pipeline-plan --resource-group cicd-pipeline-rg --sku F1 --is-linux
What do these commands do?
The resource group is a container that holds related Azure resources together. You can delete everything in one go later by deleting the group.
The App Service Plan defines the compute resources (CPU, memory) your app runs on. The F1 SKU is the free tier, so this costs nothing.
- Create the App Service (your actual web app) by running:
az webapp create --resource-group cicd-pipeline-rg --plan cicd-pipeline-plan --name [[APPNAME="your-unique-app-name"]] --runtime "PYTHON:3.12"
Why does the app name need to be unique?
Your app name becomes part of the public URL: https://your-app-name.azurewebsites.net. Since this URL is global, no two apps in all of Azure can share the same name. Choose something like yourname-cicd-pipeline.
- Confirm your App Service was created successfully by running:
az webapp show --name [[APPNAME="your-unique-app-name"]] --resource-group cicd-pipeline-rg --query "defaultHostName" --output tsv
You should see your app's hostname printed, like your-app-name.azurewebsites.net.
Seeing a region error?
The F1 free tier is not available in every Azure region. If you see an error about the scale operation not being allowed, try a different region like westus or westeurope in your az group create command. You are also limited to 1 free Linux app per subscription.
help me troubleshoot an Azure App Service creation error
Add the deploy job to your workflow
Your workflow currently has one job: build-and-test. You will add a second job called deploy that only runs after tests pass and only on pushes to main.
- Open .github/workflows/ci-cd.yml in your editor.
- Find the end of the build-and-test job (after the pytest tests/ -v line).
- Add the following deploy job below it, making sure it is at the same indentation level as build-and-test:
deploy:
runs-on: ubuntu-latest
needs: build-and-test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Login to Azure
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to Azure Web App
uses: azure/webapps-deploy@v3
with:
app-name: your-app-name-here
package: .
What does this deploy job do?
- needs: build-and-test means this job waits for tests to pass before running.
- The if condition ensures deployment only happens on pushes to main. Pull requests trigger tests but not deployment.
- azure/login@v3 uses OIDC (the federated credential you configured in Step 4) to authenticate without storing passwords.
- azure/webapps-deploy@v3 pushes your code to the App Service you just created.
- Replace your-app-name-here in the workflow with the unique app name you chose when creating the App Service.
- Save ci-cd.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before committing.
ⓧ I'd like to double check the full code
Here is the complete ci-cd.yml file. Confirm yours matches:
name: CI/CD Pipeline
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
id-token: write
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
The file continues with these sections (scroll up to confirm each is present, in order):
- Install dependencies step (pip install -r requirements.txt).
- Lint with ruff step (ruff check .).
- Run tests step (pytest tests/ -v).
- The deploy job with needs: build-and-test, the if condition, azure/login@v3, and azure/webapps-deploy@v3.
Push and verify the deployment
Time to test the full pipeline. You will push your workflow change, watch it test and deploy, then visit your live app.
- Commit and push the updated workflow by running:
git add .github/workflows/ci-cd.yml
git commit -m "Add deploy job to pipeline"
git push
- Open your GitHub repository in a browser.
- Click the **Actions** tab.
- Click on the running workflow ("Add deploy job to pipeline").
- Watch both the build-and-test job and the deploy job complete with green checkmarks.
What should I see?
The workflow visualization shows two jobs in sequence. The build-and-test job runs first. Once it passes, the deploy job starts. Both should finish with green checkmarks.
Before you check your live app, what do you expect to see when you visit the URL?
- Visit your live app URL in your browser:
https://[[APPNAME="your-unique-app-name"]].azurewebsites.net
You should see "Hello from CI/CD Pipeline!" displayed in your browser. Your app is live, deployed entirely through automation.
Seeing a default Azure page instead?
The first deployment can take 1-2 minutes to propagate. Wait 30 seconds and refresh. If you still see the default page, check the deploy job logs in the Actions tab for errors.
If the deploy job failed with an authentication error, confirm your three GitHub secrets (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID) match the values from your app registration. Also confirm the federated credential subject is exactly repo:YOUR-ORG/YOUR-REPO:ref:refs/heads/main (case-sensitive).
help me debug a failed GitHub Actions deployment to Azure App Service
Your pipeline now tests and deploys automatically on every push. Next up, you will take on the Secret Mission and add a staging environment with approval gates before production.
Secret mission
Add a Staging Environment with Approval Gates
Production deployments at real companies require human approval before code goes live. In this secret mission, you will add a staging environment that deploys automatically, then gate production behind a manual approval step. This mimics how real DevOps teams protect production from untested changes.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Azure's F1 Free tier exclusively, so there are no ongoing costs while the resources exist.
Resources you used:
- Azure resource group cicd-pipeline-rg (contains all Azure resources below).
- Azure App Service (production) running your Flask app on F1 free tier.
- Azure App Service (staging) running your Flask app on F1 free tier.
- Azure App Service Plan (F1 free tier, Linux).
- Microsoft Entra app registration (github-actions-deploy) with federated identity credentials.
- GitHub repository with Actions workflow, secrets, and the production environment.
- Local project folder on your Desktop.
Keep everything running
No action needed. Choose this if you want to keep your pipeline as a live portfolio piece.
- The F1 tier costs $0. Your app stays live at its .azurewebsites.net URL indefinitely.
- Your GitHub Actions workflow continues running on every push (free for public repositories).
- The Microsoft Entra app registration has no associated cost.
- Your GitHub repository with visible green checkmarks and pipeline history serves as proof of CI/CD skills to hiring managers.
Pause - I'll come back to this later
Stop the running App Services to free up the F1 slot for other projects. Your resources and code remain intact.
- Stop both App Services by running these commands:
az webapp stop --name your-app-name-here --resource-group cicd-pipeline-rg
az webapp stop --name your-app-name-staging --resource-group cicd-pipeline-rg
- Your apps will no longer respond at their URLs, but all configuration and deployment history stays in place.
- To resume later, run az webapp start with the same --name and --resource-group flags.
- Your GitHub repository and workflow remain fully functional. The next push still triggers the pipeline.
Delete - I don't want to use this again
Remove all Azure resources and start fresh. This is permanent.
- Delete the entire resource group (this removes both App Services and the App Service Plan) by running:
az group delete --name cicd-pipeline-rg --yes --no-wait
- Delete the Microsoft Entra app registration by running:
az ad app delete --id [[APP_ID="your app registration application (client) ID"]]
Find your app ID
If you do not have your application ID saved, run the following command to find it:
az ad app list --display-name "github-actions-deploy" --query "[].appId" -o tsv
- Delete the GitHub repository secrets by navigating to your repository on GitHub.
- Click Settings in the top menu.
- Select Secrets and variables, then Actions.
- Delete each of the three secrets: AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID.
- Optionally, delete the entire GitHub repository if you do not want it on your profile. Go to Settings, scroll to the bottom, and click Delete this repository.
- Delete the local project folder by running:
macOS
rm -rf ~/Desktop/[[REPO_FOLDER="your repository folder name"]]
Windows
Remove-Item -Recurse -Force ~\Desktop\[[REPO_FOLDER="your repository folder name"]]
Nice Work!
Nice Work!
Nice work! You just built a production-grade CI/CD pipeline that automatically lints, tests, and deploys a live Python app to Azure on every push.
You've learned how to:
- Build a GitHub Actions workflow that automatically lints with ruff, runs pytest, and deploys on every push to main.
- Experience a real pipeline failure, debug it from logs, and fix it. This is the core CI/CD feedback loop every DevOps team relies on daily.
- Configure secure OIDC authentication between GitHub and Azure using workload identity federation. No passwords or secrets stored anywhere.
- Deploy a live Python app to Azure App Service through pure automation, with a public URL you can share on your resume.
- Secret Mission: Added a staging environment with manual approval gates before production deployment, mimicking real-world deployment governance.
Ready to quiz yourself?