EthicsComputerCourses
Join training +91 90532 10052

CI/CD: Build & Push with GitHub Actions

Docker & DevOps · Lesson 31 of 39 Advanced 7 min read Updated

Building and pushing images by hand works, but people forget steps, push from a laptop with uncommitted changes, or skip the tests on a busy day. CI/CD removes the human from those repeated steps. Continuous integration (CI) means every push is built and tested automatically. Continuous delivery or deployment (CD) means a tested version is packaged and released, and possibly deployed, automatically too.

In this lesson you will write a GitHub Actions workflow that tests your app inside a Docker build, tags the image sensibly, pushes it to GitHub Container Registry and deploys release versions to a server. If GitHub Actions is new to you, read GitHub Actions: First CI Workflow first.

The pipeline you will build

  1. A developer pushes a commit or opens a pull request.
  2. GitHub Actions builds the test stage of the Dockerfile, which runs the tests.
  3. If the tests pass, it builds the production image.
  4. For pushes to main and for version tags such as v2.1.0, it pushes the image to GHCR. Pull requests are built and tested but never pushed.
  5. For version tags, a second job deploys the new version to the server.

A Dockerfile with a test stage

Running tests inside a build stage means CI needs nothing installed except Docker, and the tests run in exactly the environment the app ships in:

Dockerfile
# syntax=docker/dockerfile:1
FROM node:26-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./

FROM base AS test
RUN npm ci
COPY . .
RUN npm test

FROM base AS runtime
ENV NODE_ENV=production
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]

docker build --target test . runs the tests; if npm test fails, the build fails. A normal build produces runtime, the last stage. BuildKit skips the test stage in that case, because runtime does not depend on it.

The workflow file

Save this as .github/workflows/docker.yml in your repository:

YAML
name: Docker image

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Work out tags and labels
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/priya-sharma/shop-api
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Log in to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Run tests
        uses: docker/build-push-action@v7
        with:
          target: test
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What each part does

  • on runs the workflow for pushes to main, for tags starting with v, and for pull requests.
  • permissions gives the automatic GITHUB_TOKEN read access to the code and write access to packages, which is what GHCR needs. Nothing else is allowed.
  • docker/metadata-action works out image tags from what triggered the run, so you never type them by hand:
TriggerTags produced
Push to main at commit 4f2a9c1main, sha-4f2a9c1
Pull request number 42pr-42 (built, not pushed)
Git tag v2.1.02.1.0, 2.1, sha-... and latest

It also produces standard OCI labels such as the source repository and commit, which the last step adds to the image.

  • docker/setup-buildx-action prepares a BuildKit builder with features such as the GitHub Actions cache.
  • docker/login-action logs in to GHCR with the built-in GITHUB_TOKEN; no personal token is needed. It is skipped for pull requests, because code from a fork must never get write access to your registry.
  • docker/build-push-action builds the image. There is no checkout step: when no context is given, the action builds straight from the Git commit that triggered the run. The first use builds only the test stage. The second builds the final image and pushes it unless the run is for a pull request.
  • cache-from and cache-to: type=gha store BuildKit's layer cache in GitHub's cache service, so the next run reuses unchanged layers instead of starting from nothing on a fresh runner.

Running it

Commit the workflow and push. Open the Actions tab on GitHub to watch each step's log. When the run finishes, the image appears under Packages on your profile or organisation. To publish a release, tag a commit and push the tag:

Terminal
git tag -a v2.1.0 -m "Release 2.1.0"
git push origin v2.1.0

A few minutes later ghcr.io/priya-sharma/shop-api:2.1.0 is ready, and any server can pull it. To use Docker Hub instead, change the image name to priyasharma/shop-api, drop the registry line, and log in with your Docker Hub user name and a token stored as a repository secret, for example password: ${{ secrets.DOCKERHUB_TOKEN }}.

Adding a deploy job

Continuous deployment adds a job that runs after build, only for version tags. It connects to the server over SSH and tells Compose to pull and start the new version (the server side is set up in Deploy to a VPS with a Reverse Proxy):

YAML
deploy:
  needs: build
  if: startsWith(github.ref, 'refs/tags/v')
  runs-on: ubuntu-latest
  environment: production
  steps:
    - name: Deploy over SSH
      env:
        SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
      run: |
        mkdir -p ~/.ssh
        printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
        printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
        chmod 600 ~/.ssh/id_ed25519
        ssh deploy@shop.example.in "cd /srv/shop && ./deploy.sh ${GITHUB_REF_NAME#v}"

needs: build makes the job wait for a successful build. environment: production lets you require a manual approval in the repository settings before it runs. ${GITHUB_REF_NAME#v} turns the tag v2.1.0 into 2.1.0. The SSH key belongs to a deploy user on the server that can run Docker and nothing more, and known_hosts makes SSH refuse to connect if the server's identity ever changes.

Common mistakes

  • Missing packages: write. The push fails with "denied: installation not allowed to Create organization package" or a similar permission error.
  • Pushing from pull requests. Pull requests from forks can contain anyone's code. Build and test them, but only push from trusted branches and tags.
  • Uppercase letters in the GHCR image name. Write the owner in lowercase.
  • No build cache in CI. Every run starts on a fresh machine; add type=gha caching or builds are slow.
  • Deploying latest. Deploy the exact version tag the pipeline produced, so a rollback is just deploying the previous tag.

Key points

  • CI builds and tests every change; CD packages and releases tested versions automatically.
  • A Dockerfile test stage lets CI run tests inside the same environment the app ships in.
  • docker/metadata-action creates branch, PR, version and commit tags; docker/build-push-action builds, caches and pushes.
  • Use permissions: packages: write and the built-in GITHUB_TOKEN for GHCR, and never push from pull requests.
  • A deploy job with needs, a tag condition and an approval-protected environment can release versions to a server over SSH.

Exercise

Change the workflow so that it also runs every Monday at 03:30 UTC (to rebuild on patched base images), and so that the image name is ghcr.io/kabirmehta/fees-api. Then write the Git commands to release version 1.5.0.

Show solution
YAML
on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
  schedule:
    - cron: "30 3 * * 1"
YAML
with:
  images: ghcr.io/kabirmehta/fees-api
Terminal
git tag -a v1.5.0 -m "Release 1.5.0"
git push origin v1.5.0

The cron fields are minute, hour, day of month, month and day of week, and 1 is Monday. Scheduled runs build the default branch, so main is rebuilt and pushed with fresh base-image layers each week. Pushing the tag v1.5.0 produces the image tags 1.5.0, 1.5 and latest.

Quick Quiz

1. Why does the login step have if: github.event_name != 'pull_request'?

2. What does docker/metadata-action produce for the Git tag v2.1.0 with the tag rules shown?

3. Why add cache-from and cache-to with type=gha?

4. What does needs: build do in the deploy job?

Learn Docker & DevOps with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call