June 11, 2026

From Code to Cloud: A Beginner’s Guide to CI/CD with GitHub Actions

From Code to Cloud: A Beginner’s Guide to CI/CD with GitHub Actions
Follow along with a real project

AppFlow Tracker is an open-source Django + React application. Every code snippet and workflow file in this article is live and working in the repository. You can run the app right now — no setup required.

Table of contents
  • Run the App First
  • What Is GitHub Actions?
  • Four Concepts You Must Know
  • Workflow 1 — PR Tests (CI)
  • Workflow 2 — Docker Hub Publish (CD)
  • Secrets — Never Hardcode Passwords
  • Caching — Making Pipelines Fast
  • The Full Picture

  • 1. Run the App First

    Before we talk about automation, let’s get the app running. You have two options.

    Option A — Pull the Docker images (fastest, no code needed)

    You need Docker Desktop installed.

    bash
    # 1. Pull both images from Docker Hub
    docker pull kennjenga/appflow-tracker-backend:latest
    docker pull kennjenga/appflow-tracker-frontend:latest

    Then create a docker-compose.yml file anywhere on your machine:

    yaml
    services:
    backend:
    image: kennjenga/appflow-tracker-backend:latest
    environment:
    SECRET_KEY: local-demo-only
    DEBUG: "True"
    ALLOWED_HOSTS: localhost,127.0.0.1
    CORS_ALLOWED_ORIGINS: http://localhost,http://127.0.0.1
    DJANGO_DB_PATH: /app/db/db.sqlite3
    volumes:
    - sqlite_data:/app/db
    expose:
    - "8000"
    frontend:
    image: kennjenga/appflow-tracker-frontend:latest
    ports:
    - "80:80"
    depends_on:
    - backend
    volumes:
    sqlite_data:

    Start the app:

    bash
    # 2. Start the app
    docker compose up
    # 3. Open your browser
    # App: http://localhost
    # API docs: http://localhost/api/v1/docs
    # Admin: http://localhost/admin
    Option B — Clone the repository and run locally
    bash
    # 1. Clone
    git clone https://github.com/Kennjenga/appflow-tracker.git
    cd appflow-tracker
    # 2. Copy the Docker env file
    cp .env.docker.example .env.docker
    # 3. Build and start (first run downloads base images + installs deps)
    docker compose up --build
    # 4. Create an admin user (first run only)
    docker compose exec backend python manage.py createsuperuser

    Same URLs apply: http://localhost for the app, and http://localhost/admin for the Django admin.

    What just happened?

    Docker Compose built two containers from the source code:

  • One running Django via Gunicorn
  • One running the built React app via Nginx
  • It also connected them on a private network.

    The GitHub Actions workflows we’re about to study are what built those Docker images and pushed them to Docker Hub automatically.


    2. What Is GitHub Actions?

    GitHub Actions is an automation platform built directly into GitHub.

    You write a YAML file, put it in a special folder in your repository, and GitHub automatically runs it whenever something happens:

  • A push
  • A pull request
  • A schedule
  • A manual button click
  • No extra accounts. No separate CI/CD server. It’s already there.

    Where the files live
    javascript
    your-project/
    ├── src/
    ├── tests/
    └── .github/
    └── workflows/
    ├── pr-tests.yml ← runs on every Pull Request
    └── docker-publish.yml ← runs on every push to master

    Two YAML files can be enough for two fully automated pipelines.

    Why use it over alternatives?
    ❌ Unsupported block (table)
    3. Four Concepts You Must Know

    Everything in GitHub Actions is built from four building blocks:

    EVENT → WORKFLOW → JOB → STEP

    Event — the trigger

    An event is what kicks the workflow off. You choose which events to listen for.

    yaml
    on:
    push: # Code was pushed to the repo
    branches:
    - master
    pull_request: # A PR was opened or updated
    branches:
    - master
    - staging
    schedule: # Runs on a timer (cron syntax)
    - cron: '0 2 * * *' # Every day at 2 AM UTC
    workflow_dispatch: # Manual "Run workflow" button in GitHub UI
    Workflow — the script

    One YAML file equals one workflow. A workflow contains one or more jobs.

    Job — a group of steps

    Each job runs on its own fresh virtual machine (a Runner). Jobs run in parallel by default. Use needs: to chain them.

    yaml
    jobs:
    test:
    runs-on: ubuntu-latest
    steps: [...]
    build:
    runs-on: ubuntu-latest
    needs: test
    steps: [...]
    deploy:
    runs-on: ubuntu-latest
    needs: build
    steps: [...]
    Step — a single task

    A step is one unit of work inside a job.

  • A shell command: run:
  • A pre-built action: uses:
  • yaml
    steps:
    - name: Checkout code
    uses: actions/checkout@v4
    - name: Install dependencies
    run: pip install -r requirements.txt
    - name: Run tests
    run: pytest
    env:
    SECRET_KEY: $ secrets.SECRET_KEY

    4. Workflow 1 — PR Tests (CI)

    File: .github/workflows/pr-tests.yml

    Purpose: Every time a developer opens or updates a Pull Request targeting master or staging, this pipeline automatically runs the full test suite. If any test fails, the PR is blocked from merging.

    This is Continuous Integration (CI): automatically validating every code change before it touches a protected branch.

    The full file
    yaml
    # ════════════════════════════════════════════════════════════════════════════
    # CI Pipeline — Runs on every Pull Request targeting master or staging
    #
    # What this pipeline does:
    # Job 1 (backend-tests) → Install Python deps → Run pytest
    # Job 2 (frontend-tests) → Install Node deps → Run Vitest → Vite build
    #
    # Both jobs run in PARALLEL (no `needs:` between them).
    # The PR is only considered green when BOTH jobs pass. ✅
    # ════════════════════════════════════════════════════════════════════════════
    name: PR Tests
    # ── TRIGGER ─────────────────────────────────────────────────────────────────
    on:
    pull_request:
    branches:
    - master
    - staging
    # ── JOBS ─────────────────────────────────────────────────────────────────────
    jobs:
    backend-tests:
    name: 🐍 Backend — pytest
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: backend
    steps:
    - name: Checkout repository
    uses: actions/checkout@v4
    - name: Set up Python 3.12
    uses: actions/setup-python@v5
    with:
    python-version: '3.12'
    - name: Cache pip dependencies
    uses: actions/cache@v4
    with:
    path: ~/.cache/pip
    key: pip-$ runner.os -$ hashFiles('**/requirements.txt')
    restore-keys: |
    pip-$ runner.os -
    - name: Install dependencies
    run: pip install -r requirements.txt
    - name: Django system check
    run: python manage.py check
    env:
    SECRET_KEY: ci-only-not-a-real-secret
    DEBUG: "True"
    - name: Run pytest
    run: pytest --tb=short -v
    env:
    SECRET_KEY: ci-only-not-a-real-secret
    DEBUG: "True"
    frontend-tests:
    name: ⚛️ Frontend — Vitest + Build
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: frontend
    steps:
    - name: Checkout repository
    uses: actions/checkout@v4
    - name: Set up Node.js 20
    uses: actions/setup-node@v4
    with:
    node-version: '20'
    - name: Cache node_modules
    uses: actions/cache@v4
    with:
    path: frontend/node_modules
    key: node-$ runner.os -$ hashFiles('**/package-lock.json')
    restore-keys: |
    node-$ runner.os -
    - name: Install dependencies
    run: npm ci
    - name: Run Vitest tests
    run: npm run test
    - name: Production build check
    run: npm run build
    env:
    VITE_API_BASE_URL: http://localhost:8000/api/v1
    How this protects your repository
    javascript
    Developer opens a PR → master or staging
    [Event: pull_request fires]
    ┌───────────────────┐ ┌───────────────────────┐
    │ 🐍 backend-tests │ │ ⚛️ frontend-tests │ ← run in PARALLEL
    1. Checkout code │ │ 1. Checkout code │
    2. Setup Python │ │ 2. Setup Node 20
    3. Cache pip │ │ 3. Cache node_modules │
    4. pip install │ │ 4. npm ci │
    5. Django check │ │ 5. npm run test │
    6. pytest │ │ 6. npm run build │
    └────────┬──────────┘ └──────────┬────────────┘
    │ both must pass ✅ │
    └──────────┬──────────────┘
    PR gets a green checkmark → team can safely merge

    5. Workflow 2 — Docker Hub Publish (CD)

    File: .github/workflows/docker-publish.yml

    Purpose: Every time code is merged into master, this pipeline builds Docker images for the backend and frontend, and pushes them to Docker Hub.

    This is Continuous Deployment (CD): automatically shipping a new version after every passing merge.

    The full file
    yaml
    name: Build & Push to Docker Hub
    on:
    push:
    branches:
    - master
    workflow_dispatch:
    env:
    REGISTRY: docker.io
    BACKEND_IMAGE: kennjenga/appflow-tracker-backend
    FRONTEND_IMAGE: kennjenga/appflow-tracker-frontend
    jobs:
    build-backend:
    name: Backend — Build & Push
    runs-on: ubuntu-latest
    permissions:
    contents: read
    steps:
    - name: Checkout repository
    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-backend
    uses: docker/metadata-action@v5
    with:
    images: $ env.BACKEND_IMAGE
    tags: |
    type=raw,value=latest,enable=is_default_branch
    type=sha,prefix=sha-,format=short
    - name: Build and push backend image
    uses: docker/build-push-action@v6
    with:
    context: ./backend
    file: ./backend/Dockerfile
    push: true
    tags: $ steps.meta-backend.outputs.tags
    labels: $ steps.meta-backend.outputs.labels
    cache-from: type=gha,scope=backend
    cache-to: type=gha,mode=max,scope=backend
    platforms: linux/amd64,linux/arm64
    build-frontend:
    name: Frontend — Build & Push
    runs-on: ubuntu-latest
    permissions:
    contents: read
    steps:
    - name: Checkout repository
    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-frontend
    uses: docker/metadata-action@v5
    with:
    images: $ env.FRONTEND_IMAGE
    tags: |
    type=raw,value=latest,enable=is_default_branch
    type=sha,prefix=sha-,format=short
    - name: Build and push frontend image
    uses: docker/build-push-action@v6
    with:
    context: ./frontend
    file: ./frontend/Dockerfile
    push: true
    tags: $ steps.meta-frontend.outputs.tags
    labels: $ steps.meta-frontend.outputs.labels
    build-args: |
    VITE_API_BASE_URL=/api/v1
    cache-from: type=gha,scope=frontend
    cache-to: type=gha,mode=max,scope=frontend
    platforms: linux/amd64,linux/arm64
    How the CD pipeline flows
    javascript
    Developer merges PR → master
    [Event: push to master fires]
    ┌──────────────────────────┐ ┌───────────────────────────┐
    │ build-backend │ │ build-frontend │ ← PARALLEL
    1. Checkout │ │ 1. Checkout
    2. Setup Buildx │ │ 2. Setup Buildx
    3. Login to Docker Hub │ │ 3. Login to Docker Hub
    4. Generate :latest tag │ │ 4. Generate :latest tag │
    │ and :sha-abc1234 tag │ │ and :sha-abc1234 tag │
    5. Build + Push image │ │ 5. Build + Push image │
    └──────────────────────────┘ └───────────────────────────┘
    Images are updated on Docker Hub

    6. Secrets — Never Hardcode Passwords

    Notice this pattern in the CD workflow:

    yaml
    username: $ secrets.DOCKERHUB_USERNAME
    password: $ secrets.DOCKERHUB_TOKEN

    Secrets are encrypted key-value pairs stored in GitHub, never in your code.

    How to add secrets to your repository
  • Go to your repository on GitHub
  • Click SettingsSecrets and variablesActions
  • Click New repository secret
  • Add your secrets:
  • How to generate a Docker Hub token
  • Log in at https://hub.docker.com/
  • Click your avatar → My AccountPersonal access tokens
  • Click Generate new token
  • Set permissions to Read & Write
  • Copy the token immediately. It is only shown once.
  • GitHub masks secrets in logs. Even if a step prints a secret, it will show as ***.


    7. Caching — Making Pipelines Fast

    Without caching, every pipeline run downloads all dependencies from scratch. For a Python + Node project this can add minutes per run. Caching can cut repeat runs down dramatically.

    Example: pip caching
    yaml
    - name: Cache pip dependencies
    uses: actions/cache@v4
    with:
    path: ~/.cache/pip
    key: pip-$ runner.os -$ hashFiles('**/requirements.txt')
    restore-keys: |
    pip-$ runner.os -
    Example: node_modules caching
    yaml
    - name: Cache node_modules
    uses: actions/cache@v4
    with:
    path: frontend/node_modules
    key: node-$ runner.os -$ hashFiles('**/package-lock.json')
    restore-keys: |
    node-$ runner.os -

    8. The Full Picture

    Together, these two workflows create a complete automated software delivery system.

    javascript
    ┌─────────────────────────────────────────────────────────────────┐
    DEVELOPMENT LIFECYCLE
    │ │
    1. Developer writes code on a feature branch │
    │ ↓ │
    2. Developer opens a Pull Requestmaster (or staging)
    │ ↓ │
    │ ┌─────────────────────────────┐ │
    │ │ pr-tests.yml fires │ ← CI
    │ │ 🐍 Backend tests (pytest) │ │
    │ │ ⚛️ Frontend tests (Vitest)│ │
    │ │ ⚛️ Vite production build │ │
    │ └──────────────┬──────────────┘ │
    │ │ │
    │ ✅ all pass │
    │ ↓ │
    3. Team reviews and approves the PR
    │ ↓ │
    4. PR is merged into master │
    │ ↓ │
    │ ┌─────────────────────────────┐ │
    │ │ docker-publish.yml fires │ ← CD
    │ │ 🐳 Build backend image │ │
    │ │ 🐳 Build frontend image │ │
    │ │ 📦 PushDocker Hub │ │
    │ └──────────────┬──────────────┘ │
    │ ↓ │
    5. Anyone can pull the new image
    │ docker pull kennjenga/appflow-tracker-backend:latest │
    │ │
    └─────────────────────────────────────────────────────────────────┘

    Quick reference: GitHub Actions expression syntax
    yaml
    $ secrets.MY_SECRET
    $ env.MY_ENV_VAR
    $ github.sha
    $ github.ref
    $ github.actor
    $ runner.os
    $ hashFiles('path/to/file')
    $ steps.step_id.outputs.some_output
    Most useful pre-built actions
    yaml
    uses: actions/checkout@v4
    uses: actions/setup-python@v5
    uses: actions/setup-node@v4
    uses: actions/cache@v4
    uses: actions/upload-artifact@v4
    uses: docker/setup-buildx-action@v3
    uses: docker/login-action@v3
    uses: docker/build-push-action@v6
    uses: docker/metadata-action@v5
    Common on: triggers
    yaml
    on:
    push:
    branches: [master, staging]
    pull_request:
    branches: [master, staging]
    schedule:
    - cron: '0 2 * * 1'
    workflow_dispatch:
    release:
    types: [published]

    The full source code for AppFlow Tracker, including both workflow files, is available at https://github.com/Kennjenga/appflow-tracker .

    The Docker images are public at https://hub.docker.com/u/kennjenga .

    Latest Articles

    More Articles

    Intergrating a simulator ecommerce site with mysql, redis

    February 13, 2025

    Setting up jwt authentication, routes and a postgres connection

    how to connect an express application to postgres and setup jwt authentication

    January 30, 2025
    How can I help you architect your next project?

    Njenga AI

    System Assistant

    👋 Hi! I'm Njenga, Kenneth's AI assistant. How can I help you today?