University of Wales Trinity Saint David
ACCA7023 - Agile and DevOps

Continuous Deployment to Azure Container Apps

Overview

This lab closes the gap between a merged change and a running product. You will extend the pipeline so that every merge to main publishes the container image to a registry, deploys it to Azure Container Apps as a new revision, and then checks that the live site is really serving the new version. No password for Azure is stored anywhere. You will then ship a visible change, roll it back, and roll forward again, all through the pipeline.

Learning Objectives

By the end of this lab you will be able to:

Concept

A pipeline that stops at "the tests passed" leaves the riskiest step, putting the software in front of users, to a person following a list. Manual deployments are slow, so they are done rarely. Rare deployments are large, so they are frightening, and every one is slightly different from the last.

Continuous deployment makes release an automatic consequence of merging. The same image that was tested is the one that ships, identified by the commit it was built from. A container registry stores images so that a hosting platform can pull them. Here it is GitHub Container Registry. Azure Container Apps runs containers without you managing servers. Each deployment creates a revision, an unchangeable snapshot of the app with one specific image. Ingress traffic is directed at a revision, so rolling back is redirecting traffic to an older revision that still exists. Nothing is rebuilt.

The pipeline has to prove to Azure who it is. Storing a password as a secret works until it leaks or expires. OpenID Connect avoids storing anything. GitHub issues the running job a short-lived signed token stating which repository and branch it belongs to. Azure has been told in advance to trust tokens with exactly that description, through a federated credential on a managed identity, and exchanges it for temporary access. A smoke test then asks the live site a simple question to confirm the deployment worked.

Tasks

Task 1 - Find out what you have been given

Your lecturer has created one Container App for you and will give you six values: a client ID, a tenant ID, a subscription ID, a resource group name, a Container App name, and the public address of your app. None of them is a password. They identify things, and knowing them grants nothing.

The identity those values refer to has been given permission to update your Container App and nothing else. It will only accept requests from workflows running on the main branch of your repository. You cannot create other resources in Azure, and you cannot affect another student's app.

Open the public address in a browser.

Success condition: a Notes page loads. It is a placeholder build put there when the app was created, not yours. Add /api/health to the address and note the version it reports. The first request may take several seconds, because the app scales down to zero containers when nobody is using it and has to start one.

Task 2 - Store the settings as repository variables

  1. In your repository open Settings, Secrets and variables, Actions, and choose the Variables tab. Not the Secrets tab.
  2. Create five repository variables with exactly these names, using the values you were given.
NameValue
AZURE_CLIENT_IDthe client ID
AZURE_TENANT_IDthe tenant ID
AZURE_SUBSCRIPTION_IDthe subscription ID
AZURE_RESOURCE_GROUPthe resource group name
AZURE_CONTAINER_APPyour Container App name

Variables are readable by anybody who can see the workflow. That is acceptable here, because there is no secret among them. The Secrets tab stays empty for the whole of this lab.

Task 3 - Make the CI workflow reusable

Create an issue named Deploy automatically on merge, move it to In progress, and start a branch.

git switch main
git pull
git switch -c cd/publish-image

The deployment must not skip the checks. Rather than copy them, let another workflow call the existing one. Open .github/workflows/ci.yml and add workflow_call to its on section, so that the section reads:

.github/workflows/ci.yml
on:
  pull_request:
    branches: [main]
  # Lets the deploy workflow run these same checks before it publishes.
  workflow_call:

Task 4 - Publish the image on every merge

Create .github/workflows/deploy.yml with the content below. It is the first half of the deployment workflow.

  • on: push to main is what a merged pull request produces.
  • concurrency queues a second run behind the first, so two deployments never overlap.
  • The ci job runs the whole CI workflow again, this time against the merged result.
  • The publish job is the only one granted packages: write. It logs in to the registry with the workflow's own temporary token, builds the image with the commit identifier as its version, tags it with that same identifier, and pushes it.
  • outputs hands the full image name to whichever job comes next.
.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

# One deployment at a time. A second merge waits for the first to finish.
concurrency: deploy-main

permissions:
  contents: read

jobs:
  ci:
    name: CI
    uses: ./.github/workflows/ci.yml

  publish:
    name: Publish image
    runs-on: ubuntu-latest
    needs: ci
    permissions:
      contents: read
      packages: write
    outputs:
      image: ${{ steps.name.outputs.image }}
    steps:
      - uses: actions/checkout@v4
      - name: Work out the image name
        id: name
        # Registry names must be lower case, and GitHub user names may not be.
        run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
      - name: Log in to GitHub Container Registry
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
      - name: Build the image
        run: docker build --build-arg APP_VERSION=${{ github.sha }} -t "${{ steps.name.outputs.image }}" .
      - name: Push the image
        run: docker push "${{ steps.name.outputs.image }}"

