You can build and push images manually. But you won’t. You’ll forget, you’ll make mistakes, you’ll skip it when you’re tired.
CI/CD means: push code, image builds automatically. Every time. No manual steps. No forgetting.
This post sets up automated pipelines using GitHub Actions or Gitea Actions. Push code, get a fresh container image in your registry.
DevOps Skills Demonstrated
- CI/CD pipeline design and implementation
- GitHub Actions and Gitea Actions workflows
- Automated testing and build processes
- Docker image build automation
Market Value: CI/CD expertise is the core of DevOps and is essential for roles at £60-80k+
What You’ll Learn
- What CI/CD actually means
- GitHub Actions basics
- Gitea Actions (self-hosted alternative)
- Building and pushing Docker images automatically
- Testing in the pipeline
What Is CI/CD?
CI: Continuous Integration
Every code push triggers:
- Build the project
- Run tests
- Catch problems early
CD: Continuous Delivery/Deployment
After CI passes:
- Build container image
- Push to registry
- (Optionally) Deploy automatically
Why It Matters
| Manual Process | Automated Pipeline |
|---|---|
| Forget to build | Builds every push |
| Version mismatch | Tagged automatically |
| “Works on my machine” | Same build environment every time |
| Deploy old code accidentally | Always latest build |
GitHub Actions
Hour 0-1: How It Works
- Create
.github/workflows/build.ymlin your repo - Define triggers (on push, on PR, etc.)
- Define jobs (steps to run)
- GitHub runs it on their infrastructure
Basic Workflow Structure
name: Build and Push
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Do something
run: echo "Hello, CI/CD!"
Hour 1-3: Docker Build Workflow
Create .github/workflows/docker.yml:
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: registry.yourdomain.com
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_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,prefix=
- 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
Setting Up Secrets
In GitHub: Settings > Secrets and variables > Actions > New repository secret
Add:
REGISTRY_USERNAME– Your registry usernameREGISTRY_PASSWORD– Your registry password or token
What This Does
- Triggers on push to main or version tags
- Logs into your private registry
- Builds the Docker image
- Tags based on branch/version/commit
- Pushes to your registry
- Caches layers for faster builds
Gitea Actions (Self-Hosted Alternative)
If you use Gitea and want fully self-hosted CI/CD:
Enable Actions in Gitea
In app.ini:
[actions]
ENABLED = true
Set Up a Runner
You need a runner (machine that executes jobs):
# Download act_runner
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64
chmod +x act_runner-linux-amd64
# Register runner
./act_runner-linux-amd64 register --instance https://git.yourdomain.com
# Run (or set up as service)
./act_runner-linux-amd64 daemon
Gitea Actions Workflow
Create .gitea/workflows/build.yml (note: .gitea not .github):
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.yourdomain.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and Push
run: |
docker build -t registry.yourdomain.com/${{ gitea.repository }}:latest .
docker build -t registry.yourdomain.com/${{ gitea.repository }}:${{ gitea.sha }} .
docker push registry.yourdomain.com/${{ gitea.repository }}:latest
docker push registry.yourdomain.com/${{ gitea.repository }}:${{ gitea.sha }}
Secrets in Gitea
Repository > Settings > Actions > Secrets
Add your registry credentials.
Adding Tests
Hour 3-4: Run Tests Before Building
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup 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
build:
needs: test # Only run if tests pass
runs-on: ubuntu-latest
steps:
# ... build steps
Basic Test Script
Add to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
Even simple tests catch obvious problems.
Workflow Triggers
On Push
on:
push:
branches: [main, develop]
On Tags (Releases)
on:
push:
tags: ['v*']
On Pull Request
on:
pull_request:
branches: [main]
Manual Trigger
on:
workflow_dispatch:
inputs:
version:
description: 'Version tag'
required: true
Image Tagging Strategies
Automatic Tags with metadata-action
The docker/metadata-action creates smart tags:
tags: |
type=ref,event=branch # Branch name (main, develop)
type=semver,pattern={{version}} # v1.0.0 → 1.0.0
type=sha,prefix= # Commit SHA
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
Result for push to main:
registry/user/app:mainregistry/user/app:sha-abc1234registry/user/app:latest
Result for tag v1.2.3:
registry/user/app:1.2.3registry/user/app:sha-abc1234
Caching for Speed
Layer Caching
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
First build: ~3-5 minutes. Cached builds: ~30-60 seconds.
npm Cache
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
Caches node_modules between runs.
Debugging Failures
View Logs:
GitHub: Actions tab > Click workflow run > Click job > Expand steps
Common Issues:
“denied: requested access to the resource is denied”
- Check secrets are set correctly
- Check registry URL matches
- Check username/password
Build fails but works locally:
- Check Dockerfile doesn’t assume local files
- Check all needed files are committed
- Check
.dockerignoreisn’t excluding needed files
Tests fail:
- Check test command in package.json
- Tests might need environment variables
Run Locally:
Test GitHub Actions locally with act:
brew install act # or equivalent
act -j build
Real Example: Complete CI/CD Workflow
Full workflow with tests – .github/workflows/ci.yml:
name: CI/CD Pipeline
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
REGISTRY: registry.yourdomain.com
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint --if-present
- name: Run tests
run: npm test --if-present
build:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
What You’ve Built
Your workflow is now:
Push code > Tests run > Image builds > Pushed to registry
Automatically. Every time. No manual steps.
How to Talk About This in Interviews
On your resume:
- “CI/CD pipeline design with GitHub Actions”
- “Automated Docker build and push workflows”
- “Build caching and optimization strategies”
In interviews:
“I’ve implemented CI/CD pipelines that automatically test, build, and push Docker images on every commit. I use GitHub Actions with layer caching to keep build times under a minute. Tests run first, and builds only proceed on success. Images are tagged with both version numbers and commit SHAs for traceability.”
The Journey So Far
- Step 1: Built app with AI tools
- Step 2: Set up local development environment
- Step 3: Containerize with Docker
- Step 4: Push to private registry
- Step 5: Automate with CI/CD (You are here)
- Step 6: Deploy to your infrastructure
Practical Exercise
Today:
- Create
.github/workflows/docker.yml - Add registry secrets in GitHub
- Push a commit to main
- Watch Actions tab for build
- Verify new image in registry
Verification:
- Green checkmark in GitHub Actions
- New image tag in your registry
- No manual
docker buildneeded
Automation isn’t laziness. It’s reliability. The machine won’t forget to build.

ReadTheManual is run, written and curated by Eric Lonsdale.
Eric has over 20 years of professional experience in IT infrastructure, cloud architecture, and cybersecurity, but started with PCs long before that.
He built his first machine from parts bought off tables at the local college campus, hoping they worked. He learned on BBC Micros and Atari units in the early 90s, and has built almost every PC he’s used between 1995 and now.
From helpdesk to infrastructure architect, Eric has worked across enterprise datacentres, Azure environments, and security operations. He’s managed teams, trained engineers, and spent two decades solving the problems this site teaches you to solve.
ReadTheManual exists because Eric believes the best way to learn IT is to build things, break things, and actually read the manual. Every guide on this site runs on infrastructure he owns and maintains.
Enjoyed this guide?
New articles on Linux, homelab, cloud, and automation every 2 days. No spam, unsubscribe anytime.

