Outsourced IT Partner
Support ยท systems ยท strategy
ยฉ 2026
One person, one business โ IT support here, websites at webology.online.
One person, one business โ IT support here, websites at webology.online.
Implementing CI/CD with Python
Complete end-to-end guide with testing, Docker containers, and automated deployment
๐ Table of Contents
- Introduction & Prerequisites
- Project Setup & Git Repository
- Python Application Development
- Unit Testing with pytest
- Integration Testing
- User Acceptance Testing (UAT)
- CI/CD Pipeline Setup
- Docker Containerization
- Automated Deployment
- Sanity Checking & Health Monitoring
- Rollback Strategies
- Complete Implementation Example
- Resources & Best Practices
Introduction & Prerequisites
This comprehensive guide will walk you through implementing a complete CI/CD pipeline for Python applications, covering everything from unit testing to automated deployment with Docker containers and rollback strategies.
What You'll Learn
- Setting up a Python project with proper structure
- Implementing comprehensive testing strategies (unit, integration, UAT)
- Creating CI/CD pipelines with GitHub Actions
- Containerizing Python applications with Docker
- Automated deployment with release candidates
- Health monitoring and sanity checking
- Rollback strategies for failed deployments
- Best practices for production-ready Python applications
Prerequisites
- Python 3.8+ installed
- Git and GitHub account
- Docker installed
- Basic understanding of Python and web development
- Familiarity with command line operations
- Cloud platform account (AWS, Azure, or GCP)
Pro Tip: This guide uses a Flask web application as an example, but the principles apply to any Python application including Django, FastAPI, or CLI tools.
CI/CD Pipeline Overview
# Our CI/CD Pipeline Flow:
# 1. Code Push โ Git Repository
# 2. Trigger โ CI Pipeline
# 3. Test โ Unit Tests, Integration Tests, UAT
# 4. Build โ Docker Container
# 5. Deploy โ Staging Environment
# 6. Validate โ Sanity Checks
# 7. Deploy โ Production (Blue-Green)
# 8. Monitor โ Health Checks
# 9. Rollback โ If Issues Detected
Project Setup & Git Repository
Project Structure
# Create project directory
mkdir python-cicd-demo
cd python-cicd-demo
# Initialize Git repository
git init
git branch -M main
# Create project structure
mkdir -p {app,tests/{unit,integration,uat},docker,scripts,docs}
touch {app/__init__.py,tests/__init__.py}
touch {requirements.txt,requirements-dev.txt,Dockerfile,.gitignore}
touch {pytest.ini,setup.cfg,.env.example}
Final Project Structure
python-cicd-demo/
โโโ app/
โ โโโ __init__.py
โ โโโ main.py
โ โโโ models/
โ โโโ routes/
โ โโโ services/
โ โโโ utils/
โโโ tests/
โ โโโ __init__.py
โ โโโ unit/
โ โโโ integration/
โ โโโ uat/
โโโ docker/
โ โโโ Dockerfile
โ โโโ docker-compose.yml
โ โโโ nginx.conf
โโโ scripts/
โ โโโ deploy.sh
โ โโโ rollback.sh
โ โโโ health_check.py
โโโ .github/
โ โโโ workflows/
โ โโโ ci.yml
โ โโโ cd.yml
โโโ requirements.txt
โโโ requirements-dev.txt
โโโ pytest.ini
โโโ setup.cfg
โโโ .gitignore
โโโ README.md
Essential Configuration Files
# requirements.txt
flask==2.3.3
gunicorn==21.2.0
psycopg2-binary==2.9.7
redis==4.6.0
requests==2.31.0
python-dotenv==1.0.0
prometheus-client==0.17.1
# requirements-dev.txt
pytest==7.4.2
pytest-cov==4.1.0
pytest-mock==3.11.1
black==23.7.0
flake8==6.0.0
mypy==1.5.1
bandit==1.7.5
safety==2.3.4
pre-commit==3.4.0
# .gitignore
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment variables
.env
.env.local
.env.production
# Testing
.coverage
htmlcov/
.pytest_cache/
.tox/
# Docker
.dockerignore
# Logs
*.log
logs/
Python Application Development
Flask Application Structure
# app/main.py
from flask import Flask, jsonify, request
from prometheus_client import Counter, Histogram, generate_latest
import os
import time
import logging
# Metrics
REQUEST_COUNT = Counter('app_requests_total', 'Total requests', ['method', 'endpoint'])
REQUEST_LATENCY = Histogram('app_request_duration_seconds', 'Request latency')
def create_app():
app = Flask(__name__)
# Configuration
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key')
app.config['DATABASE_URL'] = os.getenv('DATABASE_URL', 'sqlite:///app.db')
# Logging
logging.basicConfig(level=logging.INFO)
@app.before_request
def before_request():
request.start_time = time.time()
@app.after_request
def after_request(response):
REQUEST_COUNT.labels(method=request.method, endpoint=request.endpoint).inc()
REQUEST_LATENCY.observe(time.time() - request.start_time)
return response
# Routes
@app.route('/')
def home():
return jsonify({
'message': 'Python CI/CD Demo API',
'version': os.getenv('APP_VERSION', '1.0.0'),
'status': 'healthy'
})
@app.route('/health')
def health_check():
return jsonify({
'status': 'healthy',
'timestamp': time.time(),
'version': os.getenv('APP_VERSION', '1.0.0')
})
@app.route('/metrics')
def metrics():
return generate_latest()
@app.route('/api/users', methods=['GET', 'POST'])
def users():
if request.method == 'GET':
return jsonify({'users': []})
elif request.method == 'POST':
user_data = request.get_json()
return jsonify({'message': 'User created', 'user': user_data}), 201
return app
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=int(os.getenv('PORT', 5000)), debug=False)
Application Services
# app/services/user_service.py
import logging
from typing import List, Dict, Optional
logger = logging.getLogger(__name__)
class UserService:
def __init__(self):
self.users = [] # In production, this would be a database
def create_user(self, user_data: Dict) -> Dict:
"""Create a new user"""
try:
user = {
'id': len(self.users) + 1,
'name': user_data.get('name'),
'email': user_data.get('email'),
'created_at': time.time()
}
self.users.append(user)
logger.info(f"User created: {user['id']}")
return user
except Exception as e:
logger.error(f"Error creating user: {str(e)}")
raise
def get_users(self) -> List[Dict]:
"""Get all users"""
return self.users
def get_user_by_id(self, user_id: int) -> Optional[Dict]:
"""Get user by ID"""
return next((user for user in self.users if user['id'] == user_id), None)
def validate_user_data(self, user_data: Dict) -> bool:
"""Validate user data"""
required_fields = ['name', 'email']
return all(field in user_data and user_data[field] for field in required_fields)
Unit Testing with pytest
Test Configuration
# pytest.ini
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--verbose
--cov=app
--cov-report=html
--cov-report=xml
--cov-report=term-missing
--cov-fail-under=80
--junitxml=test-results.xml
markers =
unit: Unit tests
integration: Integration tests
uat: User acceptance tests
slow: Slow running tests
Unit Tests
# tests/unit/test_user_service.py
import pytest
from app.services.user_service import UserService
class TestUserService:
@pytest.fixture
def user_service(self):
return UserService()
@pytest.fixture
def valid_user_data(self):
return {
'name': 'John Doe',
'email': 'john@example.com'
}
def test_create_user_success(self, user_service, valid_user_data):
"""Test successful user creation"""
user = user_service.create_user(valid_user_data)
assert user['id'] == 1
assert user['name'] == 'John Doe'
assert user['email'] == 'john@example.com'
assert 'created_at' in user
def test_create_user_increments_id(self, user_service, valid_user_data):
"""Test that user IDs increment correctly"""
user1 = user_service.create_user(valid_user_data)
user2 = user_service.create_user(valid_user_data)
assert user1['id'] == 1
assert user2['id'] == 2
def test_get_users_empty_initially(self, user_service):
"""Test that users list is empty initially"""
users = user_service.get_users()
assert users == []
def test_get_users_returns_created_users(self, user_service, valid_user_data):
"""Test that get_users returns created users"""
user_service.create_user(valid_user_data)
users = user_service.get_users()
assert len(users) == 1
assert users[0]['name'] == 'John Doe'
def test_get_user_by_id_existing(self, user_service, valid_user_data):
"""Test getting existing user by ID"""
created_user = user_service.create_user(valid_user_data)
found_user = user_service.get_user_by_id(created_user['id'])
assert found_user == created_user
def test_get_user_by_id_nonexistent(self, user_service):
"""Test getting non-existent user by ID"""
user = user_service.get_user_by_id(999)
assert user is None
def test_validate_user_data_valid(self, user_service, valid_user_data):
"""Test validation with valid user data"""
assert user_service.validate_user_data(valid_user_data) is True
@pytest.mark.parametrize("invalid_data", [
{},
{'name': 'John'},
{'email': 'john@example.com'},
{'name': '', 'email': 'john@example.com'},
{'name': 'John', 'email': ''}
])
def test_validate_user_data_invalid(self, user_service, invalid_data):
"""Test validation with invalid user data"""
assert user_service.validate_user_data(invalid_data) is False
Flask Application Tests
# tests/unit/test_main.py
import pytest
import json
from app.main import create_app
class TestFlaskApp:
@pytest.fixture
def app(self):
app = create_app()
app.config['TESTING'] = True
return app
@pytest.fixture
def client(self, app):
return app.test_client()
def test_home_endpoint(self, client):
"""Test home endpoint returns correct response"""
response = client.get('/')
assert response.status_code == 200
data = json.loads(response.data)
assert data['message'] == 'Python CI/CD Demo API'
assert data['status'] == 'healthy'
assert 'version' in data
def test_health_check_endpoint(self, client):
"""Test health check endpoint"""
response = client.get('/health')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'healthy'
assert 'timestamp' in data
assert 'version' in data
def test_metrics_endpoint(self, client):
"""Test metrics endpoint returns prometheus metrics"""
response = client.get('/metrics')
assert response.status_code == 200
assert b'app_requests_total' in response.data
def test_users_get_endpoint(self, client):
"""Test GET /api/users endpoint"""
response = client.get('/api/users')
assert response.status_code == 200
data = json.loads(response.data)
assert 'users' in data
assert isinstance(data['users'], list)
def test_users_post_endpoint(self, client):
"""Test POST /api/users endpoint"""
user_data = {'name': 'Test User', 'email': 'test@example.com'}
response = client.post('/api/users',
data=json.dumps(user_data),
content_type='application/json')
assert response.status_code == 201
data = json.loads(response.data)
assert data['message'] == 'User created'
assert data['user'] == user_data
Integration Testing
Database Integration Tests
# tests/integration/test_database_integration.py
import pytest
import os
import tempfile
from app.main import create_app
class TestDatabaseIntegration:
@pytest.fixture
def app(self):
# Create temporary database for testing
db_fd, db_path = tempfile.mkstemp()
app = create_app()
app.config['TESTING'] = True
app.config['DATABASE_URL'] = f'sqlite:///{db_path}'
yield app
# Cleanup
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture
def client(self, app):
return app.test_client()
@pytest.mark.integration
def test_user_creation_flow(self, client):
"""Test complete user creation flow with database"""
# Create user
user_data = {'name': 'Integration Test User', 'email': 'integration@test.com'}
response = client.post('/api/users', json=user_data)
assert response.status_code == 201
# Verify user exists
response = client.get('/api/users')
assert response.status_code == 200
# Additional database verification would go here
API Integration Tests
# tests/integration/test_api_integration.py
import pytest
import requests
import time
from concurrent.futures import ThreadPoolExecutor
class TestAPIIntegration:
@pytest.fixture
def base_url(self):
return os.getenv('TEST_API_URL', 'http://localhost:5000')
@pytest.mark.integration
def test_api_health_check(self, base_url):
"""Test API health check endpoint"""
response = requests.get(f'{base_url}/health')
assert response.status_code == 200
data = response.json()
assert data['status'] == 'healthy'
assert 'timestamp' in data
@pytest.mark.integration
def test_api_load_handling(self, base_url):
"""Test API can handle concurrent requests"""
def make_request():
return requests.get(f'{base_url}/health')
# Make 10 concurrent requests
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(make_request) for _ in range(10)]
responses = [future.result() for future in futures]
# All requests should succeed
assert all(r.status_code == 200 for r in responses)
@pytest.mark.integration
@pytest.mark.slow
def test_api_response_time(self, base_url):
"""Test API response time is acceptable"""
start_time = time.time()
response = requests.get(f'{base_url}/health')
end_time = time.time()
assert response.status_code == 200
assert (end_time - start_time) < 1.0 # Should respond within 1 second
User Acceptance Testing (UAT)
UAT Test Suite
# tests/uat/test_user_acceptance.py
import pytest
import requests
import json
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class TestUserAcceptance:
@pytest.fixture
def api_url(self):
return os.getenv('UAT_API_URL', 'http://localhost:5000')
@pytest.fixture
def web_driver(self):
options = webdriver.ChromeOptions()
options.add_argument('--headless')
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
@pytest.mark.uat
def test_user_can_access_api(self, api_url):
"""UAT: User can access the API and get a response"""
response = requests.get(f'{api_url}/')
assert response.status_code == 200
data = response.json()
assert 'message' in data
assert 'status' in data
assert data['status'] == 'healthy'
@pytest.mark.uat
def test_user_can_create_and_retrieve_users(self, api_url):
"""UAT: User can create and retrieve users via API"""
# Create a user
user_data = {
'name': 'UAT Test User',
'email': 'uat@example.com'
}
create_response = requests.post(
f'{api_url}/api/users',
json=user_data,
headers={'Content-Type': 'application/json'}
)
assert create_response.status_code == 201
create_data = create_response.json()
assert create_data['message'] == 'User created'
assert create_data['user']['name'] == user_data['name']
# Retrieve users
get_response = requests.get(f'{api_url}/api/users')
assert get_response.status_code == 200
get_data = get_response.json()
assert 'users' in get_data
@pytest.mark.uat
def test_api_error_handling(self, api_url):
"""UAT: API handles errors gracefully"""
# Test invalid endpoint
response = requests.get(f'{api_url}/invalid-endpoint')
assert response.status_code == 404
# Test invalid JSON
response = requests.post(
f'{api_url}/api/users',
data='invalid json',
headers={'Content-Type': 'application/json'}
)
assert response.status_code in [400, 422]
@pytest.mark.uat
def test_system_performance_requirements(self, api_url):
"""UAT: System meets performance requirements"""
import time
# Test response time requirement
start_time = time.time()
response = requests.get(f'{api_url}/health')
end_time = time.time()
assert response.status_code == 200
assert (end_time - start_time) < 2.0 # UAT requirement: < 2 seconds
# Test multiple requests
for _ in range(5):
response = requests.get(f'{api_url}/health')
assert response.status_code == 200
CI/CD Pipeline Setup
GitHub Actions CI Pipeline
# .github/workflows/ci.yml
name: Continuous Integration
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
env:
PYTHON_VERSION: '3.11'
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:6
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Lint with flake8
run: |
flake8 app tests --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 app tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Type check with mypy
run: mypy app
- name: Security check with bandit
run: bandit -r app
- name: Check dependencies with safety
run: safety check
- name: Run unit tests
run: |
pytest tests/unit -v --cov=app --cov-report=xml --cov-report=html
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
- name: Run integration tests
run: |
pytest tests/integration -v -m integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: |
test-results.xml
htmlcov/
coverage.xml
Docker Containerization
Multi-stage Dockerfile
# Dockerfile
FROM python:3.11-slim as builder
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.11-slim as production
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy virtual environment from builder stage
COPY --from=builder /opt/venv /opt/venv
# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Set work directory
WORKDIR /app
# Copy application code
COPY app/ ./app/
COPY scripts/ ./scripts/
# Change ownership to non-root user
RUN chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/health || exit 1
# Expose port
EXPOSE 5000
# Run application
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "app.main:create_app()"]
Docker Compose for Development
# docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/appdb
- REDIS_URL=redis://redis:6379/0
- APP_VERSION=dev
depends_on:
- db
- redis
volumes:
- ./app:/app/app
command: ["python", "-m", "flask", "run", "--host=0.0.0.0"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:13
environment:
- POSTGRES_DB=appdb
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:6-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./docker/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- app
volumes:
postgres_data:
Production Docker Compose
# docker-compose.prod.yml
version: '3.8'
services:
app:
image: ${DOCKER_REGISTRY}/python-cicd-demo:${APP_VERSION}
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- SECRET_KEY=${SECRET_KEY}
- APP_VERSION=${APP_VERSION}
depends_on:
- db
- redis
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
replicas: 3
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
db:
image: postgres:13
restart: unless-stopped
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:6-alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/nginx.prod.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- app
volumes:
postgres_data:
redis_data:
Automated Deployment
CD Pipeline with GitHub Actions
# .github/workflows/cd.yml
name: Continuous Deployment
on:
push:
branches: [ main ]
tags: [ 'v*' ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build-and-push
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy to staging
run: |
echo "Deploying ${{ needs.build-and-push.outputs.image-tag }} to staging"
# Add your staging deployment commands here
# Example: kubectl, docker-compose, or cloud provider CLI
- name: Run UAT tests
run: |
# Wait for deployment to be ready
sleep 30
# Run UAT tests against staging
pytest tests/uat -v -m uat --tb=short
env:
UAT_API_URL: ${{ secrets.STAGING_API_URL }}
deploy-production:
needs: [build-and-push, deploy-staging]
runs-on: ubuntu-latest
environment: production
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy to production
run: |
echo "Deploying ${{ needs.build-and-push.outputs.image-tag }} to production"
# Add your production deployment commands here
Deployment Scripts
#!/bin/bash
# scripts/deploy.sh
set -e
# Configuration
ENVIRONMENT=${1:-staging}
IMAGE_TAG=${2:-latest}
DOCKER_REGISTRY=${DOCKER_REGISTRY:-ghcr.io/your-org}
APP_NAME="python-cicd-demo"
echo "๐ Deploying $APP_NAME:$IMAGE_TAG to $ENVIRONMENT"
# Pull latest image
echo "๐ฅ Pulling Docker image..."
docker pull $DOCKER_REGISTRY/$APP_NAME:$IMAGE_TAG
# Stop existing containers
echo "๐ Stopping existing containers..."
docker-compose -f docker-compose.$ENVIRONMENT.yml down || true
# Start new containers
echo "โถ๏ธ Starting new containers..."
export APP_VERSION=$IMAGE_TAG
docker-compose -f docker-compose.$ENVIRONMENT.yml up -d
# Wait for health check
echo "๐ฅ Waiting for health check..."
for i in {1..30}; do
if curl -f http://localhost:5000/health > /dev/null 2>&1; then
echo "โ
Application is healthy!"
break
fi
echo "โณ Waiting for application to start... ($i/30)"
sleep 10
done
# Verify deployment
echo "๐ Verifying deployment..."
if curl -f http://localhost:5000/health > /dev/null 2>&1; then
echo "โ
Deployment successful!"
# Run post-deployment tests
echo "๐งช Running post-deployment tests..."
python scripts/health_check.py
echo "๐ Deployment completed successfully!"
else
echo "โ Deployment failed - health check failed"
echo "๐ Rolling back..."
./scripts/rollback.sh $ENVIRONMENT
exit 1
fi
Sanity Checking & Health Monitoring
Comprehensive Health Check Script
# scripts/health_check.py
import requests
import time
import sys
import logging
from typing import Dict, List, Tuple
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class HealthChecker:
def __init__(self, base_url: str, timeout: int = 30):
self.base_url = base_url.rstrip('/')
self.timeout = timeout
self.checks = []
def add_check(self, name: str, endpoint: str, expected_status: int = 200,
expected_content: str = None, max_response_time: float = 5.0):
"""Add a health check"""
self.checks.append({
'name': name,
'endpoint': endpoint,
'expected_status': expected_status,
'expected_content': expected_content,
'max_response_time': max_response_time
})
def run_check(self, check: Dict) -> Tuple[bool, str]:
"""Run a single health check"""
try:
start_time = time.time()
response = requests.get(
f"{self.base_url}{check['endpoint']}",
timeout=self.timeout
)
response_time = time.time() - start_time
# Check status code
if response.status_code != check['expected_status']:
return False, f"Expected status {check['expected_status']}, got {response.status_code}"
# Check response time
if response_time > check['max_response_time']:
return False, f"Response time {response_time:.2f}s exceeds limit {check['max_response_time']}s"
# Check content if specified
if check['expected_content']:
if check['expected_content'] not in response.text:
return False, f"Expected content '{check['expected_content']}' not found"
return True, f"OK (response time: {response_time:.2f}s)"
except requests.exceptions.RequestException as e:
return False, f"Request failed: {str(e)}"
except Exception as e:
return False, f"Unexpected error: {str(e)}"
def run_all_checks(self) -> bool:
"""Run all health checks"""
logger.info(f"๐ฅ Running health checks against {self.base_url}")
all_passed = True
results = []
for check in self.checks:
logger.info(f"๐ Checking {check['name']}...")
passed, message = self.run_check(check)
if passed:
logger.info(f"โ
{check['name']}: {message}")
else:
logger.error(f"โ {check['name']}: {message}")
all_passed = False
results.append({
'name': check['name'],
'passed': passed,
'message': message
})
# Summary
passed_count = sum(1 for r in results if r['passed'])
total_count = len(results)
if all_passed:
logger.info(f"๐ All {total_count} health checks passed!")
else:
logger.error(f"๐ฅ {total_count - passed_count} of {total_count} health checks failed!")
return all_passed
def main():
import os
# Configuration
base_url = os.getenv('HEALTH_CHECK_URL', 'http://localhost:5000')
# Initialize health checker
checker = HealthChecker(base_url)
# Add health checks
checker.add_check('Basic Health', '/health', expected_content='healthy')
checker.add_check('API Root', '/', expected_content='Python CI/CD Demo API')
checker.add_check('Users API', '/api/users', expected_status=200)
checker.add_check('Metrics', '/metrics', expected_content='app_requests_total')
# Run checks
success = checker.run_all_checks()
# Exit with appropriate code
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
Monitoring and Alerting
# scripts/monitoring.py
import requests
import time
import json
import os
from prometheus_client.parser import text_string_to_metric_families
class ApplicationMonitor:
def __init__(self, app_url: str, alert_webhook: str = None):
self.app_url = app_url.rstrip('/')
self.alert_webhook = alert_webhook
self.thresholds = {
'response_time': 2.0, # seconds
'error_rate': 0.05, # 5%
'memory_usage': 0.8, # 80%
'cpu_usage': 0.8 # 80%
}
def check_response_time(self) -> Dict:
"""Check application response time"""
try:
start_time = time.time()
response = requests.get(f'{self.app_url}/health', timeout=10)
response_time = time.time() - start_time
is_healthy = (
response.status_code == 200 and
response_time < self.thresholds['response_time']
)
return {
'metric': 'response_time',
'value': response_time,
'threshold': self.thresholds['response_time'],
'healthy': is_healthy,
'message': f"Response time: {response_time:.2f}s"
}
except Exception as e:
return {
'metric': 'response_time',
'value': None,
'threshold': self.thresholds['response_time'],
'healthy': False,
'message': f"Health check failed: {str(e)}"
}
def check_error_rate(self) -> Dict:
"""Check application error rate from metrics"""
try:
response = requests.get(f'{self.app_url}/metrics', timeout=10)
if response.status_code != 200:
raise Exception(f"Metrics endpoint returned {response.status_code}")
# Parse Prometheus metrics
total_requests = 0
error_requests = 0
for family in text_string_to_metric_families(response.text):
if family.name == 'app_requests_total':
for sample in family.samples:
total_requests += sample.value
# Count 4xx and 5xx as errors
if sample.labels.get('status', '').startswith(('4', '5')):
error_requests += sample.value
error_rate = error_requests / total_requests if total_requests > 0 else 0
is_healthy = error_rate < self.thresholds['error_rate']
return {
'metric': 'error_rate',
'value': error_rate,
'threshold': self.thresholds['error_rate'],
'healthy': is_healthy,
'message': f"Error rate: {error_rate:.2%} ({error_requests}/{total_requests})"
}
except Exception as e:
return {
'metric': 'error_rate',
'value': None,
'threshold': self.thresholds['error_rate'],
'healthy': False,
'message': f"Error rate check failed: {str(e)}"
}
def send_alert(self, alert_data: Dict):
"""Send alert to webhook"""
if not self.alert_webhook:
return
try:
payload = {
'text': f"๐จ Alert: {alert_data['message']}",
'attachments': [{
'color': 'danger',
'fields': [
{'title': 'Metric', 'value': alert_data['metric'], 'short': True},
{'title': 'Value', 'value': str(alert_data['value']), 'short': True},
{'title': 'Threshold', 'value': str(alert_data['threshold']), 'short': True},
{'title': 'Timestamp', 'value': time.strftime('%Y-%m-%d %H:%M:%S'), 'short': True}
]
}]
}
requests.post(self.alert_webhook, json=payload, timeout=10)
except Exception as e:
print(f"Failed to send alert: {str(e)}")
def run_monitoring_cycle(self):
"""Run a complete monitoring cycle"""
checks = [
self.check_response_time(),
self.check_error_rate()
]
for check in checks:
if not check['healthy']:
print(f"โ {check['message']}")
self.send_alert(check)
else:
print(f"โ
{check['message']}")
return all(check['healthy'] for check in checks)
def main():
app_url = os.getenv('APP_URL', 'http://localhost:5000')
alert_webhook = os.getenv('ALERT_WEBHOOK_URL')
monitor = ApplicationMonitor(app_url, alert_webhook)
# Run monitoring
is_healthy = monitor.run_monitoring_cycle()
if not is_healthy:
print("๐จ Application health issues detected!")
exit(1)
else:
print("โ
Application is healthy!")
if __name__ == '__main__':
main()
Rollback Strategies
Automated Rollback Script
#!/bin/bash
# scripts/rollback.sh
set -e
# Configuration
ENVIRONMENT=${1:-staging}
ROLLBACK_VERSION=${2:-"previous"}
DOCKER_REGISTRY=${DOCKER_REGISTRY:-ghcr.io/your-org}
APP_NAME="python-cicd-demo"
echo "๐ Starting rollback for $APP_NAME in $ENVIRONMENT environment"
# Function to get previous version
get_previous_version() {
if [ "$ROLLBACK_VERSION" = "previous" ]; then
# Get the previous version from deployment history
PREVIOUS_VERSION=$(docker images --format "table {{.Repository}}:{{.Tag}}" | \
grep "$DOCKER_REGISTRY/$APP_NAME" | \
head -2 | tail -1 | \
cut -d':' -f2)
if [ -z "$PREVIOUS_VERSION" ]; then
echo "โ No previous version found!"
exit 1
fi
echo "๐ Previous version found: $PREVIOUS_VERSION"
echo "$PREVIOUS_VERSION"
else
echo "$ROLLBACK_VERSION"
fi
}
# Function to backup current state
backup_current_state() {
echo "๐พ Backing up current state..."
# Export current database state
docker-compose -f docker-compose.$ENVIRONMENT.yml exec -T db \
pg_dump -U postgres appdb > backup_$(date +%Y%m%d_%H%M%S).sql
# Save current configuration
cp docker-compose.$ENVIRONMENT.yml docker-compose.$ENVIRONMENT.yml.backup
echo "โ
Current state backed up"
}
# Function to perform rollback
perform_rollback() {
local target_version=$1
echo "๐ Rolling back to version: $target_version"
# Stop current containers
echo "๐ Stopping current containers..."
docker-compose -f docker-compose.$ENVIRONMENT.yml down
# Pull target version
echo "๐ฅ Pulling target version..."
docker pull $DOCKER_REGISTRY/$APP_NAME:$target_version
# Update environment variable
export APP_VERSION=$target_version
# Start containers with target version
echo "โถ๏ธ Starting containers with target version..."
docker-compose -f docker-compose.$ENVIRONMENT.yml up -d
# Wait for application to start
echo "โณ Waiting for application to start..."
sleep 30
# Verify rollback
echo "๐ Verifying rollback..."
if curl -f http://localhost:5000/health > /dev/null 2>&1; then
echo "โ
Rollback successful!"
# Run health checks
python scripts/health_check.py
if [ $? -eq 0 ]; then
echo "๐ Rollback completed successfully!"
# Send notification
if [ -n "$SLACK_WEBHOOK_URL" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"๐ Rollback completed: $APP_NAME rolled back to $target_version in $ENVIRONMENT\"}" \
$SLACK_WEBHOOK_URL
fi
else
echo "โ Health checks failed after rollback!"
exit 1
fi
else
echo "โ Rollback failed - application not responding!"
exit 1
fi
}
# Function to rollback database if needed
rollback_database() {
echo "๐๏ธ Checking if database rollback is needed..."
# This is a simplified example - in production you'd have more sophisticated
# database migration rollback logic
if [ -f "database_rollback_$ROLLBACK_VERSION.sql" ]; then
echo "๐ Rolling back database..."
docker-compose -f docker-compose.$ENVIRONMENT.yml exec -T db \
psql -U postgres -d appdb -f database_rollback_$ROLLBACK_VERSION.sql
echo "โ
Database rollback completed"
else
echo "โน๏ธ No database rollback needed"
fi
}
# Main rollback process
main() {
echo "๐จ ROLLBACK INITIATED"
echo "Environment: $ENVIRONMENT"
echo "Target version: $ROLLBACK_VERSION"
echo "Timestamp: $(date)"
# Get target version
TARGET_VERSION=$(get_previous_version)
# Confirm rollback
if [ "$ENVIRONMENT" = "production" ]; then
echo "โ ๏ธ This is a PRODUCTION rollback!"
echo "Target version: $TARGET_VERSION"
read -p "Are you sure you want to proceed? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "โ Rollback cancelled"
exit 1
fi
fi
# Backup current state
backup_current_state
# Perform rollback
perform_rollback $TARGET_VERSION
# Rollback database if needed
rollback_database
echo "๐ Rollback process completed!"
}
# Error handling
trap 'echo "โ Rollback failed! Check logs and manual intervention may be required."' ERR
# Run main function
main
Blue-Green Deployment with Rollback
# scripts/blue_green_deploy.py
import subprocess
import time
import requests
import sys
import os
class BlueGreenDeployment:
def __init__(self, app_name: str, new_version: str):
self.app_name = app_name
self.new_version = new_version
self.current_color = self.get_current_color()
self.target_color = 'green' if self.current_color == 'blue' else 'blue'
def get_current_color(self) -> str:
"""Determine current active color"""
try:
# Check which color is currently receiving traffic
result = subprocess.run(['docker', 'ps', '--format', 'table {{.Names}}'],
capture_output=True, text=True)
if f'{self.app_name}-blue' in result.stdout:
return 'blue'
elif f'{self.app_name}-green' in result.stdout:
return 'green'
else:
return 'blue' # Default to blue if neither is running
except:
return 'blue'
def deploy_target_environment(self):
"""Deploy new version to target environment"""
print(f"๐ Deploying {self.new_version} to {self.target_color} environment")
# Stop target environment if running
subprocess.run([
'docker-compose', '-f', f'docker-compose.{self.target_color}.yml', 'down'
], check=False)
# Start target environment with new version
env = os.environ.copy()
env['APP_VERSION'] = self.new_version
subprocess.run([
'docker-compose', '-f', f'docker-compose.{self.target_color}.yml', 'up', '-d'
], env=env, check=True)
# Wait for deployment
time.sleep(30)
def health_check_target(self) -> bool:
"""Perform health check on target environment"""
print(f"๐ฅ Health checking {self.target_color} environment")
target_port = 5001 if self.target_color == 'green' else 5000
try:
for attempt in range(10):
response = requests.get(f'http://localhost:{target_port}/health', timeout=10)
if response.status_code == 200:
print(f"โ
{self.target_color} environment is healthy")
return True
time.sleep(10)
print(f"โ {self.target_color} environment failed health check")
return False
except Exception as e:
print(f"โ Health check failed: {str(e)}")
return False
def switch_traffic(self):
"""Switch traffic to target environment"""
print(f"๐ Switching traffic from {self.current_color} to {self.target_color}")
# Update load balancer configuration
# This would typically involve updating nginx config, cloud load balancer, etc.
subprocess.run([
'docker-compose', '-f', 'docker-compose.nginx.yml', 'restart'
], check=True)
time.sleep(10)
def verify_switch(self) -> bool:
"""Verify traffic switch was successful"""
print("๐ Verifying traffic switch")
try:
# Check main endpoint is responding
response = requests.get('http://localhost/health', timeout=10)
if response.status_code == 200:
data = response.json()
if data.get('version') == self.new_version:
print("โ
Traffic switch verified")
return True
print("โ Traffic switch verification failed")
return False
except Exception as e:
print(f"โ Traffic switch verification failed: {str(e)}")
return False
def cleanup_old_environment(self):
"""Clean up old environment"""
print(f"๐งน Cleaning up {self.current_color} environment")
subprocess.run([
'docker-compose', '-f', f'docker-compose.{self.current_color}.yml', 'down'
], check=False)
def rollback(self):
"""Rollback to previous environment"""
print(f"๐ Rolling back to {self.current_color} environment")
# Switch traffic back
subprocess.run([
'docker-compose', '-f', 'docker-compose.nginx.yml', 'restart'
], check=True)
# Clean up failed deployment
subprocess.run([
'docker-compose', '-f', f'docker-compose.{self.target_color}.yml', 'down'
], check=False)
print(f"โ
Rollback to {self.current_color} completed")
def deploy(self) -> bool:
"""Execute blue-green deployment"""
try:
print(f"๐ฏ Starting blue-green deployment")
print(f"Current: {self.current_color}, Target: {self.target_color}")
# Deploy to target environment
self.deploy_target_environment()
# Health check target
if not self.health_check_target():
self.rollback()
return False
# Switch traffic
self.switch_traffic()
# Verify switch
if not self.verify_switch():
self.rollback()
return False
# Cleanup old environment
self.cleanup_old_environment()
print("๐ Blue-green deployment completed successfully!")
return True
except Exception as e:
print(f"โ Deployment failed: {str(e)}")
self.rollback()
return False
def main():
if len(sys.argv) != 3:
print("Usage: python blue_green_deploy.py ")
sys.exit(1)
app_name = sys.argv[1]
version = sys.argv[2]
deployment = BlueGreenDeployment(app_name, version)
success = deployment.deploy()
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
Complete Implementation Example
Step-by-Step Implementation
Complete CI/CD Implementation Checklist
Follow this comprehensive checklist to implement the complete CI/CD pipeline:
1. Project Setup
- โ Create project structure with proper directories
- โ Set up virtual environment and dependencies
- โ Initialize Git repository with proper .gitignore
- โ Create requirements.txt and requirements-dev.txt
- โ Set up pytest configuration
2. Application Development
- โ Implement Flask application with health endpoints
- โ Add Prometheus metrics for monitoring
- โ Implement proper logging and error handling
- โ Create service layer with business logic
- โ Add configuration management
3. Testing Implementation
- โ Write comprehensive unit tests with pytest
- โ Implement integration tests with database
- โ Create UAT tests for end-to-end validation
- โ Set up code coverage reporting
- โ Add performance and load testing
4. Containerization
- โ Create multi-stage Dockerfile for optimization
- โ Set up docker-compose for development
- โ Configure production docker-compose
- โ Add health checks to containers
- โ Implement security best practices
5. CI/CD Pipeline
- โ Set up GitHub Actions for CI
- โ Configure automated testing pipeline
- โ Implement security scanning
- โ Set up container registry
- โ Create deployment pipeline
6. Deployment & Monitoring
- โ Implement automated deployment scripts
- โ Set up health monitoring
- โ Configure alerting and notifications
- โ Implement rollback strategies
- โ Set up blue-green deployment
Running the Complete Pipeline
# Complete pipeline execution example
# 1. Clone and setup
git clone https://github.com/your-org/python-cicd-demo.git
cd python-cicd-demo
# 2. Set up development environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements-dev.txt
# 3. Run all tests locally
pytest tests/ -v --cov=app --cov-report=html
# 4. Build and test Docker container
docker build -t python-cicd-demo:local .
docker run -d -p 5000:5000 --name test-app python-cicd-demo:local
# 5. Run health checks
python scripts/health_check.py
# 6. Clean up
docker stop test-app && docker rm test-app
# 7. Push to trigger CI/CD
git add .
git commit -m "feat: implement new feature"
git push origin main
# 8. Monitor deployment
# CI/CD pipeline will automatically:
# - Run all tests
# - Build Docker image
# - Deploy to staging
# - Run UAT tests
# - Deploy to production (if tests pass)
# - Monitor health and rollback if needed
Production Deployment Commands
# Production deployment workflow
# 1. Deploy new version
./scripts/deploy.sh production v1.2.0
# 2. Monitor deployment
python scripts/monitoring.py
# 3. If issues detected, rollback
./scripts/rollback.sh production
# 4. Blue-green deployment (alternative)
python scripts/blue_green_deploy.py python-cicd-demo v1.2.0
# 5. Verify deployment
curl -f https://api.yourapp.com/health
python scripts/health_check.py
Resources & Best Practices
Key Takeaways
CI/CD Success Factors:
- Comprehensive Testing: Unit, integration, and UAT tests are crucial
- Automated Everything: Minimize manual intervention in the pipeline
- Fast Feedback: Quick test execution and immediate notifications
- Rollback Ready: Always have a rollback strategy
- Monitor Everything: Health checks, metrics, and alerting
- Security First: Integrate security scanning throughout
Best Practices Summary
- Test Pyramid: Many unit tests, fewer integration tests, minimal UAT tests
- Fail Fast: Run fastest tests first to get quick feedback
- Immutable Deployments: Use containers for consistent deployments
- Environment Parity: Keep dev, staging, and production similar
- Database Migrations: Version and test database changes
- Feature Flags: Use feature toggles for safer deployments
- Monitoring: Implement comprehensive observability
- Documentation: Keep runbooks and procedures updated
Common Pitfalls to Avoid
Avoid These Mistakes:
- Skipping tests to speed up deployment
- Not testing rollback procedures
- Insufficient monitoring and alerting
- Manual deployment steps
- Not versioning database changes
- Ignoring security scanning results
- Poor error handling and logging
- Not having environment-specific configurations
Tools and Technologies Used
- Python: Flask, pytest, requests, prometheus-client
- Testing: pytest, pytest-cov, selenium
- Containerization: Docker, docker-compose
- CI/CD: GitHub Actions
- Monitoring: Prometheus, custom health checks
- Security: bandit, safety, container scanning
- Code Quality: flake8, mypy, black
Further Reading
- pytest Documentation
- Docker Development Best Practices
- GitHub Actions Documentation
- Prometheus Monitoring
- The Twelve-Factor App
You're Now a Python CI/CD Expert! You have implemented a complete, production-ready CI/CD pipeline with comprehensive testing, automated deployment, monitoring, and rollback capabilities. This foundation will serve you well in any Python project!