DEV Community

Cover image for From Zero to Live: Containerise a Python App, Deploy It to Azure Container Apps and Automate It with GitHub Actions
Rahimah Sulayman
Rahimah Sulayman

Posted on

From Zero to Live: Containerise a Python App, Deploy It to Azure Container Apps and Automate It with GitHub Actions

Introduction

I recently completed the ShipIt Weekend: Deploy Your First App to Azure lab. It has three missions:

  1. Build it: write a small web app, package it in a Docker image and run it locally.
  2. Ship it: publish the image to GitHub Container Registry (GHCR) and run it on Azure Container Apps with a public HTTPS URL.
  3. Automate it: add a GitHub Actions pipeline that builds, pushes and deploys on every push to main.

I worked through everything from a Linux terminal (Ubuntu on WSL, on a Windows laptop). This is not a polished "everything worked first time" tutorial. I made a few mistakes along the way: I named my repository differently from what the lab expected, my pipeline failed on its first run, and my first credential claim was rejected. I've kept those moments in, because that is where most of the learning happens.

What you'll need:

  • A GitHub account
  • An Azure account with an active subscription
  • Docker, Git and the Azure CLI installed in a Linux-style terminal (WSL works well on Windows)

A note on screenshots: I blurred subscription IDs, tenant IDs and secrets in the images below. I also deleted the token, the service principal and the Azure resources after finishing, so nothing shown here can be used. Do the same, and never publish real credentials.


Mission 1: Build it, containerise a web app

Step 1: Create the project and a Git repository

