Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 84

Automate Docker Image Builds and Pushes with CD

Recommended resources

Hello! Welcome back to our final module.

In our last lesson, you successfully built your first Continuous Integration (CI) pipeline. You configured GitHub Actions to automatically run your Django project's tests on every code change, creating a crucial quality gate. This ensures that new code doesn't break existing functionality.

Today, we take the next logical step from Continuous Integration to Continuous Deployment (CD). The goal is to configure a CD step to automatically build a Docker image and push it to a container registry. While CI validates your code, this step packages your application into a standardized, portable artifact—a Docker image—making it ready for deployment. This process transforms your source code into a single, versioned unit that can run consistently on any machine.

This lesson will guide you through:

  1. Understanding the role of container registries in a CD pipeline.
  2. Using GitHub Actions to securely authenticate with a registry.
  3. Building a Docker image from your application code within the CI/CD pipeline.
  4. Pushing the built image to Docker Hub, a popular container registry.
  5. Integrating this new step into your existing CI workflow.

Let's begin by preparing our application for distribution.

1. The Role of a Container Registry

In the previous module, you learned how to build a Docker image locally. But for a CI/CD pipeline, you need a central, accessible location to store these images so your deployment servers can pull them. This is the role of a container registry.

Think of a container registry like PyPI for Python packages or npm for Node.js packages. It's a storage system for your container images, with features for versioning, access control, and distribution.

GitHub Actions to Docker Image Build and Push to ghcr.io
This diagram illustrates the core workflow we are building today. An event in GitHub triggers an Action that builds a Docker image and pushes it to a remote registry, making it available for deployment.

Popular registries include:

  • Docker Hub: The default and most widely known public/private registry.
  • GitHub Container Registry (GHCR): Tightly integrated with GitHub repositories and Actions.
  • Cloud-specific registries: AWS ECR, Google Artifact Registry, Azure Container Registry.

For this lesson, we will use Docker Hub due to its widespread use. Before proceeding, make sure you have:

  1. A free account on Docker Hub.
  2. A public repository created on Docker Hub. A good naming convention is <your-dockerhub-username>/<your-project-name>.

2. Automating the Build and Push Process

Our goal is to extend the ci.yml workflow from the last lesson. After the tests pass, we want to build our Django application's Docker image and push it to Docker Hub.

To do this, our workflow needs to perform three main tasks:

  1. Check out the repository code.
  2. Log in to Docker Hub.
  3. Build the Docker image and push it.

The login step presents a security challenge: how do we provide credentials to our workflow without hardcoding them in the YAML file? The answer is GitHub Secrets.

The following video provides a practical walkthrough of this entire process. It first demonstrates the "manual" command-line approach, which is excellent for understanding the mechanics, and then shows how to troubleshoot the inevitable authentication failure.

Build and Push Docker Images with GitHub Actions

Watch this video from 'Rishab in Cloud' to see a step-by-step demonstration of building and pushing a Docker image using GitHub Actions. Pay close attention to how he resolves the authentication error by using a Docker Hub access token and GitHub Secrets.

Watch the section from 05:50 to 09:42. This part covers creating a Docker Hub access token, storing it as a GitHub secret, and using it to log in and push the image from the workflow.


3. Implementing the CD Step with Pre-built Actions

While you can use raw docker login, docker build, and docker push commands, the community has created reusable Actions that simplify this process, making workflows cleaner and more maintainable. We will use two official actions from Docker:

  • docker/login-action
  • docker/build-push-action

Let's walk through the implementation using a fantastic article that is tailored for a Django project.

🐳 Step 3: Building and Pushing Docker Images

This article by Jinal Lathiya provides a concise, step-by-step guide to containerizing a Django app and automating the image push with GitHub Actions. We will focus on the parts related to Docker Hub setup and the workflow configuration.

Read through 'Step 3.3 — Set Up Docker Hub / GHCR' and 'Step 3.4 — Automate Build and Push with GitHub Actions'. These sections explain how to create the necessary secrets and provide the exact YAML code for the workflow job.

Now, let's synthesize this information into a concrete workflow job.

Step 1: Store Credentials as GitHub Secrets

As shown in both the video and the article, you must first store your Docker Hub credentials securely.

  1. Generate a Docker Hub Access Token:

    • Go to your Docker Hub account settings.
    • Navigate to Security > New Access Token.
    • Give it a description (e.g., "GitHub Actions Token") and set its permissions to Read, Write, Delete.
    • Click Generate and copy the token immediately. You will not be able to see it again.
  2. Add Secrets to Your GitHub Repository:

    • In your GitHub repository, go to Settings > Secrets and variables > Actions.
    • Click New repository secret.
    • Create a secret named DOCKERHUB_USERNAME with your Docker Hub username as the value.
    • Create another secret named DOCKERHUB_TOKEN with the access token you just generated as the value.

