If you are just getting started with cloud deployments and CI/CD, this post is for you. I will walk you through how my team and I built a Dockerized 3-tier web application (React, Node.js, PostgreSQL), and how I specifically designed and automated the deployment infrastructure for it; covering Azure VM provisioning, manual deployment simulation, and a full GitHub Actions CI/CD pipeline with automated rollbacks.
This is the kind of project that bridges the gap between writing code locally and keeping it running reliably in the cloud. It is not glamorous, but it is one of those solutions that prevent "it works on my machine" from becoming a production outage.
This is a collaborative group Capstone project from my TechCrush Cloud Engineering bootcamp series. If you want to see where this journey started—including the write-up for my own personal Capstone project—you can read my previous posts on my Dev.to profile.
The Problem
My team (TechCrush Group 4) built FormFlow, a Dockerized 3-tier application. The application works flawlessly on our local machines via Docker Compose. The challenge is getting it to a production Azure Linux VM reliably. We needed three things to happen:
-
When infrastructure is provisioned, the VM, network security groups, and Docker runtime should all be installed automatically without anyone SSHing into the machine to run
apt-get. -
When code is pushed, we need absolute certainty about exactly what version is running in production. No pushing the
latesttag and hoping for the best. - When a deployment breaks, the system must detect the failure and roll back to the previous version immediately. Not when a user complains. Automatically.
The hardest part of building CI/CD pipelines is that if you try to automate everything at once, a failed deployment leaves you guessing. Is the pipeline YAML wrong? Is the Dockerfile bad? Is the server misconfigured? To solve and simplify this, I designed an intermediate step: a script that simulates the CI/CD pipeline locally to isolate variables before getting to GitHub Actions.
What You Will Need
Before running any of these scripts, make sure you have:
- Azure CLI installed on your local machine.
- An active Azure account to provision the Virtual Machine.
-
Docker Hub credentials configured as repository secrets (
DOCKERHUB_USERNAME,DOCKERHUB_TOKEN) for the CI/CD pipelines. -
Deployment Secrets configured in GitHub (
DEPLOY_HOST,DEPLOY_USER,DEPLOY_SSH_KEY) once the VM is provisioned. - A terminal that runs Bash.
Understanding the Design
The Principle: Isolate Variables Before Automating
The core design decision behind this entire system is that the deployment logic must be proven manually before it is automated in CI/CD.
When you write a Bash script that successfully copies files, pulls images, and orchestrates containers on a remote server, you prove the infrastructure works. When you then transition that exact script into a GitHub Actions YAML file, any subsequent errors are strictly pipeline issues.
The Architecture
The system has three layers: infrastructure provisioning, the deployment simulation script, and the GitHub Actions pipeline.
The provisioning script (provision-vm.sh) creates the Azure VM and injects a cloud-init config to install Docker. The deployment script (deploy.sh) simulates the pipeline to catch race conditions. Finally, GitHub Actions orchestrates the linting, testing, security scanning, and deployment with health checks.
The Scripts
1. Provisioning: provision-vm.sh
This script builds the underlying Azure infrastructure and prepares the server to host Docker containers.
#!/bin/bash
set -e
RG_NAME="FormFlow-RG"
LOCATION="eastus"
VM_NAME="formflow-prod-vm"
ADMIN_USER="azureuser"
IMAGE="Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest"
SIZE="Standard_D2s_v3"
# 1. Create Resource Group
az group create --name "$RG_NAME" --location "$LOCATION" -o table
# 2. Create the Virtual Machine
az vm create \
--resource-group "$RG_NAME" \
--name "$VM_NAME" \
--image "$IMAGE" \
--size "$SIZE" \
--zone 3 \
--admin-username "$ADMIN_USER" \
--generate-ssh-keys \
--custom-data cloud-init.yaml \
--public-ip-sku Standard \
-o table
What matters here:
The --custom-data cloud-init.yaml flag is the most important part of this script. Instead of provisioning a blank Ubuntu VM and manually SSHing in to install Docker, the cloud-init file runs on the very first boot. By the time the Azure API says the VM is ready, Docker, Docker Compose, and the Buildx plugins are already installing in the background.
2. The Simulation: deploy.sh
This script is what a developer runs from their local machine to test the deployment process before we trust GitHub Actions to do it.
# Step 2: Copy the entire project to the VM (excluding unnecessary files)
echo "[2/4] Copying project files to VM..."
rsync -avz --progress \
--exclude 'node_modules' \
--exclude '.git' \
--exclude 'infra' \
--exclude '*.md' \
-e "ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no" \
"$(dirname "$(dirname "$(realpath "$0")")")/" "$SSH_USER@$PUBLIC_IP:$APP_DIR/"
Why rsync? In a 3-tier application, moving files over SSH can be slow. By explicitly excluding node_modules and .git, we keep the payload incredibly lightweight. This mirrors how a CI/CD runner checks out code—it does not bring local development baggage with it.
Next, the script handles the most critical bug I encountered: the cloud-init race condition.
# Step 4: Wait for Docker to be installed by cloud-init, then run compose
$SSH_CMD "
retries=12
while [ \$retries -gt 0 ]; do
if command -v docker >/dev/null 2>&1; then
echo 'Docker is ready!'
break
fi
echo 'Docker not yet installed (cloud-init still running). Retrying in 10s...'
sleep 10
retries=\$((retries - 1))
done
if ! command -v docker >/dev/null 2>&1; then
echo 'ERROR: Docker was not installed after 2 minutes.'
exit 1
fi
cd $APP_DIR && sudo docker compose up -d --build
"
When you provision an Azure VM, cloud-init runs in the background. If you run deploy.sh immediately after provision-vm.sh, it will fail because Docker is not fully installed yet. Rather than using a static, fragile sleep 120 command, I wrote a polling loop. It checks for the docker binary every 10 seconds. If Docker is ready in 20 seconds, the deployment continues immediately. This defensive code prevents race conditions while keeping deployments fast.
The CI/CD Pipeline
Once deploy.sh proved the architecture was sound, I translated that logic into .github/workflows/pipeline.yml.
Versioning with Git SHAs
tag:
name: Generate Git SHA Tag
runs-on: ubuntu-latest
outputs:
sha_tag: ${{ steps.sha.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Set short SHA
id: sha
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
We never push the latest tag to Docker Hub. That is a recipe for untrackable production states. Instead, the pipeline generates the Git short SHA and tags the frontend, backend, and database images with it. Pulling image a1b2c3d always corresponds to a known, inspectable commit in GitHub.
Security and Integration
Before touching the production VM, the pipeline runs a full suite. It builds the containers, stands them up via Docker Compose on the GitHub runner, and runs an integration test.
- name: Smoke test – POST /todos via Nginx
run: |
curl -sf -X POST http://localhost/todos \
-H "Content-Type: application/json" \
-d '{"description":"CI smoke test todo"}'
In parallel, it runs Trivy security scans on all three images. If a high-severity vulnerability is found, or if the API fails to respond to the curl request, the pipeline fails. Bad code never reaches production.
Deployment and Automatic Rollback
If tests pass, the deploy job runs. It SSHes into the Azure VM, pulls the SHA-tagged images, and orchestrates them. But what if the new images crash on startup?
# Keep a backup of the old .env file just in case for rollback
if [ -f .env ]; then
cp .env .env.backup
OLD_TAG=$(grep IMAGE_TAG .env | cut -d '=' -f2)
fi
# ... Docker pull and compose up logic ...
# Health Check and Automatic Rollback
echo "Waiting for services to start..."
sleep 10
if ! curl -sf http://localhost/todos; then
echo "Health check failed! Initiating automatic rollback..."
if [ -n "$OLD_TAG" ]; then
echo "Rolling back to IMAGE_TAG=$OLD_TAG"
mv .env.backup .env
docker compose up -d --remove-orphans
fi
exit 1
fi
This is the safety net. Before updating the containers, the pipeline backs up the current .env file (which holds the currently running Git SHA tag). After deploying the new containers, it fires an HTTP request at the live endpoint. If the app does not respond, the script restores the .env.backup file and immediately redeploys the old containers. The pipeline then exits with code 1, alerting the team of the failure, but production remains online.
What the Result Looks Like
When another developer clones this repository, they do not need to understand the underlying infrastructure to deploy. They simply run:
$ ./infra/provision-vm.sh
...
✅ Provisioning Complete!
VM Name: formflow-prod-vm
Public IP: 20.55.38.90
They take that IP, drop it into their GitHub Secrets along with their Docker Hub credentials, and push their code. The GitHub Actions dashboard shows a clean, parallel execution:
Checkout → Generate Git SHA → Build/Lint → Integration Test & Security Scan → Deploy & Health Check.
If they introduce a breaking change, the logs show the exact moment the system protected itself:
Waiting for services to start...
Health check failed! Initiating automatic rollback...
Rolling back to IMAGE_TAG=888e10d
Rollback complete.
Process exited with status 1
What I Would Improve in a v2
1. Declarative Infrastructure as Code
Bash scripts are great for provisioning, but they do not manage state. In a v2, I would replace provision-vm.sh entirely with Terraform or Azure Bicep to handle configuration drift automatically.
2. Azure Key Vault Integration
Currently, secrets like PG_PASSWORD are injected via GitHub Actions into a .env file on the server. A more secure approach would be having the Node.js backend authenticate directly with Azure Key Vault at runtime to fetch the database credentials.
Key Takeaways
-
Never rely on static sleep commands. Use polling loops to gracefully handle background tasks like
cloud-init. -
Tag images with Git SHAs, never
latest. Version traceability makes rollbacks trivial.
What Is Next
Follow me for more content about Cloud Engineering and DevOps. Follow along on my Dev.to profile and my github profile if you want to see how it goes.
You can find the full scripts, Dockerfiles, pipeline configs, and architecture documentation here: github.com/techcrush-group4-capstone/3-tier-dockerized-application

Top comments (0)