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.
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.
- Go to hub.docker.com and create a free account
- Go to Account Settings > Security > New Access Token
- Create a token with Read/Write permissions and copy it
- In your GitHub repo, go to Settings > Secrets and variables > Actions > New repository secret
- Add two secrets:
DOCKERHUB_USERNAME— your Docker Hub usernameDOCKERHUB_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 buildmain— the branch namesha-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.
- Go to Settings > Branches > Add rule
- Set Branch name pattern to
main - Enable Require status checks to pass before merging
- Select the
Run Testsjob as a required check - 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.