Vucense

GitHub Actions Tutorial 2026: Build CI/CD Pipelines for Docker & Python

🟡Intermediate

Build production CI/CD pipelines with GitHub Actions in 2026. Covers workflows, triggers, Docker builds, Python testing, secrets management, deployment to Ubuntu servers, and self-hosted runners.

Sarah Jenkins

Author

Sarah Jenkins

Open-Source Community & Ecosystem Lead

Published

Duration

Reading

18 min

Build

25 min

GitHub Actions Tutorial 2026: Build CI/CD Pipelines for Docker & Python
Article Roadmap

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: testbuilddocker-builddocker-pushdeploy. Jobs run in parallel by default; add needs: [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


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 Marketplace
  • run: executes a shell command
  • with: passes parameters to an Action
  • env: 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 nameValueUsed in
DOCKER_USERNAMEYour Docker Hub usernamedocker-build job
DOCKER_PASSWORDDocker Hub access token (not password)docker-build job
DEPLOY_HOSTYour server’s IP or hostnamedeploy job
DEPLOY_USERSSH username on your serverdeploy job
SSH_PRIVATE_KEYContents of ~/.ssh/id_ed25519 (private key)deploy job

Generate a Docker Hub access token (more secure than your password):

  1. Docker Hub → Account Settings → Security → New Access Token
  2. Name: github-actions-<repo-name>
  3. Copy the token — this is your DOCKER_PASSWORD secret

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 docker group (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


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.

Further Reading

All Dev Corner

Comments