Docker Python CI/CD: Deploy Containerized Apps Automatically
CI/CD pipelines automate the journey from git push to production deployment: they run tests, build Docker images, push them to a registry, and deploy to servers—without human intervention. A well-designed pipeline catches bugs before production, ensures consistent builds, and lets you deploy multiple times daily with confidence. This article walks through a complete GitHub Actions pipeline for Python containerized apps, but the principles apply to GitLab CI, Jenkins, and other systems.
I remember manually building images, testing locally, pushing to a registry, and then SSH-ing into production to restart containers. One typo and the app crashed. Now, git push triggers an automated pipeline that builds, tests, pushes, and deploys in under 5 minutes. It's been transformational for speed and reliability.
A Complete GitHub Actions Pipeline
Here's a production-grade pipeline that builds a Python Docker image, runs tests, and deploys to Docker Hub and AWS ECS:
Create .github/workflows/deploy.yml:
name: Build and Deploy Docker App
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/myapp
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
- name: Run tests
run: |
docker run --rm \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
pytest tests/ -v
deploy:
runs-on: ubuntu-latest
needs: build-and-test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy to AWS ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ecs-task-definition.json
service: myapp-service
cluster: production
force-new-deployment: true
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
What's happening:
- Trigger: Pipeline runs on every push to main/develop and PRs.
- Build: Docker Buildx builds the image and pushes to Docker Hub.
- Metadata: Tags the image with git branch, semantic version, and commit SHA.
- Cache: Caches layers to speed up future builds (massive time savings).
- Test: Runs
pytestinside the container to verify the build works. - Deploy: Only on main branch, deploys the new image to AWS ECS.
Your Dockerfile remains the same:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN pip install pytest # Include test dependencies
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Setting Up Secrets in GitHub
GitHub Actions needs credentials to push to Docker Hub and deploy to AWS. Add them as secrets:
- Go to your GitHub repo → Settings → Secrets and variables → Actions.
- Add secrets:
DOCKER_USERNAME: Your Docker Hub username.DOCKER_PASSWORD: A Docker Hub access token (not your password).AWS_ACCESS_KEY_ID: IAM key for ECS.AWS_SECRET_ACCESS_KEY: IAM secret for ECS.
In the workflow, reference them with ${{ secrets.DOCKER_USERNAME }}. GitHub masks their values in logs, so they're never exposed.
GitLab CI Alternative
If you're using GitLab, here's an equivalent .gitlab-ci.yml:
stages:
- build
- test
- deploy
variables:
REGISTRY: registry.gitlab.com
IMAGE_NAME: $REGISTRY/$CI_PROJECT_PATH
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
build:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $REGISTRY
- docker build -t $IMAGE_NAME:$IMAGE_TAG -t $IMAGE_NAME:latest .
- docker push $IMAGE_NAME:$IMAGE_TAG
- docker push $IMAGE_NAME:latest
test:
stage: test
image: $IMAGE_NAME:$IMAGE_TAG
script:
- pip install pytest
- pytest tests/ -v
deploy:
stage: deploy
image: alpine:latest
script:
- apk add --no-cache curl
- curl -X POST https://your-deployment-api.com/deploy \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-d "{\"image\": \"$IMAGE_NAME:$IMAGE_TAG\"}"
only:
- main
Deploying to AWS ECS
AWS ECS (Elastic Container Service) manages containerized applications at scale. Update your ECS task definition to reference the new image:
Create ecs-task-definition.json:
{
"family": "myapp",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "1024",
"memory": "2048",
"containerDefinitions": [
{
"name": "myapp",
"image": "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest",
"portMappings": [
{
"containerPort": 8000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "ENVIRONMENT",
"value": "production"
}
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/database-url"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/myapp",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": [
"CMD-SHELL",
"curl -f http://localhost:8000/health || exit 1"
],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 10
}
}
],
"taskRoleArn": "arn:aws:iam::123456789:role/ecsTaskRole"
}
The GitHub Actions workflow updates this definition and triggers a new ECS deployment. ECS pulls the new image and rolls it out to running tasks.
Docker Registry Best Practices
When pushing to a registry (Docker Hub, GitHub Container Registry, or AWS ECR), use semantic versioning and meaningful tags:
# Build with multiple tags
docker build -t myapp:latest -t myapp:1.2.3 -t myapp:1.2 .
# Push all tags
docker push myapp:latest
docker push myapp:1.2.3
docker push myapp:1.2
In your CI pipeline, use commit SHA as the unique identifier:
docker build -t myapp:${CI_COMMIT_SHA:0:7} .
docker push myapp:${CI_COMMIT_SHA:0:7}
This ensures every build is traceable to a specific commit.
Security in CI/CD
Never commit credentials. Use secrets and environment variables:
# Bad: credentials in code
RUN echo "aws_access_key_id=AKIA..." > ~/.aws/credentials
# Good: credentials via CI secrets
script:
- aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
Scan images for vulnerabilities before deploying:
- name: Run Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
This checks for known CVEs in your dependencies and reports them.
A Complete Example: From Commit to Production
- Developer pushes to main branch:
git push origin main. - GitHub Actions workflow triggers automatically.
- Docker image is built with cache optimization.
- Tests run inside the container.
- Image is pushed to Docker Hub with tag
myapp:abc1234andmyapp:latest. - AWS ECS task definition is updated with the new image.
- ECS rolls out the new version to production (health checks verify it's working).
- If health checks fail, ECS automatically rolls back to the previous version.
Total time: 3–5 minutes, no manual steps.
Key Takeaways
- CI/CD pipelines automate build, test, and deployment of containerized apps.
- GitHub Actions is simple for containerized workflows; GitLab CI offers similar features.
- Use semantic versioning and commit SHAs to tag images for traceability.
- Cache Docker layers in the registry to speed up builds.
- Deploy to AWS ECS, Google Cloud Run, or Kubernetes automatically from CI.
- Scan images for vulnerabilities before pushing to production.
Frequently Asked Questions
How do I deploy to multiple environments (dev, staging, prod) from one pipeline?
Use git branches: main → prod, develop → staging. Each branch triggers its own deployment step with different secrets and configurations.
What if a deployment fails? How do I rollback?
ECS and Kubernetes automatically track previous deployments. You can rollback with one command: aws ecs update-service --cluster prod --service myapp --force-new-deployment (ECS rolls back to the previous stable task definition).
How do I handle database migrations in CI/CD?
Run migrations before deploying the new app version:
- name: Run database migrations
run: |
docker run --rm \
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
$IMAGE alembic upgrade head
Alembic or Django migrations run in a separate step before the main deployment.
Can I deploy to on-premises servers from GitHub Actions?
Yes, use self-hosted runners or SSH:
- name: Deploy via SSH
run: |
ssh -i ${{ secrets.SSH_KEY }} [email protected] \
"docker pull myapp:latest && docker compose up -d"
How often should I deploy?
As often as tests pass and you're confident in changes. Many teams deploy multiple times daily. Smaller, frequent deployments reduce risk.