Commit, push, open a pull request, wait for the four checks, and merge it. Do not close the issue yet.

git add .github
git commit -m "Publish image to GHCR on merge"
git push -u origin cd/publish-image

Success condition: after the merge, the Actions tab shows a new Deploy run on main. The four CI jobs run, then Publish image. When it finishes, the front page of your repository lists a package named notes-devops under Packages.

Task 5 - Make the package public

A new package is private. Azure would need a registry password to pull it, which is exactly the kind of stored secret this design avoids. The source is already public, so the image can be too.

  1. Click the notes-devops package on your repository's front page.
  2. Open Package settings.
  3. Under Danger Zone choose Change visibility, select Public, and confirm.

Success condition: the package page shows a Public label, and lists one version tagged with a 40-character commit identifier.

Task 6 - Deploy and verify

git switch main
git pull
git switch -c cd/deploy-to-azure

Open .github/workflows/deploy.yml and add the deploy job to the end. deploy: lines up with publish:.

  • id-token: write allows this one job to ask GitHub for an identity token. Without it the login step has nothing to present.
  • azure/login is given three identifiers and no secret. It performs the token exchange described in the Concept section.
  • az containerapp update --image is the deployment. It creates a new revision running the image that publish just pushed. The revision's name includes the short commit identifier, so you can tell revisions apart.
  • The last step writes a table of revisions to the run's summary page.