Your workflow can now securely access these credentials using the secrets context (e.g., ${{ secrets.DOCKERHUB_USERNAME }}).

Step 2: Create the build_and_push Job

Now, let's add a new job to our existing .github/workflows/ci.yml file. This job will handle building and pushing the image.

Here is what the new job, build_and_push, will look like:

# In .github/workflows/ci.yml

# ... (keep the 'name' and 'on' sections from the previous lesson)

jobs:
  # ... (keep the 'test' job from the previous lesson)

  build_and_push:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: your_dockerhub_username/your_repo_name:latest # IMPORTANT: Change this!

Breakdown of the build_and_push job:

  • steps:: This job has three steps.
  • actions/checkout@v4: As before, this downloads your source code, including your Dockerfile, to the runner.
  • docker/login-action@v3: This action handles authentication. The with block passes your secrets as username and password parameters.
  • docker/build-push-action@v5: This is the powerhouse. It finds your Dockerfile, builds the image, and pushes it to the registry.
    • context: .: Tells the action that the build context (where the Dockerfile is) is the root of the repository.
    • push: true: Instructs the action to push the image after a successful build.
    • tags:: Specifies the name and tag for the image. You must replace your_dockerhub_username/your_repo_name with your actual Docker Hub details. The :latest tag indicates this is the most recent version.

4. Creating a Full CI/CD Pipeline

Right now, you have two separate jobs: test and build_and_push. They will run in parallel by default. This is not what we want. We should only build and push the image if the tests pass. Furthermore, we should probably only push a new image when we merge code into our main branch, not on every single push during development.

We can orchestrate this using two key features: needs and if conditions.

Here is the complete, production-ready ci.yml file that combines CI and CD:

# .github/workflows/ci.yml

name: Django CI/CD

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
      - name: Run Django Tests
        run: python manage.py test

  build_and_push:
    # This job depends on the 'test' job completing successfully
    needs: test
    # This job only runs when code is pushed to the 'main' branch
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: your_dockerhub_username/your_repo_name:latest # Change this!

Key changes:

  • needs: test: This line in the build_and_push job tells GitHub Actions to wait until the test job has finished successfully before starting. If test fails, build_and_push will be skipped.
  • if: github.event_name == 'push' && github.ref == 'refs/heads/main': This conditional statement ensures the build_and_push job only runs for push events to the main branch. It will not run for pull requests or pushes to other branches, which saves time and avoids polluting your registry with development images.

Now, when you create a pull request, only the test job will run. Once you merge that PR into main, both the test job and the build_and_push job will run in sequence.

Test your understanding!

Using latest as a tag is convenient, but it can be problematic because it's mutable. A better practice is to use immutable tags, like the unique Git commit SHA.

The build-push-action can access GitHub context variables. The commit SHA is available at github.sha. How would you modify the tags parameter to tag the image with both latest and the short commit SHA? (Hint: tags can be a multi-line string).

Show answer

You would modify the tags section like this:

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            your_dockerhub_username/your_repo_name:latest
            your_dockerhub_username/your_repo_name:${{ github.sha }}

This will create two tags for the same image, giving you both a stable reference to the latest build and a precise, unique identifier tied to the exact version of the code.


Conclusion

You have now successfully configured the first major stage of a Continuous Deployment pipeline. Your system can now automatically test your code and, upon success, package it into a versioned, portable Docker image stored in a central registry. This is a massive step forward in creating a reliable and automated path from development to production.

Key Takeaways:

  • Container Registries (like Docker Hub) are essential for storing and distributing Docker images.
  • GitHub Secrets are the standard for securely managing credentials like API tokens in your workflows.
  • The docker/login-action and docker/build-push-action are powerful, reusable Actions that simplify the process of publishing images.
  • The needs keyword creates dependencies between jobs, allowing you to build sequential pipelines (e.g., test first, then build).
  • The if conditional allows you to control exactly when a job should run, enabling strategies like "only push to the registry on a merge to main".

Preview of the Next Lesson:
Our application is now automatically tested and packaged. The next logical step would be to deploy it. However, before we discuss deployment architectures, we need to understand a fundamental concept that drives them: scalability. In our next lesson, we will explore the difference between vertical and horizontal scaling and discuss why modern web applications are designed to scale horizontally—a principle that is deeply connected to the stateless, containerized approach we are building.