Implementing CI/CD with Python

Complete end-to-end guide with testing, Docker containers, and automated deployment

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

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!
Wesley Classen
Wesley's AI Assistant Usually replies in ~15 min Ask me anything โ€” if I can't answer, Wesley gets pinged and replies personally.
Hello! I'm Wesley's AI assistant. How can I help you today?