Step 3 - Automate Builds with GitHub Actions - VIScon 2025 Workshop

October 11, 2025

Automate Builds with GitHub Actions

Every time you push code, GitHub Actions will automatically build your Docker images and publish them to GitHub Container Registry (GHCR). No manual building, no manual pushing—just commit your code and let automation handle the rest.

In this guide, we'll build a GitHub Actions workflow step-by-step that builds both the backend and frontend images for the FurNet application.

What You'll Learn

  • How GitHub Actions workflows work and why they're powerful
  • Authentication with GitHub Container Registry
  • Building multiple Docker images in one workflow
  • Automatic image tagging with branch and commit
  • Caching for fast builds

Prerequisites

  • FurNet repository forked and cloned to your machine
  • Basic understanding of Docker and containers
  • GitHub account (free tier works perfectly)

Understanding the FurNet Application Structure

Before we build the workflow, understand that FurNet has two parts that need separate Docker images:

  • Backend (./backend/): FastAPI Python application
  • Frontend (./frontend/): React application built with Vite

Each has its own Dockerfile and will be built into separate container images.


Step 1: Understanding GitHub Actions

What are GitHub Actions?

GitHub Actions is a CI/CD platform that lets you automate workflows directly in your GitHub repository. Think of it as a robot that wakes up when certain events happen (like pushing code) and follows your instructions.

Key Concepts:

  • Workflow: An automated process defined in a YAML file
  • Job: A set of steps that run on the same runner
  • Step: An individual task (like running a command or using an action)
  • Action: A reusable unit of code (like "checkout repository" or "build Docker image")
  • Runner: A server that runs your workflows (GitHub provides these for free!)

Why This Matters:

Instead of manually running docker build and docker push every time you change code, GitHub Actions does it automatically. Push to GitHub, and within minutes, your updated images are ready to deploy.


Step 2: Create the Workflow Directory

Navigate to your FurNet repository and create the directory structure where GitHub looks for workflows:

cd furnet
mkdir -p .github/workflows

Why .github/workflows?

This is a special directory that GitHub recognizes. Any YAML file in this directory will be treated as a workflow definition and executed automatically.


Step 3: Create the Workflow File

Create a new file for the Docker build workflow using your favorite editor (e.g., vim):

vim .github/workflows/docker-publish.yml

Naming Convention:

You can name workflow files anything you want, but descriptive names like docker-publish.yml, ci.yml, or deploy.yml help you (and others) understand their purpose at a glance.

We'll build this file piece by piece in the following steps.


Step 4: Define the Workflow Name and Triggers

Start by adding the workflow name and defining when it should run:

name: Build and Push Docker Images

on:
  push:
    branches:
      - main

Breaking it down:

  • name: Build and Push Docker Images: The display name shown in GitHub's Actions tab
  • on:: Defines the events that trigger this workflow
    • push.branches: main: Triggers when you push commits to the main branch

How it works:

Every time you push to main, the workflow automatically builds new Docker images tagged with the branch name and commit SHA. This keeps things simple and predictable.


Step 5: Set Up Environment Variables

Add global environment variables that will be used throughout the workflow:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

What these variables do:

  • REGISTRY: ghcr.io: GitHub Container Registry URL where images will be pushed
  • IMAGE_NAME: ${{ github.repository }}: Your repository name in the format username/furnet
    • ${{ github.repository }} is automatically provided by GitHub

Why use variables?

Variables make your workflow portable and maintainable. If you decide to switch registries (e.g., to Docker Hub), you only change one line!


Step 6: Define the Job and Runner

Now let's start defining the actual job that will build your images:

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write

Understanding each part:

  • jobs:: Container for all jobs in this workflow
  • build-and-push:: Job name (you can have multiple jobs in one workflow)
  • runs-on: ubuntu-latest: Run on GitHub's free Ubuntu runner
  • permissions:: Security scoping for what this job can access
    • contents: read: Permission to read your repository code
    • packages: write: Permission to publish to GitHub Container Registry
    • id-token: write: Permission for advanced authentication scenarios

Security Note: Explicitly defining permissions follows the principle of least privilege—the job only gets access to what it needs.


