DEV Community

Cover image for I Built an Automated Deployment Pipeline for a 3-Tier Application to Eliminate Configuration Drift
manny300
manny300

Posted on

I Built an Automated Deployment Pipeline for a 3-Tier Application to Eliminate Configuration Drift

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:

  1. 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.
  2. When code is pushed, we need absolute certainty about exactly what version is running in production. No pushing the latest tag and hoping for the best.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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/"
Enter fullscreen mode Exit fullscreen mode

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
"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

CheckoutGenerate Git SHABuild/LintIntegration Test & Security ScanDeploy & Health Check.

screenshot showing github actions workflow

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
Enter fullscreen mode Exit fullscreen mode

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)