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:

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 to USERNAME/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:

NameValuePurpose
CR_USERYour GitHub username (e.g., alidev)Authenticates with GHCR
CR_PATYour PAT starting with ghp_...Authorizes push to GHCR

⚠️ The PAT must have write:packages scope. If you only have read: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

  1. Go to: https://github.com/USERNAME/bootcamp/settings/actions
  2. Under Actions permissions: Select Allow all actions and reusable workflows
  3. Click Save

3.2 Workflow Permissions

  1. On the same page, scroll to Workflow permissions
  2. Select Read and write permissions
  3. Click Save

📌 Why "Read and write"? The workflow needs packages: write permission to push Docker images to GHCR. Without this, you'll get 403 Forbidden errors during the push step.

⚠️ 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:

  1. Go to: https://github.com/USERNAME?tab=packages
  2. Click on each package (bootcamp/api, bootcamp/web, bootcamp/admin)
  3. Go to Package Settings (gear icon or right side)
  4. Under Manage Actions access → click Add Repository
  5. Search for bootcamp and add it with Write role
  6. Click Save

Repeat for all three packages.

Direct links (replace USERNAME):

  • https://github.com/users/USERNAME/packages/container/bootcamp%2Fapi/settings
  • https://github.com/users/USERNAME/packages/container/bootcamp%2Fweb/settings
  • https://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 see denied: 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 still ghcr.io/alidev/bootcamp/....


5. Smart Filtering — Only Build What Changed

The workflow detects which folders were modified and only runs the necessary jobs:

Files ChangedJobs That RunJobs 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⏭️ NoneAll 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 paths filter (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 inside apps/, services/, or contracts/ 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/actions to see if a build is already running or completed.

You can trigger the workflow without pushing code:

  1. Go to: https://github.com/USERNAME/bootcamp/actions
  2. Select "Build and Push Docker Images" from the left sidebar
  3. Click "Run workflow" dropdown (right side)
  4. Select branch: main
  5. Click the green "Run workflow" button

6.3 Monitor the Build

  1. Go to: https://github.com/USERNAME/bootcamp/actions
  2. Click on the running workflow
  3. You'll see three jobs: api, web, admin
  4. 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:

  1. Go to: https://github.com/USERNAME?tab=packages
  2. You should see:
    • bootcamp/api
    • bootcamp/web
    • bootcamp/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:packages via write:packages)
  • Watchtower also uses the same PAT

7.2 Making Packages Public (Optional)

If you want anyone to pull your images without authentication:

  1. Go to: https://github.com/users/USERNAME/packages/container/bootcamp%2Fapi/settings
  2. Scroll to Danger Zone
  3. Click Change visibility → select Public
  4. Repeat for bootcamp/web and bootcamp/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:

  1. Make a visible code change (e.g., change a text string in the app)
  2. Commit and push:
    git add .
    git commit -m "feat: update welcome message"
    git push origin main
    
  3. Watch the build: https://github.com/USERNAME/bootcamp/actions
  4. After build completes, either:
    • Wait ~5 minutes for Watchtower, OR
    • SSH into VPS and run docker compose pull && docker compose up -d
  5. 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/amd64 because 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:

  1. ✅ Workflow permissions set to "Read and write" (Section 3.2)
  2. ✅ Secrets CR_USER and CR_PAT configured (Section 2.2)
  3. ✅ PAT has write:packages scope (regenerate if unsure)
  4. ✅ 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_TOKEN is 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 paths filter 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 LogCauseFix
COPY failed: file not found in build contextWrong context or file pathVerify Dockerfile paths match repo structure
npm ERR! network timeoutTransient network issueRe-run the job (click "Re-run failed jobs")
Error: Process completed with exit code 137Out of memory on runnerOptimize Dockerfile (reduce concurrent operations)
Invalid workflow fileYAML syntax errorValidate at yamlchecker.com
Package not foundFirst build, packages don't exist yetThis 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

AspectPAT (current approach)GITHUB_TOKEN
SetupManual token creation + secretsAutomatic, zero setup
ScopeExplicit (write:packages)Inherited from workflow permissions
ExpirationSet by you (or never)Per-workflow-run (auto-expires)
WatchtowerSame PAT works for VPS pullStill need a PAT for Watchtower
RevocationOne token for everythingCan'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_TOKEN for Actions but a separate PAT for Watchtower adds complexity.


13. GitHub Actions Minutes & Billing

Free Tier (Personal Account)

ResourceLimit
Actions minutes2,000 min/month
Packages storage500 MB
Packages data transfer1 GB/month

Estimated Usage Per Build

JobDuration (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_dispatch for testing instead of pushing empty commits

Summary

QuestionAnswer
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
Built with LogoFlowershow