002 - GitHub CI/CD Deployment
002 - GitHub CI/CD Deployment
How it works: You push code to your USERNAME/bootcamp repo → GitHub Actions builds Docker images → Images pushed to your GHCR → Watchtower on your VPS auto-pulls and restarts containers.
📌 Prerequisites: Before setting up CI/CD, ensure you have:
- A GitHub repository at
github.com/USERNAME/bootcampwith the application source code (see 000 - Pre-Bootcamp Setup)- A Personal Access Token (PAT) with
write:packagesscope (see 001 - Deployment Guide, Section 1)If you haven't done initial deployment yet, complete 001 - Deployment Guide first — it walks through the full process end-to-end. This document provides a deeper explanation of how the CI/CD system works.
How Your CI/CD Pipeline Works
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ YOU: push code to USERNAME/bootcamp (main branch) │
│ │ │
│ ▼ │
│ GITHUB ACTIONS: .github/workflows/deploy.yml triggers │
│ │ • Detects which folders changed │
│ │ • Builds only affected images (api / web / admin) │
│ │ • Pushes to ghcr.io/USERNAME/bootcamp/...:latest │
│ │ │
│ ▼ │
│ YOUR GHCR: new images available │
│ │ │
│ ▼ (within ~5 minutes) │
│ WATCHTOWER (on your VPS): │
│ │ • Polls GHCR every 300 seconds │
│ │ • Detects new image SHA digests │
│ │ • Pulls new images │
│ │ • Gracefully restarts updated containers │
│ │ │
│ ▼ │
│ 🎉 YOUR APP IS UPDATED │
│ USERNAME.lazuar.dev ← live with latest changes │
│ │
└─────────────────────────────────────────────────────────────────────┘
1. The Workflow File
This file lives at USERNAME/bootcamp/.github/workflows/deploy.yml:
name: Build and Push Docker Images
on:
push:
branches: ["main"]
paths:
- "apps/**"
- "services/**"
- "contracts/**"
- "packages/**"
- "docker-compose.yml"
- "docker-compose.prod.yml"
- "gateway/**"
- ".github/workflows/deploy.yml"
workflow_dispatch: # Allows manual triggering from the GitHub UI
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
# Resolves to: USERNAME/bootcamp (your personal namespace)
IMAGE_NAME: ${{ github.repository }}
jobs:
# ------------------------------------------------------------------
# JOB 1: API (.NET)
# ------------------------------------------------------------------
api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Filter Changes
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
src:
- 'services/api-dotnet/**'
- 'contracts/**'
- name: Set up Docker Buildx
if: steps.changes.outputs.src == 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: steps.changes.outputs.src == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.CR_USER }}
password: ${{ secrets.CR_PAT }}
- name: Build and Push API
if: steps.changes.outputs.src == 'true'
uses: docker/build-push-action@v5
with:
context: .
file: services/api-dotnet/Dockerfile
push: true
platforms: linux/amd64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/api:latest
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ------------------------------------------------------------------
# JOB 2: WEB (Next.js)
# ------------------------------------------------------------------
web:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Filter Changes
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
src:
- 'apps/web/**'
- 'packages/**'
- 'contracts/**'
- name: Set up Docker Buildx
if: steps.changes.outputs.src == 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: steps.changes.outputs.src == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.CR_USER }}
password: ${{ secrets.CR_PAT }}
- name: Build and Push Web
if: steps.changes.outputs.src == 'true'
uses: docker/build-push-action@v5
with:
context: .
file: apps/web/Dockerfile
push: true
platforms: linux/amd64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/web:latest
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ------------------------------------------------------------------
# JOB 3: ADMIN (Vite)
# ------------------------------------------------------------------
admin:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Filter Changes
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
src:
- 'apps/admin/**'
- 'packages/**'
- 'contracts/**'
- name: Set up Docker Buildx
if: steps.changes.outputs.src == 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: steps.changes.outputs.src == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.CR_USER }}
password: ${{ secrets.CR_PAT }}
- name: Build and Push Admin
if: steps.changes.outputs.src == 'true'
uses: docker/build-push-action@v5
with:
context: .
file: apps/admin/Dockerfile
push: true
platforms: linux/amd64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/admin:latest
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
cache-from: type=gha
cache-to: type=gha,mode=max
📌 Key detail: The
IMAGE_NAME: ${{ github.repository }}variable automatically resolves toUSERNAME/bootcamp— your personal namespace. No hardcoded paths. The same workflow file works for any GitHub user.
2. Setting Up Repository Secrets
Your CI/CD pipeline authenticates with GHCR using secrets stored on your repository.
2.1 Navigate to Secrets
Go to: https://github.com/USERNAME/bootcamp/settings/secrets/actions
2.2 Add Required Secrets
Click New repository secret for each:
| Name | Value | Purpose |
|---|---|---|
CR_USER | Your GitHub username (e.g., alidev) | Authenticates with GHCR |
CR_PAT | Your PAT starting with ghp_... | Authorizes push to GHCR |
⚠️ The PAT must have
write:packagesscope. If you only haveread:packages, the build will fail with a permission error when trying to push images.
2.3 Verify Secrets Are Set
After adding, you should see:
Repository secrets (2)
CR_USER Updated 1 minute ago
CR_PAT Updated 1 minute ago
💡 You cannot view secret values after saving — only update or delete them. If unsure whether the value is correct, delete and recreate.
3. Setting Up Repository Permissions
3.1 Actions Permissions
- Go to:
https://github.com/USERNAME/bootcamp/settings/actions - Under Actions permissions: Select Allow all actions and reusable workflows
- Click Save
3.2 Workflow Permissions
- On the same page, scroll to Workflow permissions
- Select Read and write permissions
- Click Save
📌 Why "Read and write"? The workflow needs
packages: writepermission to push Docker images to GHCR. Without this, you'll get403 Forbiddenerrors during the push step.
3.3 Link Packages to Repository (After First Build)
⚠️ This step is REQUIRED. Without it, your second build will fail with
permission_denied: write_package. Complete this immediately after your first build succeeds.
After your first successful build, the packages are created but not automatically linked to the repository. You must grant your repository write access to the packages:
- Go to:
https://github.com/USERNAME?tab=packages - Click on each package (
bootcamp/api,bootcamp/web,bootcamp/admin) - Go to Package Settings (gear icon or right side)
- Under Manage Actions access → click Add Repository
- Search for
bootcampand add it with Write role - Click Save
Repeat for all three packages.
Direct links (replace USERNAME):
https://github.com/users/USERNAME/packages/container/bootcamp%2Fapi/settingshttps://github.com/users/USERNAME/packages/container/bootcamp%2Fweb/settingshttps://github.com/users/USERNAME/packages/container/bootcamp%2Fadmin/settings
📌 How to verify: After linking, push any change to
main(or manually trigger the workflow). If the build succeeds without permission errors, you're good. If you seedenied: permission_denied: write_package, you missed a package — check all three.
4. How Images Are Named
The workflow uses ${{ github.repository }} which resolves to your personal namespace:
${{ github.repository }} → USERNAME/bootcamp
Image tags produced:
ghcr.io/USERNAME/bootcamp/api:latest
ghcr.io/USERNAME/bootcamp/web:latest
ghcr.io/USERNAME/bootcamp/admin:latest
These are the exact same image paths you use in your VPS docker-compose.yml.
Example for user alidev:
ghcr.io/alidev/bootcamp/api:latest
ghcr.io/alidev/bootcamp/web:latest
ghcr.io/alidev/bootcamp/admin:latest
📌 GitHub converts usernames to lowercase in package paths. If your username is
AliDev, the image path is stillghcr.io/alidev/bootcamp/....
5. Smart Filtering — Only Build What Changed
The workflow detects which folders were modified and only runs the necessary jobs:
| Files Changed | Jobs That Run | Jobs Skipped |
|---|---|---|
services/api-dotnet/** | ✅ api | ⏭️ web, admin |
apps/web/** | ✅ web | ⏭️ api, admin |
apps/admin/** | ✅ admin | ⏭️ api, web |
contracts/** or packages/** | ✅ All three | — |
README.md only | ⏭️ None | All skipped |
This saves build time and GitHub Actions minutes.
💡 GitHub Actions free tier: Personal accounts get 2,000 minutes/month of free Actions time. Each full build takes ~5-10 minutes. That's roughly 200-400 builds per month — more than enough for a bootcamp.
📌 Important for first build: If your initial push to the repo (from 000 - Setup) didn't change files matching the
pathsfilter (e.g., all files were already there from the clone), the workflow won't trigger automatically. Use the manual trigger (workflow_dispatch) for your first build — see Section 6.2.
6. Triggering Your First Build
6.1 Automatic Trigger (Push to Main)
Any push to main that modifies files matching the paths filter triggers the workflow:
# Make a change that triggers the build
cd ~/bootcamp
echo "# trigger build" >> README.md
git add .
git commit -m "ci: trigger first build"
git push origin main
⚠️ If you only change
README.md, the paths filter won't match and no build runs. Change a file insideapps/,services/, orcontracts/to trigger the actual build, or use manual trigger.
📌 Note: Your initial push from the 000 - Setup Guide may have already triggered a build if it matched the path filters. Check
https://github.com/USERNAME/bootcamp/actionsto see if a build is already running or completed.
6.2 Manual Trigger (Recommended for First Build)
You can trigger the workflow without pushing code:
- Go to:
https://github.com/USERNAME/bootcamp/actions - Select "Build and Push Docker Images" from the left sidebar
- Click "Run workflow" dropdown (right side)
- Select branch:
main - Click the green "Run workflow" button
6.3 Monitor the Build
- Go to:
https://github.com/USERNAME/bootcamp/actions - Click on the running workflow
- You'll see three jobs:
api,web,admin - Click on any job to see live logs
Expected timeline:
- First build: 5-10 minutes (no cache)
- Subsequent builds: 2-5 minutes (with cache)
6.4 Verify Build Success
All three jobs should show ✅ green checkmarks.
Then verify images exist:
- Go to:
https://github.com/USERNAME?tab=packages - You should see:
bootcamp/apibootcamp/webbootcamp/admin
Each package should show "Published X minutes ago".
⚠️ After verifying images exist, immediately complete Section 3.3 (Link Packages to Repository) before doing anything else. This prevents your next build from failing.
7. Package Visibility & Access
7.1 Default Visibility
Packages created from personal repos default to private. This is fine for our use case because:
- Your VPS authenticates with your PAT (which has
read:packagesviawrite:packages) - Watchtower also uses the same PAT
7.2 Making Packages Public (Optional)
If you want anyone to pull your images without authentication:
- Go to:
https://github.com/users/USERNAME/packages/container/bootcamp%2Fapi/settings - Scroll to Danger Zone
- Click Change visibility → select Public
- Repeat for
bootcamp/webandbootcamp/admin
📌 Public packages don't consume your private storage quota. But for a bootcamp project, private is usually preferred.
7.3 Troubleshooting Package Access
If your VPS can't pull images after a successful build:
# On VPS — verify login is still valid
cat ~/.docker/config.json | grep ghcr
# If not found, re-login:
echo "ghp_YOUR_PAT" | docker login ghcr.io -u USERNAME --password-stdin
# Test pulling manually:
docker pull ghcr.io/USERNAME/bootcamp/api:latest
If Watchtower can't pull (check its logs):
docker logs kanban-watchtower --tail 20
Look for authentication errors. Verify REPO_USER and REPO_PASS in .env match your GitHub username and PAT.
8. The Development → Deployment Loop
Once everything is set up, your daily workflow is:
┌─────────── YOUR DEVELOPMENT LOOP ──────────────────────┐
│ │
│ 1. Edit code locally │
│ 2. Test locally (docker compose up) │
│ 3. git add . && git commit -m "feat: ..." │
│ 4. git push origin main │
│ 5. GitHub Actions builds images (~3-5 min) │
│ 6. Watchtower pulls new images (~5 min) │
│ 7. Your live site is updated! 🎉 │
│ │
│ Total: code change → live in ~8-10 minutes │
│ (or ~3-5 min if you manually pull on VPS) │
│ │
└──────────────────────────────────────────────────────────┘
Quick Manual Update (Skip Watchtower Wait)
ssh vps
cd ~/kanban-prod
docker compose pull
docker compose up -d
9. Watching a Deployment Live
To see the full pipeline in action:
- Make a visible code change (e.g., change a text string in the app)
- Commit and push:
git add . git commit -m "feat: update welcome message" git push origin main - Watch the build:
https://github.com/USERNAME/bootcamp/actions - After build completes, either:
- Wait ~5 minutes for Watchtower, OR
- SSH into VPS and run
docker compose pull && docker compose up -d
- Refresh your browser — the change is live!
Verify Update on VPS
# Check Watchtower logs for the update
docker logs kanban-watchtower --tail 10
# You should see:
# Found new ghcr.io/USERNAME/bootcamp/api:latest image (sha256:...)
# Stopping /kanban-api
# Creating /kanban-api
10. Manual Build & Push (Fallback)
If GitHub Actions fails or you need to build from your local machine:
# Login to GHCR
echo "ghp_YOUR_TOKEN" | docker login ghcr.io -u USERNAME --password-stdin
export GH_USER="USERNAME"
export GH_REPO="bootcamp"
# Build and push all three images
docker buildx build --platform linux/amd64 --push \
-t ghcr.io/$GH_USER/$GH_REPO/api:latest \
-f services/api-dotnet/Dockerfile .
docker buildx build --platform linux/amd64 --push \
-t ghcr.io/$GH_USER/$GH_REPO/web:latest \
-f apps/web/Dockerfile .
docker buildx build --platform linux/amd64 --push \
-t ghcr.io/$GH_USER/$GH_REPO/admin:latest \
-f apps/admin/Dockerfile .
⚠️ Platform note: If you're building on Apple Silicon (M1/M2/M3/M4), you MUST include
--platform linux/amd64because your VPS runs on AMD64. Without this flag, the image won't run on the VPS.
💡 This is useful for:
- Initial image creation before CI/CD is fully configured
- Debugging build issues locally
- When GitHub Actions has an outage
11. Troubleshooting CI/CD
11.1 "denied: permission_denied: write_package"
Full error in Actions log:
Error: buildx failed: denied: permission_denied: write_package
Fix checklist:
- ✅ Workflow permissions set to "Read and write" (Section 3.2)
- ✅ Secrets
CR_USERandCR_PATconfigured (Section 2.2) - ✅ PAT has
write:packagesscope (regenerate if unsure) - ✅ Packages linked to repository (Section 3.3 — this is the #1 cause on second builds!)
If all above are correct and it still fails:
# Alternative: use GITHUB_TOKEN instead of PAT
# In the workflow, change the login step:
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
📌
GITHUB_TOKENis automatically provided by GitHub Actions — no secret needed. However, it only works if workflow permissions are set to "Read and write" (Section 3.2).
11.2 Actions Not Triggering on Push
Symptoms: You pushed to main but no workflow appeared in the Actions tab.
Check:
# 1. Is the workflow file in the correct location?
ls .github/workflows/deploy.yml
# 2. Are you pushing to `main`? (not `master` or a feature branch)
git branch --show-current
# 3. Did your changes match the paths filter?
# Only these paths trigger the build:
# apps/**, services/**, contracts/**, packages/**,
# docker-compose.yml, docker-compose.prod.yml,
# gateway/**, .github/workflows/deploy.yml
# 4. Is Actions enabled on your repo?
# Check: https://github.com/USERNAME/bootcamp/settings/actions
Force a build regardless of paths:
- Use manual trigger (Section 6.2), OR
- Modify the workflow to remove
pathsfilter temporarily:
on:
push:
branches: ["main"]
# Remove or comment out the paths section to trigger on ALL pushes
# paths:
# - "apps/**"
# ...
11.3 Build Fails — Common Errors
| Error in Actions Log | Cause | Fix |
|---|---|---|
COPY failed: file not found in build context | Wrong context or file path | Verify Dockerfile paths match repo structure |
npm ERR! network timeout | Transient network issue | Re-run the job (click "Re-run failed jobs") |
Error: Process completed with exit code 137 | Out of memory on runner | Optimize Dockerfile (reduce concurrent operations) |
Invalid workflow file | YAML syntax error | Validate at yamlchecker.com |
Package not found | First build, packages don't exist yet | This is normal — first push creates them |
11.4 Build Succeeds But VPS Doesn't Update
The chain: Build ✅ → Push to GHCR ✅ → Watchtower pulls → Container restarts
Check each link:
# 1. Images exist on GHCR?
# Visit: https://github.com/USERNAME?tab=packages
# Check "Published" timestamp — should be recent
# 2. VPS can pull?
ssh vps
docker pull ghcr.io/USERNAME/bootcamp/api:latest
# If "unauthorized" → re-login to GHCR
# 3. Watchtower is running?
docker ps | grep watchtower
# If not running → docker compose up -d watchtower
# 4. Watchtower can authenticate?
docker logs kanban-watchtower --tail 20
# Look for auth errors
# 5. Force update:
cd ~/kanban-prod
docker compose pull
docker compose up -d
11.5 "Package is not linked to this repository"
After the first build, you may need to link the package to the repository for subsequent builds to have push permission.
Fix: Complete Section 3.3 — link all three packages to your repository with Write access.
12. Advanced: Using GITHUB_TOKEN Instead of PAT
For a simpler setup, you can use the built-in GITHUB_TOKEN instead of a PAT:
Workflow Change
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Pros & Cons
| Aspect | PAT (current approach) | GITHUB_TOKEN |
|---|---|---|
| Setup | Manual token creation + secrets | Automatic, zero setup |
| Scope | Explicit (write:packages) | Inherited from workflow permissions |
| Expiration | Set by you (or never) | Per-workflow-run (auto-expires) |
| Watchtower | Same PAT works for VPS pull | Still need a PAT for Watchtower |
| Revocation | One token for everything | Can't be used outside Actions |
📌 Recommendation: Use PAT (as described in this guide) because you need the same token for Watchtower on your VPS anyway. Using
GITHUB_TOKENfor Actions but a separate PAT for Watchtower adds complexity.
13. GitHub Actions Minutes & Billing
Free Tier (Personal Account)
| Resource | Limit |
|---|---|
| Actions minutes | 2,000 min/month |
| Packages storage | 500 MB |
| Packages data transfer | 1 GB/month |
Estimated Usage Per Build
| Job | Duration (cached) | Duration (first build) |
|---|---|---|
| API (.NET) | ~2-3 min | ~5-7 min |
| Web (Next.js) | ~2-4 min | ~5-8 min |
| Admin (Vite) | ~1-2 min | ~3-5 min |
| Total (parallel) | ~3-4 min | ~5-8 min |
Jobs run in parallel, so total time = longest job, not sum of all jobs.
Staying Within Limits
- At ~4 min per build, you can do ~500 builds/month for free
- Smart filtering means unchanged services don't rebuild (saving minutes)
- Use
workflow_dispatchfor testing instead of pushing empty commits
Summary
| Question | Answer |
|---|---|
| Who builds images? | GitHub Actions (triggered by YOUR push to main) |
| Who triggers builds? | You (push to USERNAME/bootcamp main branch) |
| Where do images live? | ghcr.io/USERNAME/bootcamp/api|web|admin:latest |
| What secrets are needed? | CR_USER (your username) + CR_PAT (your PAT with write:packages) |
| How does VPS update? | Watchtower auto-pulls every 5 minutes |
| What if build fails? | VPS keeps running previous version; fix code and push again |
| Can I manually trigger? | Yes — Actions tab → "Run workflow" |
| Can I build locally? | Yes — see Section 10 (fallback) |
| How do I speed up updates? | SSH into VPS → docker compose pull && docker compose up -d |
Quick Setup Checklist
# ─── 1. GENERATE PAT ───────────────────────────────────
# GitHub → Settings → Developer settings → Personal access tokens
# Scope: write:packages
# Save the token!
# ─── 2. ADD SECRETS TO REPO ────────────────────────────
# GitHub → USERNAME/bootcamp → Settings → Secrets → Actions
# Add: CR_USER = your-username
# Add: CR_PAT = ghp_your_token
# ─── 3. SET WORKFLOW PERMISSIONS ───────────────────────
# GitHub → USERNAME/bootcamp → Settings → Actions → General
# Workflow permissions: "Read and write permissions"
# ─── 4. VERIFY WORKFLOW FILE EXISTS ────────────────────
ls .github/workflows/deploy.yml
# ─── 5. TRIGGER FIRST BUILD ───────────────────────────
# Option A: Push a change to main
git push origin main
# Option B: Manual trigger via Actions UI
# https://github.com/USERNAME/bootcamp/actions → Run workflow
# ─── 6. WAIT FOR BUILD (~5-10 min first time) ─────────
# Monitor: https://github.com/USERNAME/bootcamp/actions
# ─── 7. VERIFY PACKAGES CREATED ───────────────────────
# Check: https://github.com/USERNAME?tab=packages
# ─── 8. LINK PACKAGES TO REPO (CRITICAL!) ─────────────
# Each package → Settings → Manage Actions access → Add repo → Write
# ⚠️ Do this IMMEDIATELY after first build or next build fails!
# ─── 9. DEPLOY ON VPS ─────────────────────────────────
ssh vps
cd ~/kanban-prod
docker compose pull
docker compose up -d
# ─── 10. DONE! Future pushes auto-deploy via Watchtower