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/workflowsWhy .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.ymlNaming 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:
- mainBreaking it down:
name: Build and Push Docker Images: The display name shown in GitHub's Actions tabon:: Defines the events that trigger this workflowpush.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 pushedIMAGE_NAME: ${{ github.repository }}: Your repository name in the formatusername/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: writeUnderstanding each part:
jobs:: Container for all jobs in this workflowbuild-and-push:: Job name (you can have multiple jobs in one workflow)runs-on: ubuntu-latest: Run on GitHub's free Ubuntu runnerpermissions:: Security scoping for what this job can accesscontents: read: Permission to read your repository codepackages: write: Permission to publish to GitHub Container Registryid-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@v4What this does:
steps:: List of individual tasks to executename: Checkout repository: Descriptive name for this stepuses: 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@v3What 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 aslatestonly on the default branch
Example: When you push to main with commit abc1234, you get:
ghcr.io/username/furnet/backend:mainghcr.io/username/furnet/backend:main-abc1234ghcr.io/username/furnet/backend:latest
Why multiple tags?
Different deployment scenarios need different tags:
latest: For quick testing with the newest buildmain: For deploying the latest main branch buildmain-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:mainghcr.io/username/furnet/frontend:main-abc1234ghcr.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=maxUnderstanding each parameter:
context: ./backend: Build context is the backend directory- Docker can access all files in
./backend/during the build
- Docker can access all files in
file: ./backend/Dockerfile: Path to the Dockerfile for the backendpush: true: Always push the built image to the registrytags: ${{ steps.meta-backend.outputs.tags }}: Use all the tags we generated in step 10labels: ${{ steps.meta-backend.outputs.labels }}: Add metadata labels to the imagecache-from: type=gha: Read cache from GitHub Actions cachecache-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:
- Starts from
python:3.13-slim - Installs Python dependencies from
requirements.txt - Copies the FastAPI application code
- Sets up health checks
- 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=maxWhat's different:
context: ./frontend: Build from the frontend directoryfile: ./frontend/Dockerfile: Use the frontend Dockerfiletags: ${{ steps.meta-frontend.outputs.tags }}: Use frontend tags from step 11
What gets built:
This executes the frontend Dockerfile which:
- Builds the React app using Node.js (builder stage)
- Compiles Vite assets into static files
- Copies built files to an nginx image (production stage)
- 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=maxSave 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 mainWhat 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
- Go to your GitHub repository in your browser
- Click the "Actions" tab at the top
- You should see "Build and Push Docker Images" running
- 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):
- Go to your GitHub profile page or repository
- Click "Packages" in the right sidebar
- You should see two packages:
furnet/backendfurnet/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):
- Click on the package
- Click "Package settings" (bottom right)
- Scroll down to "Danger Zone"
- Click "Change visibility" → "Public"
- 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:
- You push code → GitHub receives the commit
- Workflow triggers → GitHub Actions starts a new runner
- Code checkout → Your repository is cloned
- Buildx setup → Docker build environment is prepared
- Registry login → Authenticates with GHCR using auto-generated token
- Metadata generation → Creates appropriate tags based on branch and commit
- Backend build → Builds from
./backend/Dockerfile, using cache if available - Backend push → Pushes all tagged versions to GHCR
- Frontend build → Builds from
./frontend/Dockerfile, using cache - Frontend push → Pushes all tagged versions to GHCR
- 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: writeAlso 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:
-
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 -
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:
-
Check the file is in the correct location:
ls -la .github/workflows/docker-publish.yml -
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
-
Verify trigger conditions:
- Did you push to the
mainbranch? - Check your default branch name (might be
masterinstead ofmain)
- Did you push to the
-
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:
-
Check cache is working:
- Look for "Using cache from" messages in build logs
- Ensure both
cache-fromandcache-toare set
-
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
- Copy dependency files (
-
Check for cache invalidation:
- Changing
requirements.txtorpackage.jsoninvalidates cache - This is expected and necessary
- Changing
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:
-
Check the Dockerfile:
- Test building locally first:
cd frontend # or backend docker build -t test . -
Check the build context:
- Ensure all necessary files exist in the context directory
- Verify
.dockerignoreisn't excluding required files
-
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.