CI/CD with Docker
LEVEL 0
The Problem
You’ve built a great application. But deploying it requires:
- Developer pushes code to git
- Build Docker image locally
- Test image
- Tag image
- Push to registry
- SSH into production server
- Pull new image
- Restart containers
- Hope it works
This takes 30 minutes and is error-prone.
You need automated CI/CD.
LEVEL 1
The Concept — The Factory Assembly Line
The Concept
Imagine building cars.
Manual process: One person builds entire car by hand. Slow, inconsistent, mistakes happen.
Assembly line:
- Quality check (automated tests)
- Assembly (build)
- Paint (image creation)
- Final inspection (security scan)
- Delivery (deployment)
Automated, fast, consistent, repeatable.
CI/CD is an assembly line for software.
LEVEL 2
The Mechanics — CI/CD Pipeline
The Mechanics
GitHub Actions workflow:
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run tests
run: |
docker compose -f docker-compose.test.yml up --abort-on-container-exit
docker compose -f docker-compose.test.yml down
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
myapp:latest
myapp:${{ github.sha }}
cache-from: type=registry,ref=myapp:latest
cache-to: type=inline
security:
needs: build
runs-on: ubuntu-latest
steps:
- name: Scan image
run: |
docker pull myapp:${{ github.sha }}
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:${{ github.sha }}
deploy:
needs: [build, security]
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /app
docker compose pull
docker compose up -d
docker system prune -f
LEVEL 3
Multi-Stage Deployment
docker-compose.prod.yml:
version: '3.9'
services:
app:
image: myapp:${VERSION}
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
failure_action: rollback
rollback_config:
parallelism: 1
delay: 5s
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 40s
Deployment script:
#!/bin/bash
# deploy.sh
VERSION=$1
if [ -z "$VERSION" ]; then
echo "Usage: ./deploy.sh <version>"
exit 1
fi
echo "Deploying version $VERSION"
# Pull new image
docker pull myapp:$VERSION
# Run pre-deployment tests
docker compose -f docker-compose.test.yml up --abort-on-container-exit
if [ $? -ne 0 ]; then
echo "Tests failed, aborting deployment"
exit 1
fi
# Deploy
VERSION=$VERSION docker compose -f docker-compose.prod.yml up -d
# Wait for health checks
echo "Waiting for containers to be healthy..."
sleep 30
# Verify deployment
docker compose -f docker-compose.prod.yml ps | grep healthy
if [ $? -eq 0 ]; then
echo "Deployment successful"
# Cleanup old images
docker image prune -f
else
echo "Deployment failed, rolling back"
docker compose -f docker-compose.prod.yml rollback
exit 1
fi
LEVEL 4
GitLab CI/CD
# .gitlab-ci.yml
stages:
- test
- build
- security
- deploy
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
test:
stage: test
script:
- docker compose -f docker-compose.test.yml up --abort-on-container-exit
after_script:
- docker compose -f docker-compose.test.yml down
build:
stage: build
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
only:
- main
security_scan:
stage: security
script:
- trivy image --severity HIGH,CRITICAL --exit-code 1 $IMAGE_TAG
only:
- main
deploy_staging:
stage: deploy
script:
- scp docker-compose.prod.yml $STAGING_HOST:/app/
- ssh $STAGING_HOST "cd /app && VERSION=$CI_COMMIT_SHORT_SHA docker compose -f docker-compose.prod.yml up -d"
environment:
name: staging
url: https://staging.example.com
only:
- main
deploy_production:
stage: deploy
script:
- scp docker-compose.prod.yml $PROD_HOST:/app/
- ssh $PROD_HOST "cd /app && VERSION=$CI_COMMIT_SHORT_SHA docker compose -f docker-compose.prod.yml up -d"
environment:
name: production
url: https://example.com
when: manual
only:
- main
LEVEL 5
Best Practices
1. Build once, deploy many times
Build image once in CI, deploy same image to dev, staging, production.
git push → Build image → Tag as :abc123
↓
Deploy to staging (test)
↓
Deploy to production (same image)
2. Use semantic versioning
VERSION=$(cat VERSION) # e.g., 1.2.3
GIT_SHA=$(git rev-parse --short HEAD)
docker build -t myapp:$VERSION -t myapp:$GIT_SHA -t myapp:latest .
3. Automate rollback
deploy:
script:
- ./deploy.sh $VERSION
- ./healthcheck.sh || ./rollback.sh
4. Separate config from code
services:
app:
image: myapp:${VERSION}
env_file:
- .env.production # Different per environment
5. Log everything
- name: Deploy
run: |
./deploy.sh 2>&1 | tee deploy.log
aws s3 cp deploy.log s3://deployment-logs/$(date +%Y%m%d-%H%M%S).log