Skip to main content

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:

  1. Trigger: Pipeline runs on every push to main/develop and PRs.
  2. Build: Docker Buildx builds the image and pushes to Docker Hub.
  3. Metadata: Tags the image with git branch, semantic version, and commit SHA.
  4. Cache: Caches layers to speed up future builds (massive time savings).
  5. Test: Runs pytest inside the container to verify the build works.
  6. 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:

  1. Go to your GitHub repo → Settings → Secrets and variables → Actions.
  2. 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

  1. Developer pushes to main branch: git push origin main.
  2. GitHub Actions workflow triggers automatically.
  3. Docker image is built with cache optimization.
  4. Tests run inside the container.
  5. Image is pushed to Docker Hub with tag myapp:abc1234 and myapp:latest.
  6. AWS ECS task definition is updated with the new image.
  7. ECS rolls out the new version to production (health checks verify it's working).
  8. 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.

Further Reading