.github/workflows/deploy.yml
deploy:
    name: Deploy to Azure Container Apps
    runs-on: ubuntu-latest
    needs: publish
    permissions:
      contents: read
      # Allows this job to request the short-lived token Azure exchanges.
      id-token: write
    outputs:
      url: ${{ steps.url.outputs.url }}
    steps:
      - name: Log in to Azure without a stored secret
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - name: Create a new revision from the new image
        run: |
          az containerapp update \
            --name "${{ vars.AZURE_CONTAINER_APP }}" \
            --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
            --image "${{ needs.publish.outputs.image }}" \
            --revision-suffix "sha-${GITHUB_SHA::7}-${GITHUB_RUN_ATTEMPT}" \
            --output none
      - name: Look up the public address
        id: url
        run: |
          fqdn=$(az containerapp show \
            --name "${{ vars.AZURE_CONTAINER_APP }}" \
            --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
            --query properties.configuration.ingress.fqdn --output tsv)
          echo "url=https://${fqdn}" >> "$GITHUB_OUTPUT"
      - name: List revisions in the run summary
        run: |
          {
            echo "### Revisions"
            echo '```'
            az containerapp revision list \
              --name "${{ vars.AZURE_CONTAINER_APP }}" \
              --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
              --query "[].{name:name, active:properties.active, traffic:properties.trafficWeight, created:properties.createdTime}" \
              --output table
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

Then add the smoke job. "The deployment command succeeded" and "users are getting the new version" are different claims. This job checks the second one. It asks the live health endpoint which version is running, and passes only when the answer is the commit that was just deployed. It retries for up to five minutes, because a new revision takes time to start.

.github/workflows/deploy.yml
smoke:
    name: Smoke test
    runs-on: ubuntu-latest
    needs: deploy
    steps:
      - name: Wait for the new version to answer
        run: |
          url="${{ needs.deploy.outputs.url }}/api/health"
          for attempt in $(seq 1 20); do
            version=$(curl --silent --fail --max-time 10 "$url" | jq -r .version || true)
            echo "Attempt ${attempt}: version=${version}"
            if [ "$version" = "$GITHUB_SHA" ]; then
              echo "The live site is serving ${GITHUB_SHA}" >> "$GITHUB_STEP_SUMMARY"
              exit 0
            fi
            sleep 15
          done
          echo "The live site never reported version ${GITHUB_SHA}"
          exit 1

Create one more workflow, .github/workflows/rollback.yml. It is started by hand, not by a push. You will use it shortly.

.github/workflows/rollback.yml
name: Rollback

on:
  workflow_dispatch:
    inputs:
      revision:
        description: Revision to send all traffic to. Type latest to return to normal. Leave empty to only list revisions.
        required: false
        default: ''

concurrency: deploy-main

permissions:
  contents: read
  id-token: write

jobs:
  rollback:
    name: Move traffic
    runs-on: ubuntu-latest
    steps:
      - name: Log in to Azure without a stored secret
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - name: Move all traffic to the chosen revision
        if: ${{ inputs.revision != '' }}
        env:
          REVISION: ${{ inputs.revision }}
        run: |
          az containerapp ingress traffic set \
            --name "${{ vars.AZURE_CONTAINER_APP }}" \
            --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
            --revision-weight "${REVISION}=100" \
            --output none
      - name: List revisions in the run summary
        run: |
          {
            echo "### Revisions"
            echo '```'
            az containerapp revision list \
              --name "${{ vars.AZURE_CONTAINER_APP }}" \
              --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \
              --query "[].{name:name, active:properties.active, traffic:properties.trafficWeight, created:properties.createdTime}" \
              --output table
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

Commit, push, open a pull request, wait for green, and merge.

git add .github
git commit -m "Deploy to Azure Container Apps with smoke test"
git push -u origin cd/deploy-to-azure

Success condition: the Deploy run on main shows seven jobs in a chain ending with a green Smoke test. Its log shows the version changing from the old value to your commit identifier. Open your app's address. It is now your build. /api/health reports your commit identifier as the version.

If Log in to Azure fails, check the five variable names and values for typing mistakes, and check that the run is on main. If it still fails, give your lecturer the exact error message. It usually means the repository name they registered does not match yours exactly.

Task 7 - Ship a visible change

Close the deployment issue. Then deliver a small change the way every change will now travel. Create an issue named Personalise the page heading and a branch for it.

git switch main
git pull
git switch -c feature/page-heading

Open client/src/App.tsx and change the main heading from Notes to My Notes. Commit, push, open a pull request that closes the issue, wait for green, and merge. Then do nothing.

git commit -am "Personalise the page heading"
git push -u origin feature/page-heading

Success condition: without any further action from you, the live site shows My Notes a few minutes after the merge. Time it, from pressing merge to seeing the change. That number is your lead time for a change that is already written.

Task 8 - Roll back

Suppose the new heading turned out to be a mistake and has to go, now.

  1. Open the Actions tab, choose the Rollback workflow, and choose Run workflow. Leave the revision box empty and run it.
  2. Open the finished run. Its summary lists your revisions with their creation times and how much traffic each receives. The newest has 100.
  3. Copy the name of the revision created just before the newest one.
  4. Run the Rollback workflow again, this time pasting that name into the revision box.

Success condition: within a minute the live site says Notes again, and /api/health reports the earlier commit. The summary shows the older revision with 100 and the newest with 0. Nothing was rebuilt, retested or redeployed. Traffic was pointed at something that already existed. Compare the time this took with your lead time from the previous task.

The repository still contains the "bad" change, and the site is now pinned to an old revision. A new deployment would create a revision that receives no traffic. Rollback buys time. It does not fix anything. Return to normal by running Rollback once more with latest in the revision box, and confirm the site says My Notes.

Task 9 - Look at what the running app logs

This task needs the Azure portal. If your lecturer has not given your account access to the resource group, read it and move on.

  1. Sign in to the Azure portal, open the resource group, and open your Container App.
  2. Under Monitoring open Log stream. Reload your live site a few times and watch for output. You should see the start-up line Notes API listening on port 3000 whenever a new container starts.
  3. Under Application open Revisions and replicas and find the same revisions the pipeline listed.

You have read access only. Notice that everything you changed in Azure during this lab was changed by the pipeline, and is recorded in the history of the Actions tab with the commit that caused it.

Task 10 - Evaluate

Explain why nothing in this pipeline would need to change, and nothing would need to be revoked, if the entire contents of the repository settings page were leaked.

The settings hold only identifiers: client, tenant and subscription IDs and resource names. They are not credentials. Access is granted by Azure in exchange for a token that GitHub issues only to a workflow actually running on the main branch of this specific repository, and that token expires within minutes. An attacker holding the identifiers cannot produce such a token. With a stored client secret, the same leak would hand over working credentials until somebody rotated them.

The smoke test checks that the health endpoint reports the new version. Name one failure it would catch that the deploy step would not, and one failure it would still miss.

It would catch a revision that was created but never became healthy or never received traffic: a container that crashes on start, a wrong port, or traffic still pinned to an old revision. The deploy command reports success in all of those cases. It would miss anything beyond the health endpoint: a page that fails to load its scripts, a broken create or delete flow, or slow responses. Running the browser tests against the live address would catch more, at the cost of writing test data into production.

Every merge to main now goes live without a person approving the release. Argue for or against that in the context of this product, then say what would change your answer.

For: changes are small, the pipeline checks every one of them, rollback takes under a minute, and removing the manual step removes delay and inconsistency. Against: the tests cannot judge whether a change is wanted, and notes are lost on every deployment because storage is in memory. What changes the answer is the cost of a bad release. With real user data, regulatory obligations, or a database migration that cannot be undone by redirecting traffic, a manual approval gate or a gradual rollout to a fraction of traffic becomes worth its delay.

Solution

Try the tasks above before revealing the solution.