Building a Multi Environment CI/CD Pipeline on Azure Container Apps
From a pull request to production with GitHub Actions, Docker & Azure
Introduction
Releasing an application can look simple: write some code, build it and deploy it. But when an application needs to move through Development, Staging, and Production, it gets complicated. But it doesn't have to be that way.
For this project, I wanted to build a CI/CD pipeline that manages that journey in a consistent way. I built a small Node.js application with a two api endpoints (/health and /api/status) and a simple UI which shows the current deployed environment and the version of the app.
This project uses GitHub Actions, Docker, Azure Container Registry (ACR), Azure Container Apps and Azure Bicep. It includes automated tests at different stages, image scanning, OIDC authentication, managed identity, blue/green production releases and a separate rollback workflow.
- Check out the GitHub repository
- Connect with me on LinkedIn
Thought process
When I started planning the project, I wanted to avoid building a pipeline that simply ran `test`, `build`, and `deploy`. I wanted to showcase how an application should move between environments and what controls are needed before it reaches users.
These questions shaped the design:
- How can I make sure the image tested in Staging is the same image that reaches Production?
- What should happen when a pull request changes application code or infrastructure?
- How can I test a new Production revision without sending users to it first?
- What should rollback check before changing live traffic?
That led to a few key decisions.
I used one long lived main branch with short lived feature & hotfix branches (Trunk Based development).
GitHub Actions validates pull requests before they can be merged. After a merge, the pipeline builds and scans one Docker image, pushes it to ACR, and records its SHA256 digest. That digest is then promoted through Development and Staging before Production approval.
I also wanted infrastructure and release operations to have clear responsibilities. Bicep provisions the Azure platform, while the release workflow manages application revisions and Production traffic. This keeps a normal infrastructure deployment from unexpectedly changing the live release.
The application is intentionally stateless and runs in a single region. I kept databases, AKS, request log aggregation, distributed tracing, and multi region failover out of scope so the project could stay focused on the software delivery process.
Azure Architecture
The architecture is split into a shared layer and three application environments. The registry and image pull identity are shared, while each environment has its own resource group, Container Apps Environment, and Container App.
Components
The 3 Environments
The Container Apps environments are cae-nodeapp-dev, cae-nodeapp-staging, and cae-nodeapp-prod. All resources run in eastus region.
Each environment has a different role in the promotion process, along with different resource settings.
Development is the first Azure deployment. The pipeline checks the application’s health, runs integration tests, and verifies that the expected commit is running. This environment can scale to zero to reduce cost, so the integration test accounts for the longer first response after idle.
Staging receives the same image digest after Development passes. The end-to-end tests run against the deployed application.
Production is the user facing environment. It uses multiple revision mode so a new revision can be tested before it receives traffic. Production keeps at least one replica to reduce the chance of a user facing cold start.
Github Actions Pipeline
GitHub Actions orchestrates the application delivery process. A normal change follows this path.
1. Branching strategy
I used trunk based development with one long lived main branch. Feature & hotfix branches are short lived and merge into main through pull requests.
I decided not to create separate `dev`, `staging`, and `production` branches. Those branches would represent deployment environments in source control, even though the actual deployment state is managed by the pipeline and Azure.
Here, the branch describes how the code was developed; image digests, deployment records, Container Apps revisions, labels, and traffic weights describe what is running.
2. Pull request checks
Whenever a pull request is opened, GitHub Actions runs:
- Unit tests
- Linting
- npm audit
- Bicep template compilation
The `test` and `bicep` jobs do not receive Azure credentials and do not request `id-token: write`. A pull request can validate the application and infrastructure templates, but it cannot change Azure resources.
Both checks passed? Then Merge to main
If either check fails, the required status check blocks the merge and the deployment path stops before Azure is changed.
3. Build and publish the Docker image
After changes are merged into main, the build job creates the Docker image and scans it with Trivy. The scan is configured to fail on fixable High and Critical findings. If the scan passes, the image is pushed to ACR and the resulting digest is recorded.
The image repository is thesudofiles.azurecr.io/nodeapp
A commit-SHA tag is kept for lookup, but deployments use the digest
thesudofiles.azurecr.io/nodeapp@sha256:<digest>
I used the image digest because tags can change, but a digest never does. Using the digest guarantees that the exact image we tested in Staging is the one that goes to Production. This builds consistency right into the deployment process, instead of just hoping both environments rebuild the exact same way.
4. Deploying to Development and Staging
Once the image digest is available, the deployment jobs begin.
The Development job deploys the image to nodeapp-dev, checks /health, runs the integration tests, and verifies the expected application version. The version endpoint is useful for confirming which commit the application reports, while the digest remains the stronger identity for the image itself.
When Development passes, the same digest is deployed to nodeapp-staging. The pipeline waits for the revision to become Running and then runs the end-to-end test suite.
Both environments deploy automatically after the preceding checks pass. I kept the manual approval for Production because that is the point where the release changes user-facing traffic.
5. Production approval and blue/green deployment
Get Yushan Jayaweera’s stories in your inbox
Join Medium for free to get updates from this writer.
After Staging passes, the workflow reaches the protected GitHub Environment named production and waits for reviewer approval.
Since this is a personal project, the approval is a deliberate release checkpoint rather than independent separation of duties. In a team, a service owner or another engineer could take that role.
Production uses Azure Container Apps multiple revision mode with two labels: blue and green. These labels describe roles, not permanent version names. Either label can be live, so the workflow reads the current traffic configuration instead of assuming that blue is always active.
Before changing traffic, the workflow checks that exactly one labeled revision has 100% traffic and that the label matches the live revision. It then creates the new revision using the image digest with 0% traffic, assigns the standby label, waits for the revision to become Running, and tests it through its own URL.
The candidate must pass /health and the end-to-end test suite while users remain on the current revision. Only after those checks pass does the workflow switch traffic:
Current revision 100% → 0%
New revision 0% → 100%
The workflow reads the traffic configuration again to verify the switch, then watches the public application endpoint for five minutes. If that watch fails, it attempts to return traffic to the previous revision and fails the release.
After a successful release, the workflow records the deployment and creates a prod-<sha7> release tag.
Now only the live and previous labeled revisions remain active.
I chose Container Apps traffic weights instead of adding another routing service because this is a small, single-region application. The built-in revision and traffic features cover the release behavior I wanted to demonstrate without adding another component and its associated cost.
Blue/green does not catch every possible application bug. A feature can fail while
/healthstill passes, and this approach does not solve database migrations or multi region recovery. It reduces one specific risk: sending users to a new revision before it has been checked.
Azure infrastructure with Bicep
The Azure resources are defined in Bicep so the platform can be provisioned consistently rather than created manually in the portal.
The templates cover the shared registry and identity, the Container Apps environments, the Container Apps, ingress, health probes, resource sizing, scaling limits, and Production multiple revision mode.
The repository is organized into reusable modules and environment parameter files:
I chose Bicep because the target platform is Azure, and it maps directly to the resources being provisioned. The parameter files let the same modules create environments with different CPU, memory, replica, and revision settings.
Bicep manages the long-lived platform, while the release workflow manages application revisions and Production traffic.
A routine infrastructure deployment should not reset live traffic. The Production app template is therefore applied during bootstrap, while later Production releases use explicit revision and traffic steps.
Pull requests compile the Bicep templates, but they do not run an authenticated `what-if`. That keeps Azure access out of untrusted PR jobs. Trusted `what-if` checks and infrastructure drift detection are future improvements.
I also considered whether I needed AKS. For this application, I wanted HTTPS ingress, probes, revisions, traffic weights, labels and scaling, but I did not need to operate a Kubernetes cluster. Azure Container Apps provided the release capabilities without making cluster operations the main focus of the project.
Identity and access
There are two separate identity paths in the project, each with a different responsibility.
GitHub Actions to Azure with OIDC
GitHub Actions authenticates to Azure using OpenID Connect (OIDC). The workflow requests a short lived token and Microsoft Entra ID accepts it when the token subject matches a configured federated credential. This avoids storing a long-lived Azure client secret in the repository.
PR jobs do not receive Azure access. Only trusted jobs that need to build, push, or deploy request the relevant identity permissions.
Container Apps to ACR with managed identity
The running Container Apps use the user-assigned managed identityid-nodeapp-pull. It has the AcrPull role on the registry, allowing the apps to pull their images at runtime. The ACR admin user is disabled.
-GitHub Actions: OIDC → Microsoft Entra ID & Build, push and deploy permissions
Container Apps: id-nodeapp-pull (AcrPull on ACR)
The deployment identity currently has Contributor on the environment resource groups,
AcrPushon ACR and Managed Identity Operator on the pull identity. Contributor is broader than ideal for a hardened production setup.
Rollback workflow
I wanted rollback to be a deliberate process rather than a command that blindly changes Production traffic. The rollback workflow is manually triggered and has two main parts: a read only plan and an approved apply.
The plan checks that exactly one labeled revision has 100% traffic, identifies the live and standby labels, confirms the standby revision has 0% traffic, checks that no release is running or waiting for approval and health checks the standby revision. If the workflow cannot read the current run state, it stops rather than assuming the system is idle.
Before applying the rollback, the workflow checks the release state and traffic configuration again. If Production has changed since the plan was created, it stops with not-applied . If the state still matches, it changes the traffic weights:
Standby revision → 100%
Live revision → 0%
It then reads the traffic configuration again and reports rolled-back only when the final state is correct. Release and rollback apply jobs use the same nodeapp-release concurrency group to prevent overlapping operations. The state re-check is still necessary because concurrency alone cannot prove that an earlier rollback plan is still valid.
The workflow keeps only the live and previous revisions active, so it supports a one-release rollback. Older versions need to be redeployed from their original commit and image digest. Rollback also does not fix the bug or remove the bad commit from main . The correction still needs to go through the normal pipeline.
Testing and challenges
I tested more than the successful deployment path. I also deliberately caused failures to check whether the pipeline stopped or recovered in the way I expected.
The rollback issue was a useful reminder that recovery workflows need testing of their own. It is not enough for the normal deployment to work; the recovery path also needs to be exercised before relying on it.
Observability and limitations
The project is intentionally small and single-region. It does not include a database, request-log aggregation, distributed tracing, dashboards, error rate alerts, latency objectives, or multi region failover.
The operational signals currently in place are:
- Health probes: Each revision has a /health probe. These help the platform assess replica health, but they do not move traffic between revisions.
- Release watch: The pipeline checks the public endpoint for five minutes after the Production traffic switch. This is a short post-release check, not continuous monitoring.
- Deployment records and version reporting: GitHub records the environment, image, revision, and address. /api/status returns the application commit, and the integration test checks the expected version.
These signals do not catch every application-level or business-level failure. Something that happens after the 5 minute release watch, or a failure that does not affect /health, may only be noticed through the restart alert or a user report.
The current security controls include OIDC authentication, no stored Azure client secret, a disabled ACR admin user, managed identity for image pulls, Trivy scanning, PR jobs without Azure access, required checks, Production approval, and non-root container execution.
Conclusion
This project gave me a chance to bring together GitHub Actions, Docker, Bicep, Azure Container Apps, automated testing, identity, and production release strategies in one working setup.
The main thing I wanted to get right was the journey between environments. The pipeline builds one image, promotes that same digest through Development and Staging, and puts a human approval before Production. The new Production revision is checked before it receives traffic, and the rollback workflow verifies the current state before restoring the previous revision.
There is still room to improve, specially around observability and monitoring. But I can now follow a change from a pull request to Production and see which artifact was deployed, what checks it passed, how traffic was moved, and how I would take it back if needed.
What I learned
- Artifact promotion: Using an image digest makes it possible to promote the same artifact through every environment rather than rebuilding it at each stage.
- Environment strategy: Separate Azure environments provide different places to validate a release, while a single main branch keeps source control straightforward.
- Infrastructure and release state: Bicep and the deployment workflow need clear responsibilities so infrastructure changes do not unexpectedly alter live traffic.
- Identity separation: The pipeline and the running application need different permissions, so OIDC and managed identity serve different purposes.
- Release recovery: Rollback needs a plan, approval, a fresh state check, a traffic change and final verification.
- Testing failure paths: Deliberately failing parts of the workflow helped reveal assumptions that a successful deployment alone would not expose.
Project links
- GitHub repository
- Connect with me on LinkedIn






