Key Takeaways
- The mental model: A workflow file has three levels: triggers (when to run), jobs (what machines to use), and steps (what commands to run). That’s the entire structure. Everything else is configuration.
- The standard pipeline:
test→build→docker-build→docker-push→deploy. Jobs run in parallel by default; addneeds: [job-name]to make them sequential. - Secrets management: Store passwords, tokens, and API keys in GitHub’s encrypted secrets (
Settings → Secrets and variables → Actions), reference them as${{ secrets.MY_SECRET }}. They’re masked in logs. - Sovereignty path: Replace GitHub-hosted runners with a self-hosted runner on your Ubuntu server, push Docker images to a private Gitea registry instead of Docker Hub, and deploy via SSH — removing all cloud dependencies from your build pipeline.
Introduction: GitHub Actions in 2026
Direct Answer: How do I build a CI/CD pipeline with GitHub Actions for Docker and Python in 2026?
To build a GitHub Actions CI/CD pipeline for a Docker and Python project, create .github/workflows/ci.yml in your repository root. Define a trigger (on: push: branches: [main]), a test job that uses ubuntu-24.04, checks out the code with actions/checkout@v4, sets up Python with actions/setup-python@v5, installs dependencies with pip install -r requirements.txt, and runs tests with pytest. Add a docker-build job that needs: [test], logs into Docker Hub or your private registry, builds the image with docker/build-push-action@v6, and pushes it tagged with the commit SHA. Add a deploy job that needs: [docker-build], SSHs into your Ubuntu server using appleboy/ssh-action@v1, and runs docker pull + docker compose up -d. Store credentials in secrets.DOCKER_USERNAME, secrets.DOCKER_PASSWORD, and secrets.SSH_PRIVATE_KEY. The complete workflow runs in approximately 3–8 minutes on GitHub-hosted runners or 1–3 minutes on a self-hosted runner.
“CI/CD is not about shipping faster. It’s about shipping confidently. The discipline of automated testing before every deployment is what separates teams that deploy fearlessly from teams that deploy anxiously.”
GitHub Actions is the most widely adopted CI/CD platform in 2026 — free for public repositories and 2,000 free minutes per month for private ones. This guide builds two complete workflows: a standard pipeline using GitHub-hosted runners, and a sovereign pipeline using a self-hosted runner on your own Ubuntu 24.04 server with a private Docker registry.
Prerequisites
- A GitHub repository with a Python project
- Docker installed on your deployment server (How to Install Docker on Ubuntu 24.04)
- Basic Git knowledge (Git Tutorial 2026)
- A Dockerfile in your project root (example provided in Step 2)
Part 1: Workflow Anatomy — The Three Levels
Every GitHub Actions workflow has the same three-level structure:
# .github/workflows/ci.yml
name: CI Pipeline # Display name in GitHub UI
# LEVEL 1: TRIGGERS — when this workflow runs
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * *' # Daily at 2 AM UTC
# LEVEL 2: JOBS — groups of steps that run on a machine
jobs:
test: # Job ID (used in 'needs:' by other jobs)
name: Run Tests # Display name
runs-on: ubuntu-24.04 # Machine to run on (GitHub-hosted or self-hosted)
# LEVEL 3: STEPS — individual commands
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/ -v
Key concepts:
uses:runs a pre-built Action from the GitHub Actions Marketplacerun:executes a shell commandwith:passes parameters to an Actionenv:sets environment variables for a step${{ secrets.NAME }}injects an encrypted secret${{ github.sha }}accesses the commit hash (one of many built-in context variables)
Part 2: A Real Python Project — The Test Subject
Create a minimal Python project to test the pipeline on:
# Create project structure
mkdir -p ~/github-actions-demo/{src,tests}
cd ~/github-actions-demo
# Main application
cat > src/calculator.py << 'EOF'
"""Simple calculator — CI/CD pipeline demo."""
def add(a: float, b: float) -> float:
return a + b
def subtract(a: float, b: float) -> float:
return a - b
def multiply(a: float, b: float) -> float:
return a * b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
EOF
# Tests
cat > tests/test_calculator.py << 'EOF'
import pytest
from src.calculator import add, subtract, multiply, divide
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0.1, 0.2) == pytest.approx(0.3)
def test_subtract():
assert subtract(5, 3) == 2
assert subtract(0, 5) == -5
def test_multiply():
assert multiply(3, 4) == 12
assert multiply(-2, 3) == -6
def test_divide():
assert divide(10, 2) == 5.0
assert divide(7, 2) == 3.5
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(5, 0)
EOF
# Requirements
cat > requirements.txt << 'EOF'
pytest>=8.0.0
pytest-cov>=5.0.0
EOF
# Dockerfile
cat > Dockerfile << 'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
CMD ["python", "-m", "src.calculator"]
EOF
# Create empty __init__ files
touch src/__init__.py tests/__init__.py
# Initialize Git
git init
git add .
git commit -m "feat: initial calculator app with tests"
Run tests locally first to confirm they pass:
pip install -r requirements.txt --break-system-packages 2>/dev/null || \
pip install -r requirements.txt
pytest tests/ -v
Expected output:
collected 5 items
tests/test_calculator.py::test_add PASSED
tests/test_calculator.py::test_subtract PASSED
tests/test_calculator.py::test_multiply PASSED
tests/test_calculator.py::test_divide PASSED
tests/test_calculator.py::test_divide_by_zero PASSED
============================== 5 passed in 0.12s ==============================
Part 3: Complete CI/CD Pipeline Workflow
Create the workflow file:
mkdir -p .github/workflows
cat > .github/workflows/ci.yml << 'EOF'
# .github/workflows/ci.yml
# Complete CI/CD pipeline: test → build → docker → deploy
# Vucense Dev Corner — tested April 2026
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
# Cancel in-progress runs when a new push arrives on the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── JOB 1: Lint and Test ────────────────────────────────────────────────
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-24.04
# Test against multiple Python versions simultaneously
strategy:
matrix:
python-version: ['3.11', '3.12']
fail-fast: false # Don't cancel other matrix jobs if one fails
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip' # Cache pip dependencies between runs
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with coverage
run: |
pytest tests/ \
--verbose \
--cov=src \
--cov-report=term-missing \
--cov-report=xml \
--cov-fail-under=80 # Fail if coverage drops below 80%
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ matrix.python-version }}
path: coverage.xml
retention-days: 7
# ── JOB 2: Build and Push Docker Image ─────────────────────────────────
docker-build:
name: Build Docker Image
runs-on: ubuntu-24.04
needs: [test] # Only runs if test job passes
# Only push on commits to main branch (not on PRs)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Docker Buildx (enables multi-platform builds and caching)
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags and labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USERNAME }}/calculator-app
tags: |
type=sha,prefix=sha- # Tag with git commit SHA
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # GitHub Actions cache
cache-to: type=gha,mode=max # Save to cache after build
# ── JOB 3: Deploy to Production ────────────────────────────────────────
deploy:
name: Deploy to Production
runs-on: ubuntu-24.04
needs: [docker-build] # Only runs after Docker image is pushed
environment: production # Requires manual approval if configured
steps:
- name: Deploy via SSH
uses: appleboy/[email protected]
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
# Pull the new image
docker pull ${{ secrets.DOCKER_USERNAME }}/calculator-app:latest
# Stop old container and start new one
docker stop calculator-app 2>/dev/null || true
docker rm calculator-app 2>/dev/null || true
docker run -d \
--name calculator-app \
--restart unless-stopped \
-p 127.0.0.1:8000:8000 \
${{ secrets.DOCKER_USERNAME }}/calculator-app:latest
# Verify deployment
sleep 3
docker ps | grep calculator-app
# Clean up old images
docker image prune -f
EOF
Commit and push to trigger the workflow:
git add .github/
git commit -m "ci: add complete CI/CD pipeline"
git push origin main
Expected GitHub Actions output (Jobs tab → CI/CD Pipeline):
✅ Test (Python 3.11) — 1m 23s
✅ Test (Python 3.12) — 1m 18s
✅ Build Docker Image — 2m 47s
✅ Deploy to Production — 0m 42s
Part 4: Managing Secrets
Before the docker-build and deploy jobs can run, add these secrets to your GitHub repository:
Navigate to: GitHub repo → Settings → Secrets and variables → Actions → New repository secret
| Secret name | Value | Used in |
|---|---|---|
DOCKER_USERNAME | Your Docker Hub username | docker-build job |
DOCKER_PASSWORD | Docker Hub access token (not password) | docker-build job |
DEPLOY_HOST | Your server’s IP or hostname | deploy job |
DEPLOY_USER | SSH username on your server | deploy job |
SSH_PRIVATE_KEY | Contents of ~/.ssh/id_ed25519 (private key) | deploy job |
Generate a Docker Hub access token (more secure than your password):
- Docker Hub → Account Settings → Security → New Access Token
- Name:
github-actions-<repo-name> - Copy the token — this is your
DOCKER_PASSWORDsecret
Generate a deployment SSH key:
# Generate a dedicated key for GitHub Actions (on your local machine)
ssh-keygen -t ed25519 -C "[email protected]" \
-f ~/.ssh/github_actions_deploy -N ""
# Add the public key to your server's authorized_keys
cat ~/.ssh/github_actions_deploy.pub | \
ssh youruser@YOUR_SERVER_IP "cat >> ~/.ssh/authorized_keys"
# The PRIVATE key goes in GitHub secrets
cat ~/.ssh/github_actions_deploy
# Copy this entire output (including -----BEGIN and END lines) as SSH_PRIVATE_KEY
Verify secrets work without exposing them:
# Add a test step to your workflow (temporarily)
- name: Verify secrets are available
run: |
echo "Docker user length: ${#DOCKER_USERNAME}" # Shows length, not value
echo "SSH key starts with: ${SSH_PRIVATE_KEY:0:30}..." # Shows prefix only
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
Expected output (secrets are masked in logs):
Docker user length: 12
SSH key starts with: -----BEGIN OPENSSH PRIVATE...
Part 5: Advanced Workflow Patterns
Conditional steps and jobs
# Run only on specific branches
- name: Deploy to staging
if: github.ref == 'refs/heads/develop'
run: ./deploy-staging.sh
# Run only when specific files change
on:
push:
paths:
- 'src/**'
- 'tests/**'
- 'requirements.txt'
paths-ignore:
- '**.md'
- 'docs/**'
Reusable workflow steps with composite actions
# .github/actions/setup-python-env/action.yml
name: 'Setup Python Environment'
description: 'Install Python and dependencies'
inputs:
python-version:
description: 'Python version'
required: true
default: '3.12'
runs:
using: 'composite'
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: 'pip'
- run: pip install -r requirements.txt
shell: bash
# Use it in any workflow
- name: Setup environment
uses: ./.github/actions/setup-python-env
with:
python-version: '3.12'
Matrix builds for multiple platforms
jobs:
test:
strategy:
matrix:
os: [ubuntu-24.04, macos-latest]
python: ['3.11', '3.12']
exclude:
- os: macos-latest
python: '3.11' # Skip this combination
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
Scheduled security scans
name: Security Scan
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6 AM UTC
push:
branches: [main]
jobs:
security:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner on Docker image
uses: aquasecurity/trivy-action@master
with:
image-ref: '${{ secrets.DOCKER_USERNAME }}/calculator-app:latest'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
Part 6: Self-Hosted Runner — The Sovereign Path
GitHub-hosted runners send your code and build artifacts through GitHub’s infrastructure. For maximum sovereignty — especially for private applications — run your own GitHub Actions runner on your Ubuntu 24.04 server.
Benefits of self-hosted runners:
- Unlimited free minutes (no 2,000/month limit)
- Access to your private Docker registry and internal services
- Builds run on your hardware — no code leaves your network
- Faster builds for large Docker images (no external push/pull latency)
Install the runner on Ubuntu 24.04
# On your Ubuntu server:
# Create a dedicated user for the runner (don't run as root)
sudo useradd -m -s /bin/bash github-runner
sudo usermod -aG docker github-runner # Allow Docker access
# Switch to the runner user
sudo -u github-runner -s
# Create the runner directory
mkdir -p ~/actions-runner && cd ~/actions-runner
# Download the runner (get the latest version from GitHub)
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.320.0/actions-runner-linux-x64-2.320.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
Get your repository token from GitHub:
GitHub repo → Settings → Actions → Runners → New self-hosted runner
Copy the token shown on that page (starts with AARTF...).
# Configure the runner (replace OWNER/REPO and TOKEN)
./config.sh \
--url https://github.com/OWNER/REPO \
--token YOUR_RUNNER_TOKEN \
--name "sovereign-ubuntu-server" \
--labels "self-hosted,ubuntu-24.04,sovereign" \
--work "_work" \
--unattended
Expected output:
--------------------------------------------------------------------------------
| ____ _ _ _ _ _ _ _ _ |
| / ___(_) |_| | | |_ _| |__ / \ ___| |_(_) ___ _ __ ___ |
| | | _| | __| |_| | | | | '_ \ / _ \ / __| __| |/ _ \| '_ \/ __| |
...
--------------------------------------------------------------------------------
✔ Connected to GitHub
Current runner version: '2.320.0'
2026-04-17 10:15:22Z: Listening for Jobs
# Install as a systemd service (runs on boot)
exit # Back to your admin user
sudo /home/github-runner/actions-runner/svc.sh install github-runner
sudo systemctl start actions-runner.service
sudo systemctl status actions-runner.service --no-pager | head -5
Expected output:
● actions-runner.service - GitHub Actions Runner (github-runner-OWNER-REPO-sovereign-ubuntu-server)
Loaded: loaded (/etc/systemd/system/actions-runner.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-04-17 10:16:00 UTC; 5s ago
Verify runner appears in GitHub:
GitHub repo → Settings → Actions → Runners
You should see sovereign-ubuntu-server with a green dot (online).
Update your workflow to use the self-hosted runner
jobs:
test:
runs-on: [self-hosted, ubuntu-24.04, sovereign] # Use your runner
steps:
- uses: actions/checkout@v4
# ... rest of steps unchanged
Self-hosted runner security considerations:
- Never use self-hosted runners for public repositories — this would allow anyone to run arbitrary code on your server via pull requests
- Keep the runner software updated:
./svc.sh stop && ./config.sh remove && # re-download and configure - The runner runs as a non-root user — grant Docker access via the
dockergroup (already done above)
Part 7: Docker Compose Deployment Workflow
For applications using Docker Compose (like the sovereign AI stack), the deployment step pulls a new image and recreates the service:
# .github/workflows/deploy-compose.yml
name: Deploy with Docker Compose
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Copy compose file to server
uses: appleboy/[email protected]
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "docker-compose.yml,.env.production"
target: "/opt/myapp/"
- name: Deploy with Docker Compose
uses: appleboy/[email protected]
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/myapp
# Pull latest images
docker compose pull
# Zero-downtime deployment:
# Scale up new containers, then scale down old ones
docker compose up -d --no-deps --scale app=2 app
sleep 10
docker compose up -d --no-deps --scale app=1 app
# Remove unused images
docker image prune -f
echo "Deployment complete: $(date)"
Part 8: The Sovereignty Layer — Self-Hosted CI Audit
echo "=== SOVEREIGN CI/CD AUDIT ==="
echo ""
echo "[ GitHub Actions runner status ]"
sudo systemctl is-active actions-runner.service 2>/dev/null && \
echo " ✓ Self-hosted runner active" || \
echo " ✗ Runner not active — code builds on GitHub cloud"
echo ""
echo "[ Runner user Docker access ]"
id github-runner | grep -q "docker" && \
echo " ✓ Runner has Docker group access" || \
echo " ✗ Runner cannot access Docker"
echo ""
echo "[ Runner work directory ]"
ls -la /home/github-runner/actions-runner/_work/ 2>/dev/null | head -3 | \
awk '{print " " $0}' || echo " ✗ No work directory found"
echo ""
echo "[ Private registry usage (sovereign builds) ]"
# Check if Docker compose files reference private registry instead of Docker Hub
grep -r "docker.io\|hub.docker.com" /opt/myapp/ 2>/dev/null && \
echo " ⚠ References to Docker Hub found — consider private registry" || \
echo " ✓ No Docker Hub references in deployment configs"
SovereignScore breakdown:
- GitHub-hosted runners + Docker Hub push: SovereignScore 62/100
- Self-hosted runners + private Gitea registry: SovereignScore 91/100
- This article’s default workflow: SovereignScore 74/100 (self-hosted runner available, Docker Hub used for registry)
Workflow Quick Reference
# ── TRIGGERS ────────────────────────────────────────────────────────────
on:
push: branches: [main]
pull_request: branches: [main]
schedule: - cron: '0 2 * * *'
workflow_dispatch: # Manual trigger button in UI
release: types: [published]
# ── CONTEXTS (built-in variables) ────────────────────────────────────────
${{ github.sha }} # Full commit SHA
${{ github.ref_name }} # Branch or tag name
${{ github.actor }} # Username who triggered the run
${{ github.repository }} # owner/repo
${{ github.event_name }} # push | pull_request | schedule
${{ runner.os }} # Linux | macOS | Windows
${{ job.status }} # success | failure | cancelled
# ── COMMON ACTIONS (2026 current versions) ───────────────────────────────
actions/checkout@v4
actions/setup-python@v5 with: { python-version: '3.12', cache: 'pip' }
actions/setup-node@v4 with: { node-version: '22', cache: 'npm' }
actions/upload-artifact@v4 with: { name: artifact, path: ./dist }
actions/download-artifact@v4 with: { name: artifact }
actions/cache@v4 with: { path: ~/.cache, key: ${{ hashFiles('requirements.txt') }} }
docker/setup-buildx-action@v3
docker/login-action@v3 with: { username, password }
docker/build-push-action@v6 with: { context: ., push: true, tags, cache-from }
appleboy/[email protected] with: { host, username, key, script }
# ── JOB DEPENDENCIES ────────────────────────────────────────────────────
jobs:
build:
needs: [test] # Runs after 'test' job succeeds
deploy:
needs: [test, build] # Runs after both succeed
if: success() # Only if all needs succeeded
# ── ENVIRONMENT VARIABLES ────────────────────────────────────────────────
env: # Workflow-level (available to all jobs)
NODE_ENV: production
jobs:
build:
env: # Job-level (available to all steps in job)
BUILD_ENV: ci
steps:
- name: Build
env: # Step-level (available only to this step)
SECRET: ${{ secrets.MY_SECRET }}
run: echo "Building..."
Troubleshooting
Error: Input required and not supplied: password during docker login
Cause: The secret DOCKER_PASSWORD is not set in repository secrets.
Fix: Go to Settings → Secrets → Actions, add DOCKER_PASSWORD with your Docker Hub access token (not your account password).
Host key verification failed in SSH deploy step
Cause: GitHub Actions runner doesn’t have the server’s host key in known_hosts.
Fix: Add known_hosts input to the SSH action:
- uses: appleboy/[email protected]
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} # Add this
Get the known_hosts value: ssh-keyscan YOUR_SERVER_IP 2>/dev/null
Self-hosted runner shows as offline in GitHub
Cause: The runner service stopped or the server rebooted without the service enabled. Fix:
sudo systemctl enable actions-runner.service
sudo systemctl start actions-runner.service
sudo systemctl status actions-runner.service
Workflow cancels mid-run with no error message
Cause: The concurrency group is cancelling in-progress runs when a new push arrives.
Fix: Remove or adjust the concurrency block, or set cancel-in-progress: false for deploy jobs.
Conclusion
You now have a complete GitHub Actions CI/CD pipeline: tests run on every push and pull request across multiple Python versions, Docker images are built and tagged with the commit SHA on every merge to main, and deployments happen automatically via SSH. The self-hosted runner moves builds onto your own hardware — keeping code local, eliminating runner minute costs, and giving the pipeline access to your private services.
The next build on this foundation is How to Self-Host Gitea with CI/CD Runners on Ubuntu 24.04 — replacing GitHub entirely with a sovereign Gitea instance and Gitea Actions for a fully self-hosted development pipeline.
People Also Ask: GitHub Actions FAQ
How do I pass data between jobs in GitHub Actions?
Use outputs to pass small values between jobs, and actions/upload-artifact + actions/download-artifact for larger files. For outputs:
jobs:
build:
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- id: meta
run: echo "version=1.2.3" >> $GITHUB_OUTPUT
deploy:
needs: [build]
steps:
- run: echo "Deploying ${{ needs.build.outputs.image-tag }}"
How many GitHub Actions minutes do I get for free?
Free accounts get 2,000 minutes/month for private repositories (public repos are unlimited). Minutes are multiplied by the OS factor: Linux = 1×, macOS = 10×, Windows = 2×. So 2,000 minutes of Linux CI = 200 minutes of macOS CI. Self-hosted runners consume zero GitHub minutes — they’re unlimited and free. For teams exceeding the free tier, GitHub Team costs $4/user/month and includes 3,000 minutes.
Should I use GitHub Actions or GitLab CI for CI/CD?
GitHub Actions has a larger ecosystem of pre-built Actions (the Marketplace has 20,000+ actions) and tighter integration with GitHub’s code review and security features. GitLab CI has better native support for self-hosted pipelines, more granular YAML controls, and built-in container registry. For teams already using GitHub, GitHub Actions is the obvious choice. For teams prioritising full self-hosting (code hosting + CI + registry in one platform), GitLab CE or a self-hosted Gitea/Forgejo with Gitea Actions is more coherent. Both support self-hosted runners.
How do I debug a failing GitHub Actions workflow?
Enable debug logging by adding repository secrets ACTIONS_RUNNER_DEBUG = true and ACTIONS_STEP_DEBUG = true — this prints verbose output for every step. For persistent debugging, add a tmate step to get an SSH session into the runner mid-workflow:
- name: Debug with tmate
uses: mxschmitt/action-tmate@v3
if: failure() # Only opens SSH session if previous step failed
Further Reading
- Git Tutorial 2026: Complete Guide with Branching & Merging — the version control foundation for CI/CD
- How to Install Docker on Ubuntu 24.04 LTS — the container platform GitHub Actions deploys to
- Self-Hosted Gitea CI/CD: Replace GitHub Entirely — sovereign alternative to GitHub Actions
- Ubuntu 24.04 LTS Server Setup Checklist — secure the server your self-hosted runner lives on
- Official GitHub Actions Documentation — complete reference
Tested on: Ubuntu 24.04 LTS self-hosted runner (Hetzner CX22), GitHub-hosted ubuntu-24.04 runner. GitHub Actions runner v2.320.0. Last verified: April 17, 2026. Report a broken snippet if an action version becomes deprecated.