- Project Overview
- Prerequisites
- Phase 1: GitHub Repository Setup
- Phase 2: Project Structure & Source Code
- Phase 3: GitHub Container Registry (GHCR) Setup
- Phase 4: AWS Infrastructure Setup
- Phase 5: GitHub Actions Configuration
- Phase 6: Testing the Complete Pipeline
- Phase 7: Understanding the Workflow
- Troubleshooting
This guide will help you build a complete CI/CD pipeline for a Node.js/TypeScript application with:
- Automated Build: Code push triggers automatic Docker image creation
- Automated Testing: Acceptance tests run every 30 minutes on new images
- QA Environment: Manual deployment to QA with sign-off process
- Production Deployment: Controlled release to production
- Version Management: Semantic versioning with prerelease and production tags
Pipeline Flow:
Developer Push → Build & Publish → Acceptance Tests → QA Deploy → QA Sign-off → Production Deploy
- ✅ GitHub Account (free tier works)
- ✅ AWS Account with billing enabled
- ✅ Basic knowledge of Git commands
- ✅ SSH key for GitHub (we'll set this up)
- ✅ Local machine with:
- Git installed
- Node.js 20+ installed
- Text editor (VS Code recommended)
- Terminal/Command Line access
- GitHub: Free (GHCR includes 500MB free storage)
- AWS: ~$5-10/month for t3.micro instances
- Total: ~$10/month maximum
- Go to https://github.com
- Click the "+" icon → "New repository"
- Fill in details:
- Repository name:
cicd-demo-project - Description:
Complete CI/CD pipeline demo with GitHub Actions - Visibility: Public (for free GHCR) or Private (if you have GitHub Pro)
- ✅ Initialize with README
- Add .gitignore: Node
- License: MIT
- Repository name:
- Click "Create repository"
# Open terminal and navigate to your workspace
cd ~/workspace
# Clone the repository (replace YOUR_USERNAME)
git clone https://github.com/YOUR_USERNAME/cicd-demo-project.git
# Navigate into the project
cd cicd-demo-project# Set your identity
git config user.name "Your Name"
git config user.email "your.email@example.com"
# Verify settings
git config --list# Create all directories at once
mkdir -p monolith/src
mkdir -p monolith/test
mkdir -p system-test/test/e2e-tests
mkdir -p .github/workflows
mkdir -p .github/actions/deploy-docker-imagesYour structure should look like:
cicd-demo-project/
├── .github/
│ ├── workflows/ # GitHub Actions workflows
│ └── actions/
│ └── deploy-docker-images/ # Reusable deployment action
├── monolith/ # Main application
│ ├── src/
│ ├── test/
│ ├── package.json
│ ├── tsconfig.json
│ └── Dockerfile
└── system-test/ # End-to-end tests
├── test/
│ └── e2e-tests/
└── package.json
{
"name": "monolith",
"version": "1.0.0",
"description": "Demo monolith application",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2",
"jest": "^29.7.0",
"@types/jest": "^29.5.11"
}
}{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}import express, { Request, Response } from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req: Request, res: Response) => {
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || '1.0.0'
});
});
// Root endpoint
app.get('/', (req: Request, res: Response) => {
res.status(200).json({
message: 'Welcome to the Monolith API',
endpoints: {
health: '/health',
users: '/api/users'
}
});
});
// Example API endpoint
app.get('/api/users', (req: Request, res: Response) => {
const users = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
];
res.status(200).json(users);
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`📊 Health check: http://localhost:${PORT}/health`);
});
export default app;# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build TypeScript
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy built application from builder
COPY --from=builder /app/dist ./dist
# Expose port
EXPOSE 3000
# Set environment
ENV NODE_ENV=production
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start application
CMD ["node", "dist/index.js"]node_modules
dist
npm-debug.log
.git
.gitignore
README.md
test
*.test.ts
.env
.DS_Store
{
"name": "system-test",
"version": "1.0.0",
"description": "End-to-end system tests",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.40.1",
"@types/node": "^20.10.0"
}
}import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
});import { test, expect } from '@playwright/test';
test.describe('API Tests', () => {
test('should return health status', async ({ request }) => {
const response = await request.get('/health');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('healthy');
expect(body.timestamp).toBeTruthy();
});
test('should return welcome message on root', async ({ request }) => {
const response = await request.get('/');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.message).toBe('Welcome to the Monolith API');
});
test('should return users list', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.ok()).toBeTruthy();
const users = await response.json();
expect(Array.isArray(users)).toBeTruthy();
expect(users.length).toBeGreaterThan(0);
});
});# Install monolith dependencies
cd monolith
npm install
cd ..
# Install system-test dependencies
cd system-test
npm install
cd ..# Build the application
cd monolith
npm run build
# Start the server (in terminal 1)
npm start
# In another terminal, test the endpoints
curl http://localhost:3000/health
curl http://localhost:3000/api/users
# Stop the server (Ctrl+C)- Go to GitHub → Click your profile picture → Settings
- Scroll down to Developer settings (bottom left)
- Click Personal access tokens → Tokens (classic)
- Click Generate new token → Generate new token (classic)
- Fill in details:
- Note:
GHCR Access Token - Expiration: 90 days (or custom)
- Scopes: Check these boxes:
- ✅
write:packages(includes read:packages) - ✅
delete:packages - ✅
repo(full control)
- ✅
- Note:
- Click Generate token
- IMPORTANT: Copy the token immediately (starts with
ghp_)- Store it securely (you won't see it again)
- Go to your repository:
https://github.com/YOUR_USERNAME/cicd-demo-project - Click Settings tab
- In left sidebar, click Secrets and variables → Actions
- Click New repository secret
- Add secret:
- Name:
GITHUB_TOKEN(already exists by default, we'll use it) - Note: GitHub automatically provides
GITHUB_TOKENfor workflows
- Name:
Good news: GitHub Actions automatically provides a GITHUB_TOKEN with package permissions, so you don't need to manually add it!
- In repository Settings → Actions → General
- Scroll to Workflow permissions
- Select: ✅ Read and write permissions
- Check: ✅ Allow GitHub Actions to create and approve pull requests
- Click Save
- Go to https://aws.amazon.com
- Sign in to your account
- Select your region (e.g.,
us-east-1)
- Go to EC2 Dashboard → Key Pairs (under Network & Security)
- Click Create key pair
- Settings:
- Name:
cicd-demo-key - Key pair type: RSA
- Private key file format:
.pem
- Name:
- Click Create key pair
- Save the
.pemfile securely (you'll need it to SSH)
# Set correct permissions (Linux/Mac)
chmod 400 ~/Downloads/cicd-demo-key.pem-
Go to EC2 → Security Groups
-
Click Create security group
-
Fill in:
- Name:
cicd-app-sg - Description:
Security group for application servers - VPC: Default VPC
- Name:
-
Inbound rules - Click Add rule for each:
Type Protocol Port Range Source Description SSH TCP 22 My IP SSH access Custom TCP TCP 3000 0.0.0.0/0 Application port HTTP TCP 80 0.0.0.0/0 HTTP HTTPS TCP 443 0.0.0.0/0 HTTPS -
Outbound rules: Leave default (All traffic)
-
Click Create security group
We'll create 3 instances: Acceptance, QA, and Production.
- Go to EC2 → Instances → Launch instances
- Settings:
- Name:
acceptance-server - AMI: Ubuntu Server 24.04 LTS (free tier eligible)
- Instance type: t3.micro (or t2.micro for free tier)
- Key pair: Select
cicd-demo-key - Network settings:
- VPC: Default
- Auto-assign public IP: Enable
- Firewall: Select existing
cicd-app-sg
- Storage: 8 GB gp3 (default)
- Advanced details → User data (paste this script):
- Name:
#!/bin/bash
# Update system
apt-get update
apt-get upgrade -y
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
usermod -aG docker ubuntu
# Install Docker Compose
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
# Create application directory
mkdir -p /opt/app
chown ubuntu:ubuntu /opt/app
# Enable Docker service
systemctl enable docker
systemctl start docker
echo "Setup complete!" > /var/log/user-data.log- Click Launch instance
- Wait for instance to be Running
- Note the Public IPv4 address
Repeat the same steps but with:
- Name:
qa-server - Same user data script
- Note its Public IP
Repeat the same steps but with:
- Name:
production-server - Same user data script
- Note its Public IP
Wait 3-5 minutes for user data scripts to complete, then SSH into each:
# SSH into acceptance server
ssh -i ~/Downloads/cicd-demo-key.pem ubuntu@<ACCEPTANCE_PUBLIC_IP>
# Verify Docker is installed
docker --version
docker-compose --version
# Exit
exit
# Repeat for QA and Production servers- Go to repository Settings → Secrets and variables → Actions
- Add these secrets (click New repository secret for each):
| Name | Value | Description |
|---|---|---|
AWS_ACCEPTANCE_HOST |
Acceptance server public IP | E.g., 54.123.45.67 |
AWS_ACCEPTANCE_USER |
ubuntu |
SSH username |
AWS_ACCEPTANCE_KEY |
Contents of .pem file |
Full private key |
AWS_QA_HOST |
QA server public IP | E.g., 54.123.45.68 |
AWS_QA_USER |
ubuntu |
SSH username |
AWS_QA_KEY |
Contents of .pem file |
Full private key |
AWS_PRODUCTION_HOST |
Production server public IP | E.g., 54.123.45.69 |
AWS_PRODUCTION_USER |
ubuntu |
SSH username |
AWS_PRODUCTION_KEY |
Contents of .pem file |
Full private key |
To copy .pem file contents:
cat ~/Downloads/cicd-demo-key.pem
# Copy the entire output including BEGIN and END linesname: 'Deploy Docker Images'
description: 'Deploy Docker images to specified environment'
inputs:
environment:
description: 'Target environment (acceptance, qa, production)'
required: true
version:
description: 'Version tag to deploy'
required: true
image-urls:
description: 'JSON array of image URLs with digests or tags'
required: true
ssh-host:
description: 'SSH host to deploy to'
required: true
ssh-user:
description: 'SSH user'
required: true
ssh-key:
description: 'SSH private key'
required: true
github-token:
description: 'GitHub token for GHCR authentication'
required: true
runs:
using: "composite"
steps:
- name: Debug Input
shell: bash
run: |
echo "=== DEBUG INFO ==="
echo "Raw image-urls input: ${{ inputs.image-urls }}"
echo "Environment: ${{ inputs.environment }}"
echo "Version: ${{ inputs.version }}"
echo "=================="
- name: Parse Image URL
id: parse
shell: bash
run: |
set -e
RAW_INPUT='${{ inputs.image-urls }}'
echo "Raw input: $RAW_INPUT"
# Remove brackets and quotes if it's a JSON array
IMAGE_URL=$(echo "$RAW_INPUT" | sed 's/^\["//' | sed 's/"\]$//' | sed 's/^"//' | sed 's/"$//')
echo "Parsed IMAGE_URL: $IMAGE_URL"
if [ -z "$IMAGE_URL" ] || [ "$IMAGE_URL" = "null" ]; then
echo "❌ Failed to parse image URL"
exit 1
fi
echo "image-url=$IMAGE_URL" >> $GITHUB_OUTPUT
echo "✅ Successfully parsed image URL"
- name: Create Deployment Script
shell: bash
run: |
cat > /tmp/deploy.sh << 'SCRIPT_END'
#!/bin/bash
set -e
echo "=== Deployment Starting ==="
echo "Environment: $ENVIRONMENT"
echo "Image URL: $IMAGE_URL"
echo "Version: $VERSION"
echo "=========================="
# Validate inputs
if [ -z "$IMAGE_URL" ]; then
echo "❌ IMAGE_URL is empty"
exit 1
fi
# Login to GHCR
echo "🔐 Logging into GHCR..."
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin 2>&1 | grep -v "WARNING" || true
# Pull new image
echo "⬇️ Pulling image: $IMAGE_URL"
docker pull "$IMAGE_URL"
# Stop existing container
echo "🛑 Stopping existing container..."
docker stop monolith-app 2>/dev/null || true
docker rm monolith-app 2>/dev/null || true
# Run new container
echo "▶️ Starting new container..."
docker run -d \
--name monolith-app \
--restart unless-stopped \
-p 3000:3000 \
-e NODE_ENV=production \
-e APP_VERSION="$VERSION" \
"$IMAGE_URL"
# Wait for application to start
echo "⏳ Waiting for application to start..."
sleep 10
# Verify deployment with health check
echo "🏥 Running health check..."
MAX_ATTEMPTS=30
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
if curl -f http://localhost:3000/health 2>/dev/null; then
echo "✅ Health check passed!"
echo "✅ Deployment successful!"
echo ""
echo "📊 Container status:"
docker ps --filter name=monolith-app
echo ""
echo "🧹 Cleaning up old images..."
docker image prune -af --filter "until=24h" 2>/dev/null || true
echo ""
echo "🎉 Deployment complete!"
exit 0
fi
ATTEMPT=$((ATTEMPT + 1))
echo "⏳ Waiting for health check... attempt $ATTEMPT/$MAX_ATTEMPTS"
sleep 2
done
echo "❌ Health check failed after $MAX_ATTEMPTS attempts"
echo "📋 Container logs:"
docker logs monolith-app || true
exit 1
SCRIPT_END
chmod +x /tmp/deploy.sh
- name: Deploy to Server
shell: bash
run: |
set -e
SSH_HOST="${{ inputs.ssh-host }}"
SSH_USER="${{ inputs.ssh-user }}"
IMAGE_URL="${{ steps.parse.outputs.image-url }}"
VERSION="${{ inputs.version }}"
ENVIRONMENT="${{ inputs.environment }}"
echo "🚀 Deploying to $ENVIRONMENT environment"
echo "📦 Host: $SSH_HOST"
echo "👤 User: $SSH_USER"
echo "🐳 Image: $IMAGE_URL"
echo "🏷️ Version: $VERSION"
# Validate IMAGE_URL again
if [ -z "$IMAGE_URL" ] || [ "$IMAGE_URL" = "null" ]; then
echo "❌ Error: IMAGE_URL is empty or null after parsing"
echo "Original input was: ${{ inputs.image-urls }}"
exit 1
fi
# Create SSH key file
mkdir -p ~/.ssh
echo "${{ inputs.ssh-key }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
# Add host to known_hosts
ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
# Copy deployment script to server
echo "📤 Copying deployment script to server..."
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no /tmp/deploy.sh ${SSH_USER}@${SSH_HOST}:/tmp/deploy.sh
# Execute deployment on server
echo "🚀 Executing deployment on server..."
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no ${SSH_USER}@${SSH_HOST} << EOF
export ENVIRONMENT="$ENVIRONMENT"
export IMAGE_URL="$IMAGE_URL"
export VERSION="$VERSION"
export GITHUB_TOKEN="${{ inputs.github-token }}"
export GITHUB_ACTOR="${{ github.actor }}"
bash /tmp/deploy.sh
EOF
# Cleanup
rm -f ~/.ssh/deploy_key /tmp/deploy.sh
echo "✅ Deployment to $ENVIRONMENT completed successfully!"https://github.com/kohlidevops/cicd-demo-project/blob/main/.github/workflows/acceptance-stage.yml
https://github.com/kohlidevops/cicd-demo-project/blob/main/.github/workflows/qa-stage.yml
https://github.com/kohlidevops/cicd-demo-project/blob/main/.github/workflows/qa-signoff.yml
https://github.com/kohlidevops/cicd-demo-project/blob/main/.github/workflows/prod-stage.yml
Since we're using custom actions that don't exist yet, we need to create mock versions:
# Optivem Actions Reference
The workflows reference several custom actions from `optivem/*`.
For this demo, we'll create simplified versions.
These actions would normally:
- `find-latest-docker-images-action`: Find latest Docker image digests
- `should-run-acceptance-stage-action`: Determine if tests should run
- `publish-docker-image-action`: Build and publish Docker images
- `generate-prerelease-version-action`: Create version numbers
- `tag-docker-images-action`: Tag images with versions
- `create-release-action`: Create GitHub releases
- `summarize-commit-stage-action`: Create workflow summaries
- And more...
For now, we'll use Docker CLI commands directly in workflows.# Navigate to project root
cd ~/workspace/cicd-demo-project
# Check what files we created
git status
# Add all files
git add .
# Commit
git commit -m "Initial project setup with complete CI/CD pipeline"
# Push to GitHub
git push origin main- Go to your repository on GitHub
- Click Actions tab
- You should see
commit-stage-monolithworkflow running - Click on the workflow run to see details
- Watch each job execute:
build: Builds and publishes Docker imagesummary: Creates summary
- After workflow completes, go to repository main page
- Click Packages (right sidebar)
- You should see
monolithpackage - Click on it to see the published image with
latesttag
Since acceptance stage runs every 30 minutes, let's trigger it manually:
- Go to Actions → acceptance-stage
- Click Run workflow dropdown
- Select branch:
main - Check Force run
- Click Run workflow
- Watch the workflow execute through all stages
- After acceptance stage creates a prerelease (e.g.,
v1.0.0-rc.1), note the version - Go to Actions → qa-stage
- Click Run workflow
- Enter the prerelease version (e.g.,
v1.0.0-rc.1) - Click Run workflow
- Wait for deployment to complete
- Test the QA server:
# Replace with your QA server IP
curl http://<QA_SERVER_IP>:3000/health
curl http://<QA_SERVER_IP>:3000/api/usersAfter QA testing:
- Go to Actions → qa-signoff
- Click Run workflow
- Enter:
- Version:
v1.0.0-rc.1 - Result:
successorfailure
- Version:
- Click Run workflow
- Go to Actions → prod-stage
- Click Run workflow
- Enter the prerelease version:
v1.0.0-rc.1 - Click Run workflow
- Wait for deployment
- Test production server:
# Replace with your production server IP
curl http://<PRODUCTION_SERVER_IP>:3000/healthgraph TD
A[Developer pushes code] --> B[commit-stage-monolith]
B --> C{Build successful?}
C -->|Yes| D[Publish Docker image with 'latest' tag]
C -->|No| E[Build fails, fix code]
D --> F[acceptance-stage runs every 30min]
F --> G{New image found?}
G -->|Yes| H[Deploy to acceptance env]
G -->|No| I[Skip this run]
H --> J[Run E2E tests]
J --> K{Tests pass?}
K -->|Yes| L[Create prerelease v1.0.0-rc.1]
K -->|No| M[Tests fail, fix code]
L --> N[Manual: qa-stage workflow]
N --> O[Deploy to QA]
O --> P[QA team tests manually]
P --> Q[Manual: qa-signoff workflow]
Q --> R{QA approved?}
R -->|Yes| S[Manual: prod-stage workflow]
R -->|No| T[Fix issues, repeat]
S --> U[Tag as v1.0.0]
U --> V[Deploy to production]
V --> W[Create production release]
- Latest:
latest(always points to most recent build) - Digest:
@sha256:abc123...(immutable reference) - Prerelease:
v1.0.0-rc.1,v1.0.0-rc.2(candidate releases) - QA Status:
v1.0.0-rc.1-qa-success(after QA sign-off) - Production:
v1.0.0,v1.0.1(stable releases)
Jobs use needs: to establish order:
job-a:
runs-on: ubuntu-latest
steps:
- name: Do something
job-b:
needs: job-a # Waits for job-a
runs-on: ubuntu-latest
job-c:
needs: [job-a, job-b] # Waits for both
runs-on: ubuntu-latestJobs use if: to run conditionally:
job-d:
needs: job-c
if: needs.job-c.result == 'success' # Only on success
runs-on: ubuntu-latestSolution:
- Check that files are in correct directories
- Verify workflow file has correct
on:triggers - Check Actions tab is enabled in repository settings
Solution:
- Verify
package.jsonhas all dependencies - Check
tsconfig.jsonis valid - Ensure
Dockerfilereferences correct paths - Check build logs for specific errors
Solution:
- Verify workflow permissions: Settings → Actions → General → Workflow permissions → "Read and write"
- Check
GITHUB_TOKENhas package permissions - Ensure repository visibility matches GHCR requirements
Solution:
- Verify AWS security group allows SSH (port 22)
- Check SSH key is correctly formatted in secrets (including BEGIN/END lines)
- Verify server IP address is correct
- Ensure EC2 instance is running
Solution:
# SSH into server
ssh -i ~/Downloads/cicd-demo-key.pem ubuntu@<SERVER_IP>
# Login to GHCR manually
echo "YOUR_GITHUB_PAT" | docker login ghcr.io -u YOUR_USERNAME --password-stdin
# Try pulling image
docker pull ghcr.io/YOUR_USERNAME/cicd-demo-project/monolith:latestSolution:
- Check security group allows traffic on port 3000
- Verify container is running:
docker ps - Check container logs:
docker logs monolith-app - Test locally on server:
curl http://localhost:3000/health
Solution:
# Check if app is listening
docker exec monolith-app netstat -tlnp
# Check application logs
docker logs monolith-app
# Restart container
docker restart monolith-app- GitHub Actions: https://docs.github.com/en/actions
- Docker: https://docs.docker.com/
- Node.js: https://nodejs.org/docs/
- TypeScript: https://www.typescriptlang.org/docs/
- Express: https://expressjs.com/
- Playwright: https://playwright.dev/
- AWS EC2: https://docs.aws.amazon.com/ec2/
You now have a complete CI/CD pipeline! Your code automatically:
- ✅ Builds on every push
- ✅ Tests automatically
- ✅ Deploys to QA when ready
- ✅ Requires QA approval
- ✅ Deploys to production safely
Pipeline Benefits:
- Faster releases
- Fewer bugs in production
- Consistent deployments
- Audit trail of all changes
- Easy rollbacks
Happy deploying! 🚀