Step 7: Add the Checkout Step

Begin defining the steps that will execute in sequence:

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

What this does:

  • steps:: List of individual tasks to execute
  • name: Checkout repository: Descriptive name for this step
  • uses: actions/checkout@v4: Uses GitHub's official action to clone your repository

This step clones your code into the runner's workspace so subsequent steps can access your Dockerfiles and source code.


Step 8: Set Up Docker Buildx

Add Docker Buildx to enable advanced build features:

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

What is Docker Buildx?

Buildx is Docker's modern build system that provides:

  • Multi-architecture builds: Build for ARM and x86 simultaneously
  • Advanced caching: Significantly faster builds on subsequent runs
  • BuildKit backend: Modern, efficient build engine

Even though we're only building for one architecture in this workshop, Buildx provides better caching and performance.


Step 9: Authenticate with GitHub Container Registry

Add the login step to authenticate with GHCR:

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

Breaking it down:

  • registry: ${{ env.REGISTRY }}: Login to ghcr.io (from our environment variables)
  • username: ${{ github.actor }}: Your GitHub username (automatically provided)
  • password: ${{ secrets.GITHUB_TOKEN }}: Authentication token

Important: GITHUB_TOKEN is automatically created by GitHub for every workflow run. You don't need to configure anything—it just works!

Security: The token is scoped to this workflow and expires after the job completes.


Step 10: Generate Backend Image Metadata

Now we'll generate tags and labels for the backend image:

      - name: Extract metadata for Backend
        id: meta-backend
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend
          tags: |
            type=ref,event=branch
            type=sha,prefix={{branch}}-
            type=raw,value=latest,enable={{is_default_branch}}

Understanding the tags:

This step automatically generates multiple tags based on your Git activity:

  • type=ref,event=branch: Tag with branch name (e.g., main)
  • type=sha,prefix={{branch}}-: Tag with git commit SHA (e.g., main-abc1234)
  • type=raw,value=latest: Tag as latest only on the default branch

Example: When you push to main with commit abc1234, you get:

  • ghcr.io/username/furnet/backend:main
  • ghcr.io/username/furnet/backend:main-abc1234
  • ghcr.io/username/furnet/backend:latest

Why multiple tags?

Different deployment scenarios need different tags:

  • latest: For quick testing with the newest build
  • main: For deploying the latest main branch build
  • main-abc1234: For tracking and deploying specific commits

The id: meta-backend saves these outputs so we can reference them in the build step.


Step 11: Generate Frontend Image Metadata

Add a similar metadata step for the frontend:

      - name: Extract metadata for Frontend
        id: meta-frontend
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/frontend
          tags: |
            type=ref,event=branch
            type=sha,prefix={{branch}}-
            type=raw,value=latest,enable={{is_default_branch}}

Why a separate step?

Backend and frontend are completely independent images with their own lifecycles. This generates tags like:

  • ghcr.io/username/furnet/frontend:main
  • ghcr.io/username/furnet/frontend:main-abc1234
  • ghcr.io/username/furnet/frontend:latest

Notice the only difference from the backend step is meta-frontend and /frontend in the image path.


Step 12: Build and Push the Backend Image

