Introduction
I recently completed the ShipIt Weekend: Deploy Your First App to Azure lab. It has three missions:
- Build it: write a small web app, package it in a Docker image and run it locally.
- Ship it: publish the image to GitHub Container Registry (GHCR) and run it on Azure Container Apps with a public HTTPS URL.
- 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 -
mkdir -pcreates the folder (and doesn't complain if it already exists), and&&runscdonly if that succeeded. -
git init -b mainturns the folder into a Git repository and names the first branchmain.
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 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.
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 -
FROM python:3.12-slimstarts from a small official Python image. -
WORKDIR /appandCOPY app.py .put the app in the container. -
ENV PORT=8080andEXPOSE 8080set and document the port. -
USER nobodyruns the app as an unprivileged user instead of root. This is a small but important security habit. -
CMDis the command that runs when the container starts.
Step 4: Build and run it locally
docker build -t shipit-app . 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.
docker run -d --rm -p 8080:8080 --name shipit shipit-app curl http://localhost:8080 docker stop shipit -
-druns the container in the background,--rmdeletes it when it stops,-p 8080:8080maps the port to your machine, and--name shipitgives it a name. -
curlfetches the page, anddocker stopshuts the container down.
The app answered with the HTML heading, so the container worked.
Step 5: Commit, create a GitHub repository and push
git add app.py Dockerfile git commit -m "feat: containerised web app" 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 -
git remote add origin <url>registers the GitHub repo under the nicknameorigin. I used the SSH URL because I already had SSH keys set up. -
git push -u origin mainuploads themainbranch and links it toorigin/main, so a plaingit pushworks from now on.
Tip: If you follow the lab, name your GitHub repository
shipit-appfrom 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 -
export GH_USER=...stores your username in a variable, so later commands don't need it typed out. It must be lowercase. - The
LABELline 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 -amstages 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.
Step 2: Log in to GitHub Container Registry
GHCR is free for public images. Docker needs a personal access token to log in:
- GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
- Generate a token with only the
write:packagesscope and a short expiry.
echo YOUR_TOKEN | docker login ghcr.io -u $GH_USER --password-stdin --password-stdin makes Docker read the token from the pipe instead of from the command line, which is safer. I got Login Succeeded.
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 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.
The push uploaded each layer and ended with the image digest.
Step 4: Make the package public
A new GHCR package is private by default, as the badge on its page shows.
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.
Afterwards the Danger Zone says "This package is currently public."
Step 5: Sign in to Azure and prepare the subscription
az login --use-device-code --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.
az extension add --name containerapp --upgrade az provider register --namespace Microsoft.App --wait az provider register --namespace Microsoft.OperationalInsights --wait - The first command installs the CLI extension that provides the
az containerappcommands. Mine was already installed and up to date, which is fine. - The
provider registercommands 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--waitthey print nothing when they succeed, and the prompt simply returns.
Step 6: Create a resource group
az group create --name shipit-rg --location westeurope 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".
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 This one command creates a Container Apps environment, a Log Analytics workspace and the app itself.
-
--imagepoints at the image in GHCR (this works because the package is public). -
--ingress externalgives the app a public address. -
--target-port 8080tells 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.
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 -
az containerapp showfetches the app's details, and--query ... -o tsvpicks 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.
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 -
az ad sp create-for-rbaccreates a service principal, an identity for automation (like a robot account). -
--role contributorlets it create and update resources. -
--scopes ...limits that to theshipit-rgresource group only. This is the least-privilege idea in practice. -
--json-authprints 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).
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.
Step 2: Write the pipeline
mkdir -p .github/workflows The folder must be named exactly .github/workflows, because that's where GitHub looks for workflows.
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 What the workflow does:
-
on:runs it on every push tomain, 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_CREDENTIALSsecret. - Deploy:
az containerapp updatepoints the app at the new image.
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 -
sed -i.bak 's/old/new/'does an in-place find-and-replace and keeps a backup, whichrmthen deletes. -
grep MESSAGE app.pyconfirms the change before I committed.
Step 4: The first pipeline run fails
I opened the Actions tab and the run had failed after just 19 seconds.
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. 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.
Then I clicked Re-run all jobs on the failed run, and this time it passed in 45 seconds.
Step 6: Verify the deployment
curl $APP_URL 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.
And 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 git remote set-url changes the address stored for origin, so future pushes go to the renamed repo.
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 -
az group deleteremoves the resource group and everything in it: the app, its environment and the logs.--yesskips the prompt and--no-waitreturns immediately while Azure deletes in the background. -
az ad sp deleteremoves the service principal. Deleting the resource group does not remove it. -
az group existsprintstruewhile the group is still there andfalseonce 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.
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
shipitvsshipit-appmismatch 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:
- I wrote a tiny Python web server and packaged it in a Docker image that runs as a non-root user.
- I pushed the image to GitHub Container Registry, made it public, and deployed it to Azure Container Apps with the Azure CLI.
- I created a least-privilege service principal and a GitHub Actions workflow, so every push to
mainbuilds, pushes and deploys automatically. - 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.






























