DevOpsLesson
DOCKERKUBERNETESTERRAFORMAWSCI/CDLINUXGITDEVOPS ROADMAPCLOUD ROADMAPSRE ROADMAPGIT CHEATSHEETDOCKER CHEATSHEETK8S CHEATSHEETTF CHEATSHEETLINUX CHEATSHEETDOCKERFILE LINTERYAML VALIDATORCRON PARSERREGEX TESTER
DevOpsLesson

Free, comprehensive DevOps tutorials and learning roadmaps. Master Docker, Kubernetes, CI/CD, and more.

Stay Updated

Get notified about new tutorials and features.

Tutorials

  • What is DevOps?
  • Docker Tutorial
  • Terraform Tutorial
  • CI/CD Pipeline
  • All Tutorials

Roadmaps

  • DevOps Engineer
  • Cloud Engineer
  • SRE Path
  • All Roadmaps

Company

  • About Us
  • Blog
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 DevOpsLesson. All rights reserved.

Tools

YAML ValidatorDockerfile LinterCron ParserRegex Tester

Tutorials

DockerKubernetesTerraformCI/CDLinux

Roadmaps

DevOps EngineerDockerCKA Exam

How to Build a CI/CD Pipeline with GitHub Actions and Docker (2026)

A practical, step-by-step guide to building a production-ready CI/CD pipeline using GitHub Actions and Docker. Covers building images, pushing to a registry, running tests, and deploying automatically on every push.

V
Vishvesh Patel
DevOps Engineer
July 15, 20269 min read
How to Build a CI/CD Pipeline with GitHub Actions and Docker (2026)

Every time you push code, something should happen automatically. Build it. Test it. Package it. Deploy it. That is what a CI/CD pipeline does, and GitHub Actions is one of the best tools to build one.

This guide walks you through building a complete pipeline from scratch: a Dockerfile, a GitHub Actions workflow that builds and tests your image, pushes it to a registry, and deploys it on every push to main.

No prior GitHub Actions experience required. Basic Docker knowledge assumed.


What we will build

By the end of this guide you will have:

  • A containerised application with a Dockerfile
  • A GitHub Actions workflow that runs on every push to main
  • Automated steps: lint, test, build Docker image, push to registry
  • A deployment step that rolls out the new image automatically
  • Proper secrets management (no credentials in code)

Prerequisites

  • A GitHub account
  • Docker installed locally
  • Basic familiarity with Dockerfiles (FROM, RUN, COPY, CMD)
  • An application to containerise (we'll use a simple Node.js app)

Step 1: Create your application and Dockerfile

Start with a minimal Node.js application. Create a new directory and add these files:

app.js

const http = require('http');

const PORT = process.env.PORT || 3000;

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ status: 'ok', message: 'Hello from DevOps Lesson' }));
});

server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

package.json

{
  "name": "cicd-demo",
  "version": "1.0.0",
  "scripts": {
    "start": "node app.js",
    "test": "echo 'Tests passed' && exit 0"
  }
}

Dockerfile

FROM node:20-alpine

# Create non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy dependency files first (layer caching)
COPY package*.json ./
RUN npm ci --only=production

# Copy application source
COPY . .

# Switch to non-root user
USER appuser

EXPOSE 3000
CMD ["node", "app.js"]

.dockerignore

node_modules
.git
.github
*.md

Test it locally before setting up CI/CD:

docker build -t cicd-demo:local .
docker run -p 3000:3000 cicd-demo:local
curl http://localhost:3000

You should see {"status":"ok","message":"Hello from DevOps Lesson"}.


Step 2: Push to GitHub

Create a new GitHub repository and push your code:

git init
git add .
git commit -m "Initial commit: app + Dockerfile"
git remote add origin https://github.com/YOUR_USERNAME/cicd-demo.git
git push -u origin main

Step 3: Understand GitHub Actions basics

GitHub Actions workflows live in .github/workflows/ in your repository. Each workflow is a YAML file that defines:

  • Triggers (on): what events cause the workflow to run (push, pull_request, schedule)
  • Jobs: groups of steps that run on the same runner
  • Steps: individual commands or actions within a job
  • Runners: the virtual machines that run your jobs (Ubuntu, macOS, Windows)

A minimal workflow structure:

name: My Workflow

on:
  push:
    branches: [main]

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

      - name: Run a command
        run: echo "Hello from GitHub Actions"