Now add the actual build step for the backend:

      - name: Build and push Backend Docker image
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          file: ./backend/Dockerfile
          push: true
          tags: ${{ steps.meta-backend.outputs.tags }}
          labels: ${{ steps.meta-backend.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Understanding each parameter:

  • context: ./backend: Build context is the backend directory
    • Docker can access all files in ./backend/ during the build
  • file: ./backend/Dockerfile: Path to the Dockerfile for the backend
  • push: true: Always push the built image to the registry
  • tags: ${{ steps.meta-backend.outputs.tags }}: Use all the tags we generated in step 10
  • labels: ${{ steps.meta-backend.outputs.labels }}: Add metadata labels to the image
  • cache-from: type=gha: Read cache from GitHub Actions cache
  • cache-to: type=gha,mode=max: Write cache to GitHub Actions cache (maximum mode stores all layers)

Why caching matters:

The first build might take 3-5 minutes. With caching, subsequent builds complete in 30-60 seconds when only code changes (dependencies are cached).

What gets built:

This executes the backend Dockerfile which:

  1. Starts from python:3.13-slim
  2. Installs Python dependencies from requirements.txt
  3. Copies the FastAPI application code
  4. Sets up health checks
  5. Configures the application to run on port 8000

Step 13: Build and Push the Frontend Image

Add the frontend build step:

      - name: Build and push Frontend Docker image
        uses: docker/build-push-action@v5
        with:
          context: ./frontend
          file: ./frontend/Dockerfile
          push: true
          tags: ${{ steps.meta-frontend.outputs.tags }}
          labels: ${{ steps.meta-frontend.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What's different:

  • context: ./frontend: Build from the frontend directory
  • file: ./frontend/Dockerfile: Use the frontend Dockerfile
  • tags: ${{ steps.meta-frontend.outputs.tags }}: Use frontend tags from step 11

What gets built:

This executes the frontend Dockerfile which:

  1. Builds the React app using Node.js (builder stage)
  2. Compiles Vite assets into static files
  3. Copies built files to an nginx image (production stage)
  4. Sets up nginx to serve the frontend on port 8080

Multi-stage builds:

Notice the frontend uses a multi-stage build (check frontend/Dockerfile). This keeps the final image small—it only contains nginx and built assets, not the entire Node.js build toolchain.


Step 14: The Complete Workflow

Here's the complete workflow file with all the pieces together:

name: Build and Push Docker Images

on:
  push:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata for Backend
        id: meta-backend
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend
          tags: |
            type=ref,event=branch
            type=sha,prefix={{branch}}-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Extract metadata for Frontend
        id: meta-frontend
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/frontend
          tags: |
            type=ref,event=branch
            type=sha,prefix={{branch}}-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push Backend Docker image
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          file: ./backend/Dockerfile
          push: true
          tags: ${{ steps.meta-backend.outputs.tags }}
          labels: ${{ steps.meta-backend.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Build and push Frontend Docker image
        uses: docker/build-push-action@v5
        with:
          context: ./frontend
          file: ./frontend/Dockerfile
          push: true
          tags: ${{ steps.meta-frontend.outputs.tags }}
          labels: ${{ steps.meta-frontend.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Save this complete workflow to .github/workflows/docker-publish.yml in your FurNet repository.


Step 15: Commit and Push

Now save your workflow file and push it to GitHub:

git add .github/workflows/docker-publish.yml
git commit -m "feat: add GitHub Actions workflow for Docker builds"
git push origin main

What happens next:

The moment you push, GitHub detects the new workflow file and immediately starts executing it. Your first automated build has begun!


Step 16: Watch Your Workflow Run

  1. Go to your GitHub repository in your browser
  2. Click the "Actions" tab at the top
  3. You should see "Build and Push Docker Images" running
  4. Click on the workflow run to see detailed logs

What you'll see:

GitHub shows you real-time progress:

  • ✓ Checkout repository
  • ✓ Set up Docker Buildx
  • ✓ Log in to GitHub Container Registry
  • ✓ Extract metadata for Backend
  • ✓ Extract metadata for Frontend
  • 🔄 Build and push Backend Docker image (this takes the longest)
  • 🔄 Build and push Frontend Docker image

First build timing:

Expect the first run to take 5-10 minutes total. Both images are being built from scratch, and all dependencies need to be downloaded.


Step 17: Verify Your Published Images

After the workflow completes successfully (green checkmark):

  1. Go to your GitHub profile page or repository
  2. Click "Packages" in the right sidebar
  3. You should see two packages:
    • furnet/backend
    • furnet/frontend

Click on each package to see:

  • All available tags (latest, main, main-abc1234, etc.)
  • Image size
  • Layers and security scans
  • Pull commands

Default visibility: Private

To make images public (so anyone can pull them):

  1. Click on the package
  2. Click "Package settings" (bottom right)
  3. Scroll down to "Danger Zone"
  4. Click "Change visibility""Public"
  5. Confirm by typing the repository name

Do this for both backend and frontend packages.


Understanding the Complete Pipeline

Now that you've built the workflow, let's understand how everything flows together:

  1. You push code → GitHub receives the commit
  2. Workflow triggers → GitHub Actions starts a new runner
  3. Code checkout → Your repository is cloned
  4. Buildx setup → Docker build environment is prepared
  5. Registry login → Authenticates with GHCR using auto-generated token
  6. Metadata generation → Creates appropriate tags based on branch and commit
  7. Backend build → Builds from ./backend/Dockerfile, using cache if available
  8. Backend push → Pushes all tagged versions to GHCR
  9. Frontend build → Builds from ./frontend/Dockerfile, using cache
  10. Frontend push → Pushes all tagged versions to GHCR
  11. Workflow complete → Images are ready to deploy!

From your perspective: Push code, wait a few minutes, pull updated images.


Troubleshooting

Problem: "Permission denied while trying to push"

Symptoms: Workflow fails at the "Build and push" step with permission errors

Solution: Check that your workflow has the correct permissions:

permissions:
  contents: read
  packages: write
  id-token: write

Also verify you haven't modified repository settings to restrict package publishing.


Problem: "Image not found when pulling"

Symptoms: docker pull ghcr.io/username/furnet/backend:latest fails with "not found"

Solution:

  1. Check if the package is private:

    • Private packages require authentication to pull
    • Make the package public (see Step 17) or login:
    echo $GITHUB_TOKEN | docker login ghcr.io -u username --password-stdin
  2. Verify the image name is exactly correct:

    • Go to GitHub Packages and copy the exact pull command
    • Ensure username, repository name, and tag are all correct

Problem: "Workflow doesn't trigger"

Symptoms: You pushed code but no workflow run appears in the Actions tab

Solution:

  1. Check the file is in the correct location:

    ls -la .github/workflows/docker-publish.yml
  2. Validate YAML syntax:

    • YAML is indentation-sensitive (use spaces, not tabs)
    • Use a YAML validator online or in your editor
    • Check for common issues like missing colons or incorrect indentation
  3. Verify trigger conditions:

    • Did you push to the main branch?
    • Check your default branch name (might be master instead of main)
  4. Check Actions are enabled:

    • Go to repository Settings → Actions → General
    • Ensure "Allow all actions and reusable workflows" is selected

Problem: "Build is very slow"

Symptoms: Builds take 10+ minutes every time

Solution:

The first build is always slow (5-10 minutes). Subsequent builds should be much faster (1-3 minutes) thanks to caching.

If builds remain slow:

  1. Check cache is working:

    • Look for "Using cache from" messages in build logs
    • Ensure both cache-from and cache-to are set
  2. Optimize your Dockerfiles:

    • Copy dependency files (requirements.txt, package.json) before code
    • This lets Docker cache dependency installation layers
    • Only code changes trigger rebuilds of later layers
  3. Check for cache invalidation:

    • Changing requirements.txt or package.json invalidates cache
    • This is expected and necessary

Example timing breakdown:

  • First build: 8 minutes (no cache)
  • Second build (only code change): 90 seconds (dependencies cached)
  • Third build (dependency change): 5 minutes (need to reinstall dependencies)

Problem: "One image builds but the other fails"

Symptoms: Backend builds successfully but frontend fails (or vice versa)

Solution:

  1. Check the Dockerfile:

    • Test building locally first:
    cd frontend  # or backend
    docker build -t test .
  2. Check the build context:

    • Ensure all necessary files exist in the context directory
    • Verify .dockerignore isn't excluding required files
  3. Review build logs:

    • Click on the failed step in GitHub Actions
    • Read the error message carefully
    • Common issues: missing dependencies, syntax errors, network timeouts

Next Steps

With automated Docker image builds in place, you're ready to set up your Kubernetes cluster!

What you've accomplished:

  • ✓ Created a complete CI/CD pipeline for building container images
  • ✓ Automated building of both backend and frontend
  • ✓ Implemented intelligent tagging (branch, commit, and latest)
  • ✓ Set up layer caching for fast builds
  • ✓ Published images to GitHub Container Registry

Ready for the next step? Continue to Step 4: Install k3s to set up a lightweight Kubernetes cluster on your workshop VM.