mkdir -p ~/shipit-app && cd ~/shipit-app
git init -b main
Enter fullscreen mode Exit fullscreen mode
  • mkdir -p creates the folder (and doesn't complain if it already exists), and && runs cd only if that succeeded.
  • git init -b main turns the folder into a Git repository and names the first branch main.

Creating the project folder and initialising Git

Step 2: Write the web app

The app uses only Python's standard library, so there is nothing to install and less that can break.

cat > app.py << 'EOF'
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

MESSAGE = "Hello from ShipIt Weekend!"


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body = f"<h1>{MESSAGE}</h1><p>Built with Docker, shipped to Azure, automated with GitHub Actions.</p>".encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


HTTPServer(("0.0.0.0", int(os.environ.get("PORT", "8080"))), Handler).serve_forever()
EOF
Enter fullscreen mode Exit fullscreen mode

The cat > app.py << 'EOF' pattern is a here-document: everything up to the EOF line is written into app.py. The quotes around 'EOF' stop the shell from interpreting anything inside. The server listens on port 8080 (or whatever PORT says) and answers every request with a small HTML page.

Writing app.py with a here-document

Step 3: Write the Dockerfile

cat > Dockerfile << 'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
ENV PORT=8080
EXPOSE 8080
USER nobody
CMD ["python", "app.py"]
EOF
Enter fullscreen mode Exit fullscreen mode
  • FROM python:3.12-slim starts from a small official Python image.
  • WORKDIR /app and COPY app.py . put the app in the container.
  • ENV PORT=8080 and EXPOSE 8080 set and document the port.
  • USER nobody runs the app as an unprivileged user instead of root. This is a small but important security habit.
  • CMD is the command that runs when the container starts.

Writing the Dockerfile

Step 4: Build and run it locally

docker build -t shipit-app .
Enter fullscreen mode Exit fullscreen mode

docker build reads the Dockerfile in the current folder (the .) and -t shipit-app names the image. The first build downloads the Python base image, so it took about 16 seconds for me.

Building the Docker image

docker run -d --rm -p 8080:8080 --name shipit shipit-app
curl http://localhost:8080
docker stop shipit
Enter fullscreen mode Exit fullscreen mode
  • -d runs the container in the background, --rm deletes it when it stops, -p 8080:8080 maps the port to your machine, and --name shipit gives it a name.
  • curl fetches the page, and docker stop shuts the container down.

The app answered with the HTML heading, so the container worked.

Running the container, testing it with curl and stopping it

Step 5: Commit, create a GitHub repository and push

git add app.py Dockerfile
git commit -m "feat: containerised web app"
Enter fullscreen mode Exit fullscreen mode

Before pushing, I created an empty repository on GitHub. This is where I made my first mistake: I named it shipit, but the lab expects shipit-app. I didn't notice until much later, and it caused a confusing problem at the end (more on that in Mission 3). I connected my local repo to it like this:

git remote add origin git@github.com:rahimahisah17/shipit.git
git push -u origin main
Enter fullscreen mode Exit fullscreen mode
  • git remote add origin <url> registers the GitHub repo under the nickname origin. I used the SSH URL because I already had SSH keys set up.
  • git push -u origin main uploads the main branch and links it to origin/main, so a plain git push works from now on.

Committing, adding the remote and pushing to the shipit repository

Tip: If you follow the lab, name your GitHub repository shipit-app from the start. The final credential check expects that name.


Mission 2: Ship it, GHCR and Azure Container Apps

Step 1: Set your GitHub username and link the image to the repo

export GH_USER=rahimahisah17
echo "LABEL org.opencontainers.image.source=https://github.com/$GH_USER/shipit" >> Dockerfile
git commit -am "chore: link image to repository" && git push
Enter fullscreen mode Exit fullscreen mode
  • export GH_USER=... stores your username in a variable, so later commands don't need it typed out. It must be lowercase.
  • The LABEL line adds the repo's URL to the image as metadata, so GitHub can link the package to the repository. >> appends to the file (a single > would overwrite it).
  • git commit -am stages and commits changes to tracked files in one step.

Small mistake #1: When I first ran echo $GH_USER it printed nothing. I had opened a new terminal session, and environment variables don't survive that. I ran export again and it was fine. If a command suddenly produces strange image names or URLs, check that your variables are still set.

Setting GH_USER, adding the LABEL and pushing

Step 2: Log in to GitHub Container Registry

GHCR is free for public images. Docker needs a personal access token to log in:

  1. GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
  2. Generate a token with only the write:packages scope and a short expiry.
echo YOUR_TOKEN | docker login ghcr.io -u $GH_USER --password-stdin
Enter fullscreen mode Exit fullscreen mode

--password-stdin makes Docker read the token from the pipe instead of from the command line, which is safer. I got Login Succeeded.

Logging in to ghcr.io (token blurred)

Security note: Treat the token like a password. Don't share it, don't screenshot it unblurred, and delete it when you've finished.

Step 3: Build, tag and push the image

docker build -t ghcr.io/$GH_USER/shipit-app:v1 .
docker push ghcr.io/$GH_USER/shipit-app:v1
Enter fullscreen mode Exit fullscreen mode

The tag has four parts: the registry (ghcr.io), the owner (your username), the image name (shipit-app) and the version (v1). Because the layers were cached, the build took 2.6 seconds.

Building the image with the GHCR tag

The push uploaded each layer and ended with the image digest.

Pushing the image to GHCR

Step 4: Make the package public

A new GHCR package is private by default, as the badge on its page shows.

The package page showing it is Private

Azure has to pull the image without a password, so it must be public. In the package's Package settings, go to the Danger Zone, click Change visibility, choose Public and type the package name to confirm.

Changing the package visibility to Public

Afterwards the Danger Zone says "This package is currently public."

The package is now public

Step 5: Sign in to Azure and prepare the subscription

az login --use-device-code
Enter fullscreen mode Exit fullscreen mode

--use-device-code gives you a code and a URL to open in a browser, which works well in WSL, where a browser often can't open automatically. You'll see a couple of yellow warnings ("Attempted too soon" and a note about a tenant without subscriptions). They look alarming but are harmless: the login still completes and lists your subscription.

Signing in with a device code

az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App --wait
az provider register --namespace Microsoft.OperationalInsights --wait
Enter fullscreen mode Exit fullscreen mode
  • The first command installs the CLI extension that provides the az containerapp commands. Mine was already installed and up to date, which is fine.
  • The provider register commands switch on the services Container Apps needs (the Container Apps service itself and Log Analytics for logs). A new subscription must do this once. With --wait they print nothing when they succeed, and the prompt simply returns.

Installing the extension and registering the providers

Step 6: Create a resource group

az group create --name shipit-rg --location westeurope
Enter fullscreen mode Exit fullscreen mode

A resource group is a folder-like container for everything in this lab, which makes cleanup easy at the end. The JSON output showed "provisioningState": "Succeeded".

Creating the resource group

Step 7: Deploy to Azure Container Apps

az containerapp up \
  --name shipit-app \
  --resource-group shipit-rg \
  --location westeurope \
  --image ghcr.io/$GH_USER/shipit-app:v1 \
  --ingress external \
  --target-port 8080
Enter fullscreen mode Exit fullscreen mode

This one command creates a Container Apps environment, a Log Analytics workspace and the app itself.

  • --image points at the image in GHCR (this works because the package is public).
  • --ingress external gives the app a public address.
  • --target-port 8080 tells Azure which port inside the container receives traffic.

It's slow. Mine ran for over five minutes on a spinner while Azure created the environment and workspace. Don't press Ctrl+C: stopping halfway can leave half-created resources behind. When it finished, it printed the public URL.

Deploying with az containerapp up

Step 8: Test the live app

export APP_URL=https://$(az containerapp show --name shipit-app --resource-group shipit-rg --query properties.configuration.ingress.fqdn -o tsv)
echo $APP_URL
curl $APP_URL
Enter fullscreen mode Exit fullscreen mode
  • az containerapp show fetches the app's details, and --query ... -o tsv picks out only the hostname as plain text.
  • $(...) drops that result into the variable.

The warning about the extension altering the command's behaviour is harmless. curl returned my page from the public internet.

Reading the app URL and testing it with curl


Mission 3: Automate it, CI/CD with GitHub Actions

Step 1: Give GitHub Actions access, with least privilege

The pipeline needs permission to update the app, but only that app's resource group.

export SUB_ID=$(az account show --query id -o tsv)
az ad sp create-for-rbac --name shipit-github --role contributor \
  --scopes /subscriptions/$SUB_ID/resourceGroups/shipit-rg --json-auth
Enter fullscreen mode Exit fullscreen mode
  • az ad sp create-for-rbac creates a service principal, an identity for automation (like a robot account).
  • --role contributor lets it create and update resources.
  • --scopes ... limits that to the shipit-rg resource group only. This is the least-privilege idea in practice.
  • --json-auth prints the credentials in the JSON format the GitHub login action expects.

The output contains a clientSecret. You'll see a warning about it, plus a deprecation notice for --json-auth (it still worked for me).

Creating the service principal (secrets blurred)

Copy the whole JSON and save it as a GitHub secret: your repo → Settings → Secrets and variables → Actions → New repository secret, with the name AZURE_CREDENTIALS.

Saving the JSON as the AZURE_CREDENTIALS secret (blurred)

Step 2: Write the pipeline

mkdir -p .github/workflows
Enter fullscreen mode Exit fullscreen mode

The folder must be named exactly .github/workflows, because that's where GitHub looks for workflows.

Creating the workflows folder

cat > .github/workflows/deploy.yml << 'EOF'
name: deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  packages: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push the image
        run: |
          IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/shipit-app:${GITHUB_SHA::7}
          docker build -t "$IMAGE" .
          docker push "$IMAGE"
          echo "IMAGE=$IMAGE" >> "$GITHUB_ENV"

      - name: Log in to Azure
        uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Deploy to Azure Container Apps
        run: az containerapp update --name shipit-app --resource-group shipit-rg --image "$IMAGE"
EOF
Enter fullscreen mode Exit fullscreen mode

What the workflow does:

  • on: runs it on every push to main, and lets you start it manually (workflow_dispatch).
  • permissions: lets it read the code and write packages.
  • Log in to GHCR: uses a temporary built-in token, GITHUB_TOKEN, so no personal token is needed in the pipeline.
  • Build and push: tags the image with the first 7 characters of the commit ID, so every deployment is traceable to a commit.
  • Log in to Azure: uses the AZURE_CREDENTIALS secret.
  • Deploy: az containerapp update points the app at the new image.

Writing the workflow file

The terminal in this screenshot looks jumbled because of how it displayed my paste, but the file itself was fine, as the workflow runs below prove.

Step 3: Change the app and push

I changed the app's message so the pipeline had something new to deploy:

sed -i.bak 's/Hello from ShipIt Weekend!/Shipped automatically by GitHub Actions!/' app.py && rm app.py.bak
grep MESSAGE app.py
git add .github/workflows/deploy.yml app.py
git commit -m "ci: deploy to Azure on every push"
git push
Enter fullscreen mode Exit fullscreen mode
  • sed -i.bak 's/old/new/' does an in-place find-and-replace and keeps a backup, which rm then deletes.
  • grep MESSAGE app.py confirms the change before I committed.

Editing app.py, committing and pushing

Step 4: The first pipeline run fails

I opened the Actions tab and the run had failed after just 19 seconds.

The first workflow run fails

I opened the failing job and found the red step: Build and push the image. The image had built fine, but the log ended with:

denied: permission_denied: write_package
Error: Process completed with exit code 1.
Enter fullscreen mode Exit fullscreen mode

Why it happened: I had first pushed the shipit-app image from my laptop using my personal token, so the package belongs to my account. The workflow's built-in GITHUB_TOKEN is a different identity, and it had no permission to write to that existing package.

Step 5: The fix, give the repository write access to the package

In the package's Package settings → Manage Actions access, I clicked Add Repository, chose my repo and gave it the Write role. That grants only this one repository permission to push to this one package.

Giving the repository Write access to the package

Then I clicked Re-run all jobs on the failed run, and this time it passed in 45 seconds.

The re-run succeeds

Step 6: Verify the deployment

curl $APP_URL
Enter fullscreen mode Exit fullscreen mode

The response now showed the new heading, "Shipped automatically by GitHub Actions!", which proves the pipeline built, pushed and deployed the change without me touching Azure.

curl shows the new message; the remote URL is updated after the repo rename

And in the browser:

The live app in the browser

Step 7: The repository name problem and claiming the credential

The lab ends by asking you to claim a verified credential, using your live URL and the repository https://github.com/<your-username>/shipit-app.

My repository was called shipit, not shipit-app. My first claim attempt failed with the message "We couldn't reach your live URL". The app was up and curl returned the page, so I first suspected Azure had scaled the app to zero while idle and the checker gave up before it woke. I checked the app with curl and got a successful response.

The real problem turned out to be the repository name. The form still contained .../shipit. After I renamed the repository to shipit-app and submitted the matching URL, the claim worked. The error message pointed at the wrong thing, so the lesson is: when a validation message doesn't make sense, re-check every input against the instructions, not just the one the message mentions.

[Screenshot placeholder: the failed claim showing "We couldn't reach your live URL"]

To rename the repo, I went to the repo's Settings → General → Repository name, entered shipit-app and clicked Rename. GitHub redirects the old name, and the secret, workflow and Actions history all move with the repo. Then I updated my local remote:

git remote set-url origin git@github.com:rahimahisah17/shipit-app.git
Enter fullscreen mode Exit fullscreen mode

git remote set-url changes the address stored for origin, so future pushes go to the renamed repo.

failed claim

Step 8: Clean up

Deleting what you no longer need is how you make sure nothing is ever charged:

az group delete --name shipit-rg --yes --no-wait
az ad sp delete --id $(az ad sp list --display-name shipit-github --query "[0].id" -o tsv)
az group exists --name shipit-rg
Enter fullscreen mode Exit fullscreen mode
  • az group delete removes the resource group and everything in it: the app, its environment and the logs. --yes skips the prompt and --no-wait returns immediately while Azure deletes in the background.
  • az ad sp delete removes the service principal. Deleting the resource group does not remove it.
  • az group exists prints true while the group is still there and false once it's gone.

Deletion takes a few minutes. I got true twice, then Deleting from az group show, then finally false. I also deleted the AZURE_CREDENTIALS secret and the personal access token.

Cleaning up the Azure resources


Troubleshooting summary

What happened Cause Fix
echo $GH_USER printed nothing I opened a new terminal, and variables don't persist between sessions Run export GH_USER=... again
Package couldn't be pulled by Azure GHCR packages are private by default Set the package visibility to Public
az containerapp up seemed stuck Creating the environment and Log Analytics workspace takes several minutes Wait, and don't press Ctrl+C
Pipeline failed with permission_denied: write_package The package was first pushed with my personal token, so the workflow's GITHUB_TOKEN couldn't write to it Add the repo under Manage Actions access with the Write role, then re-run
Credential claim said "couldn't reach your live URL" My repo was named shipit, not shipit-app Rename the repo to shipit-app and use the matching URL

Lessons learned

  • Follow naming conventions in guided labs. My shipit vs shipit-app mismatch caused the longest detour.
  • Read the failing step, not just the red X. The Actions log told me exactly what was wrong: write_package.
  • Least privilege pays off. The service principal can only touch one resource group, and the repo has write access to only one package.
  • No secrets in code. The Azure credentials live in an encrypted GitHub secret, and the pipeline's registry login uses a temporary token.
  • Clean up. Deleting the resource group, the service principal, the secret and the token means nothing keeps running or stays exposed.

One thing I'd still tidy up: my Dockerfile's LABEL line still points at the old repository name (shipit). GitHub redirects the old name, so nothing broke, but updating it would be cleaner.


Summary

In one weekend-sized lab I went from an empty folder to a live HTTPS app on Azure with a working CI/CD pipeline:

  1. I wrote a tiny Python web server and packaged it in a Docker image that runs as a non-root user.
  2. I pushed the image to GitHub Container Registry, made it public, and deployed it to Azure Container Apps with the Azure CLI.
  3. I created a least-privilege service principal and a GitHub Actions workflow, so every push to main builds, pushes and deploys automatically.
  4. I debugged a package permission error and a repository naming mismatch, then cleaned up all the Azure resources.

The app itself is simple, but the workflow behind it is the real thing: containers, a registry, cloud hosting, least-privilege access and automated deployment. If you're learning DevOps or cloud, I'd recommend trying it yourself, mistakes included.

The code is on GitHub: rahimahisah17/shipit-app

Thanks for reading! If you've hit similar errors in your own pipelines, I'd love to hear how you solved them in the comments.

Top comments (0)