Step 4: Add Docker Hub credentials as secrets

Your pipeline will push Docker images to Docker Hub. Store your credentials as secrets — never in the workflow YAML file.

  1. Go to hub.docker.com and create a free account
  2. Go to Account Settings > Security > New Access Token
  3. Create a token with Read/Write permissions and copy it
  4. In your GitHub repo, go to Settings > Secrets and variables > Actions > New repository secret
  5. Add two secrets:
    • DOCKERHUB_USERNAME — your Docker Hub username
    • DOCKERHUB_TOKEN — the access token you just created

Step 5: Create the CI/CD workflow

Create the file .github/workflows/ci-cd.yml:

name: CI/CD Pipeline

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

env:
  IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/cicd-demo

jobs:
  # ─── Job 1: Test ─────────────────────────────────────────────────────────────
  test:
    name: Run Tests
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

  # ─── Job 2: Build and Push Docker Image ──────────────────────────────────────
  build-push:
    name: Build and Push Image
    runs-on: ubuntu-latest
    needs: test  # only runs if test job passes
    # Only push on main branch, not on PRs
    if: github.ref == 'refs/heads/main'

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}

    steps:
      - name: Check out 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.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=sha,prefix=sha-
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ─── Job 3: Deploy ───────────────────────────────────────────────────────────
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: build-push
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/cicd-demo:latest
            docker stop cicd-demo || true
            docker rm cicd-demo || true
            docker run -d \
              --name cicd-demo \
              --restart unless-stopped \
              -p 3000:3000 \
              ${{ secrets.DOCKERHUB_USERNAME }}/cicd-demo:latest
            echo "Deployment complete"

Step 6: Understanding the pipeline

Let's break down what each part does.

The on trigger

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

The test job runs on both pushes and pull requests. The build-push and deploy jobs only run on pushes to main. This means:

  • When you open a PR: tests run automatically, giving reviewers confidence
  • When you merge to main: the full pipeline runs, including deployment

Job dependencies with needs

build-push:
  needs: test

The needs keyword creates a dependency chain. build-push only starts if test passes. deploy only starts if build-push passes. If any job fails, downstream jobs are skipped automatically.

Docker layer caching

cache-from: type=gha
cache-to: type=gha,mode=max

This caches Docker build layers in GitHub's cache. On subsequent runs, unchanged layers are reused, cutting build times significantly. A 3-minute build can become 30 seconds.

Image tagging strategy

The metadata-action automatically generates tags:

  • latest — always points to the newest main build
  • main — the branch name
  • sha-abc1234 — the exact git commit SHA for traceability

In production, deploy with the SHA tag, not latest. This gives you exact reproducibility and easy rollbacks.

The deploy job (optional for now)

The deploy step is included to show the pattern, but it requires adding three more secrets: DEPLOY_HOST, DEPLOY_USER, and DEPLOY_SSH_KEY. Skip this step initially and focus on getting the test + build pipeline working first.


Step 7: Trigger your first pipeline run

Push the workflow file:

git add .github/
git commit -m "Add CI/CD pipeline"
git push origin main

Go to Actions tab in your GitHub repo. You should see the workflow running. Click into it to watch the steps execute in real time.

A successful first run will show:

  • ✅ Test job: installs deps, runs tests
  • ✅ Build-push job: builds the Docker image, pushes to Docker Hub
  • ⚡ Deploy job: skipped until you add the deploy secrets

Step 8: Testing pull requests

Create a new branch and make a change:

git checkout -b feature/update-message
# Edit app.js to change the message
git add .
git commit -m "Update response message"
git push origin feature/update-message

Open a pull request on GitHub. You will see the CI checks running automatically at the bottom of the PR. The build-push job does not run (it is scoped to main), but the tests do — giving reviewers confidence the PR doesn't break anything before merging.


Step 9: Add branch protection rules

Make your pipeline meaningful by requiring it to pass before merges are allowed.

  1. Go to Settings > Branches > Add rule
  2. Set Branch name pattern to main
  3. Enable Require status checks to pass before merging
  4. Select the Run Tests job as a required check
  5. Enable Require branches to be up to date before merging

Now nobody can merge broken code into main.


Step 10: Extending the pipeline

Once the basics work, common extensions include:

Container security scanning

- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ${{ env.IMAGE_NAME }}:latest
    format: 'sarif'
    output: 'trivy-results.sarif'
    exit-code: '1'
    severity: 'CRITICAL'

Dockerfile linting

- name: Lint Dockerfile with Hadolint
  uses: hadolint/hadolint-action@v3.1.0
  with:
    dockerfile: Dockerfile

Deploy to Kubernetes

- name: Update Kubernetes deployment
  run: |
    kubectl set image deployment/cicd-demo \
      cicd-demo=${{ env.IMAGE_NAME }}:sha-${{ github.sha }} \
      --namespace=production

Notify on failure

- name: Send Slack notification on failure
  if: failure()
  uses: 8398a7/action-slack@v3
  with:
    status: failure
    webhook_url: ${{ secrets.SLACK_WEBHOOK }}

Common mistakes and how to fix them

"Permission denied" when pushing to Docker Hub

Check that your secret names match exactly: DOCKERHUB_USERNAME and DOCKERHUB_TOKEN. Secret names are case-sensitive. Also verify your Docker Hub token has Write permissions, not Read-only.

Build succeeds locally but fails in CI

The most common cause is environment differences. Your local machine might have a dependency cached that CI doesn't. Add explicit version pins to your package.json and use npm ci (not npm install) which uses the lockfile exactly.

Workflow doesn't trigger

Make sure the workflow file is in .github/workflows/ (with the dot) and has a .yml extension, not .yaml.txt or similar. Verify the branches list in your on section includes the branch you're pushing to.

Deploy job runs on PRs

The if: github.ref == 'refs/heads/main' condition prevents this. Make sure you have that condition on the build-push and deploy jobs.


The full workflow at a glance

Push to main branch
        │
        ▼
   [Test Job]
   npm ci + npm test
        │
        ├── FAIL → Pipeline stops, no deploy
        │
        ▼ PASS
   [Build-Push Job]
   docker build + push to registry
        │
        ▼
   [Deploy Job]
   SSH to server, pull latest image, restart container
        │
        ▼
   Production updated ✅

Every step is automated, logged, and auditable. Every deployment is traceable to a git commit. Every PR is checked before it merges. That is a production-grade CI/CD pipeline.


Next steps

  • Add the deploy step to a real server (AWS EC2 tutorial)
  • Switch from Docker Hub to Amazon ECR
  • Deploy to Kubernetes instead of a single Docker host (Kubernetes tutorials)
  • Add Terraform to provision your infrastructure automatically (Terraform tutorials)
  • Explore the full CI/CD tutorial series

Frequently asked questions

What is a CI/CD pipeline?

A CI/CD pipeline is an automated sequence of steps that runs whenever code is pushed to a repository. CI (Continuous Integration) runs tests and builds automatically. CD (Continuous Delivery/Deployment) automatically deploys passing builds to staging or production.

Is GitHub Actions free to use?

GitHub Actions is free for public repositories with no limits. For private repositories, the free tier includes 2,000 minutes per month. Most small teams and personal projects stay well within the free tier.

Do I need to know Docker before learning GitHub Actions?

Yes — you should understand the basics of Docker (building images, running containers, using a Dockerfile) before building a CI/CD pipeline that uses Docker. The two skills complement each other directly.

What is the difference between GitHub Actions and GitLab CI?

Both are CI/CD platforms built into their respective Git hosting services. GitHub Actions uses YAML workflow files in .github/workflows/. GitLab CI uses a .gitlab-ci.yml file at the repo root. The concepts are nearly identical — jobs, stages, runners, artifacts — but the syntax differs slightly.

How do I store secrets safely in GitHub Actions?

Never hardcode credentials in your workflow files. Store them in Settings > Secrets and variables > Actions in your GitHub repo. Reference them in YAML as ${{ secrets.MY_SECRET }}. GitHub masks these values in logs automatically.

Back to all posts
Latest Articles
Top 10 DevOps Projects for Your Resume (Beginner to Advanced)
July 15, 2026 · 10 min read
Kubernetes vs Docker: What's the Difference and When to Use Each
July 15, 2026 · 9 min read
DevOps Roadmap 2026: Step-by-Step Guide for Beginners
July 15, 2026 · 10 min read
How to Use Claude to Find Jobs: Scrape LinkedIn with Apify (2026 Guide)
June 19, 2026 · 8 min read
Share this post