Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Website Deployment with GitHub Actions CI/CD

Overview

This phase covers deploying production websites to your VPS with automatic deployment via GitHub Actions. Once configured, you can simply push code to GitHub and your website automatically builds and deploys to your VPS.

Example Website: Boeygembos

Details:

  • Domain: www.boeygembos.be
  • Tech: React (Vite)
  • Build: npm run build → dist/
  • Repo: https://github.com/boeygembos/boeygembos.github.io
  • Subfolder: Code in boeygembos-app/ subdirectory

Architecture

File Structure on VPS

/opt/docker/websites/boeygembos/
├── docker-compose.yml       # Container definition + Traefik labels
├── nginx.conf              # Nginx config for React SPA routing
└── html/                   # Website files (deployed here)
    ├── index.html
    ├── assets/
    └── ...

Part 1: Prepare DNS Records

Step 1.3: Get Your VPS IP Addresses

IPv4 (required):

# On your VPS
curl -4 ifconfig.me

IPv6 (optional):

# Check if you have IPv6
curl -6 ifconfig.me

Or check Hetzner Cloud Console - it shows both IPs on your server overview.

Note about IPv6:

  • If your VPS IP ends with ::/64, you have a subnet but no single assigned address
  • For simplicity, you can skip IPv6 and delete AAAA records
  • IPv4 is sufficient for 99.9% of users

Step 1.4: Update DNS Records

Change these records in Domain.com:

Record 1: Root domain

Type: A
Host: @ (or root domain)
Points To: YOUR_VPS_IPv4
TTL: 3600 (1 hour)

Record 2: www subdomain

Type: A
Host: www (or "Other Host" → www)
Points To: YOUR_VPS_IPv4
TTL: 3600 (1 hour)

IPv6 (optional):

  • If you have IPv6: Update AAAA records to your VPS IPv6 (without the /64)
  • If you don’t have IPv6: Delete both AAAA records

Step 1.5: Wait for DNS Propagation

DNS changes take time to propagate:

  • Typical: 2-10 minutes
  • Maximum: Up to the old TTL value (could be hours)

Verify DNS:

# Check if DNS resolves to your VPS IP
dig yourdomain.com +short
dig www.yourdomain.com +short

# Should show your VPS IP

Or use online tools:

  • https://dnschecker.org/
  • https://www.whatsmydns.net/

Part 2: Deploy Website to VPS

Step 2.1: Create Website Directory

SSH into your VPS:

# Create directory for your website
mkdir -p /opt/docker/websites/YOUR_SITE_NAME
cd /opt/docker/websites/YOUR_SITE_NAME

Replace YOUR_SITE_NAME with something descriptive (e.g., boeygembos, client-site-1).

Step 2.2: Get Built Files

# On your local machine:
git clone YOUR_GITHUB_REPO_URL
cd YOUR_REPO
cd SUBDIRECTORY_IF_ANY
npm install
npm run build

# Upload to VPS (replace with your details)
scp -r dist/* root@YOUR_VPS_IP:/opt/docker/websites/YOUR_SITE_NAME/html/

Step 2.3: Create Nginx Configuration

For React/Vue/Angular SPAs (single-page applications), you need special routing:

nano /opt/docker/websites/YOUR_SITE_NAME/nginx.conf

Paste this configuration:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Enable gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Cache static assets
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # React/Vue/Angular Router - serve index.html for all routes
    location / {
        try_files $uri $uri/ /index.html;
    }
}

For plain HTML sites (no client-side routing), you can use a simpler config or skip this file entirely.

Save with Ctrl+X, Y, Enter.

Step 2.4: Create Docker Compose File

nano /opt/docker/websites/YOUR_SITE_NAME/docker-compose.yml

Basic configuration (single domain):

version: '3.8'

services:
  YOUR_SITE_NAME:
    image: nginx:alpine
    container_name: YOUR_SITE_NAME
    restart: unless-stopped
    networks:
      - web
    volumes:
      - ./html:/usr/share/nginx/html:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.YOUR_SITE_NAME.rule=Host(`www.yourdomain.com`)"
      - "traefik.http.routers.YOUR_SITE_NAME.entrypoints=websecure"
      - "traefik.http.routers.YOUR_SITE_NAME.tls.certresolver=letsencrypt"
      - "traefik.http.services.YOUR_SITE_NAME.loadbalancer.server.port=80"

networks:
  web:
    external: true

Advanced: With www redirect (redirects non-www to www):

version: '3.8'

services:
  YOUR_SITE_NAME:
    image: nginx:alpine
    container_name: YOUR_SITE_NAME
    restart: unless-stopped
    networks:
      - web
    volumes:
      - ./html:/usr/share/nginx/html:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    labels:
      - "traefik.enable=true"
      
      # Main domain: www.yourdomain.com
      - "traefik.http.routers.YOUR_SITE_NAME.rule=Host(`www.yourdomain.com`)"
      - "traefik.http.routers.YOUR_SITE_NAME.entrypoints=websecure"
      - "traefik.http.routers.YOUR_SITE_NAME.tls.certresolver=letsencrypt"
      
      # Redirect non-www to www
      - "traefik.http.routers.YOUR_SITE_NAME-redirect.rule=Host(`yourdomain.com`)"
      - "traefik.http.routers.YOUR_SITE_NAME-redirect.entrypoints=websecure"
      - "traefik.http.routers.YOUR_SITE_NAME-redirect.tls.certresolver=letsencrypt"
      - "traefik.http.routers.YOUR_SITE_NAME-redirect.middlewares=redirect-to-www"
      - "traefik.http.middlewares.redirect-to-www.redirectregex.regex=^https://yourdomain.com/(.*)"
      - "traefik.http.middlewares.redirect-to-www.redirectregex.replacement=https://www.yourdomain.com/$${1}"
      - "traefik.http.middlewares.redirect-to-www.redirectregex.permanent=true"
      
      - "traefik.http.services.YOUR_SITE_NAME.loadbalancer.server.port=80"

networks:
  web:
    external: true

Important replacements:

  • YOUR_SITE_NAME → Your site name (lowercase, no spaces)
  • yourdomain.com → Your actual domain
  • www.yourdomain.com → Your www subdomain

Save with Ctrl+X, Y, Enter.

Step 2.5: Deploy the Website

cd /opt/docker/websites/YOUR_SITE_NAME

# Verify files are in place
ls -la html/

# Should see index.html and other files

# Start the container
docker compose up -d

# Check logs
docker compose logs -f

Watch for Traefik requesting SSL certificates. Press Ctrl+C when done.

Step 2.6: Verify Website Works

  1. Open browser: https://www.yourdomain.com
  2. Should see:
    • ✅ Valid SSL certificate (green padlock)
    • ✅ Your website loading
    • ✅ No certificate warnings
  3. Test redirect (if configured): https://yourdomain.com → https://www.yourdomain.com
  4. Check Traefik dashboard: https://traefik.mydomain.com
    • See your router in HTTP Routers
    • See SSL certificate in TLS section

Part 3: Setup GitHub Actions CI/CD

Now let’s automate deployments so you can just push to GitHub!

Step 3.1: Create SSH Key for GitHub Actions

On your VPS:

# Create dedicated SSH key for deployments
ssh-keygen -t ed25519 -C "github-actions-YOUR_SITE" -f ~/.ssh/github-actions-YOUR_SITE -N ""

# Add public key to authorized_keys
cat ~/.ssh/github-actions-YOUR_SITE.pub >> ~/.ssh/authorized_keys

# Display private key (you'll copy this)
cat ~/.ssh/github-actions-YOUR_SITE

Copy the entire private key output, including:

  • -----BEGIN OPENSSH PRIVATE KEY-----
  • All the encoded content
  • -----END OPENSSH PRIVATE KEY-----

Why Ed25519?

  • Modern, secure cryptography
  • Smaller keys than RSA
  • Faster signing/verification
  • Widely supported

Alternative with RSA:

ssh-keygen -t rsa -b 4096 -C "github-actions-YOUR_SITE" -f ~/.ssh/github-actions-YOUR_SITE -N ""

Step 3.2: Add GitHub Secrets

Go to your GitHub repository settings:

URL format:

https://github.com/USERNAME/REPO_NAME/settings/secrets/actions

Click New repository secret and add these three secrets:

Secret 1: SSH Private Key

  • Name: VPS_SSH_KEY
  • Value: Paste the entire private key (from Step 3.1)
  • Click Add secret

Secret 2: VPS IP Address

  • Name: VPS_HOST
  • Value: Your VPS IP address (e.g., 123.45.67.89)
  • Click Add secret

Secret 3: SSH User

  • Name: VPS_USER
  • Value: root
  • Click Add secret

You should now see three secrets listed (values are hidden for security).

Step 3.3: Create GitHub Actions Workflow

On your local machine, in your repository:

# Navigate to your repo
cd /path/to/your-repo

# Create workflows directory
mkdir -p .github/workflows

# Create workflow file
nano .github/workflows/deploy.yml

Basic workflow (code in repo root):

name: Build and Deploy to VPS

on:
  push:
    branches:
      - main  # Change to your branch name (main, master, release, etc.)

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm install
      
      - name: Build
        run: npm run build
      
      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
      
      - name: Deploy to VPS
        run: |
          rsync -avz --delete \
            -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
            dist/ \
            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/opt/docker/websites/YOUR_SITE_NAME/html/
      
      - name: Restart container
        run: |
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
            "cd /opt/docker/websites/YOUR_SITE_NAME && docker compose restart"
      
      - name: Cleanup
        if: always()
        run: rm -f ~/.ssh/deploy_key

Advanced workflow (code in subdirectory):

If your code is in a subdirectory (like boeygembos-app/):

name: Build and Deploy to VPS

on:
  push:
    branches:
      - release  # Your branch name

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    # Set default working directory
    defaults:
      run:
        working-directory: YOUR_SUBDIRECTORY_NAME
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: 'YOUR_SUBDIRECTORY_NAME/package-lock.json'
      
      - name: Install dependencies
        run: npm install
      
      - name: Build
        run: npm run build
      
      - name: Setup SSH
        working-directory: ${{ github.workspace }}
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
      
      - name: Deploy to VPS
        run: |
          rsync -avz --delete \
            -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
            dist/ \
            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/opt/docker/websites/YOUR_SITE_NAME/html/
      
      - name: Restart container
        working-directory: ${{ github.workspace }}
        run: |
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
            "cd /opt/docker/websites/YOUR_SITE_NAME && docker compose restart"
      
      - name: Cleanup
        if: always()
        working-directory: ${{ github.workspace }}
        run: rm -f ~/.ssh/deploy_key

Important customizations:

  • Line 5: Change branch name (main, master, release, etc.)
  • Line 11 (advanced): Change YOUR_SUBDIRECTORY_NAME to your subdirectory
  • Line 17 (advanced): Update cache path to match your subdirectory
  • Line 38 & 45: Change dist/ if your build outputs to a different directory (build/, out/, etc.)
  • Line 39 & 46: Change /opt/docker/websites/YOUR_SITE_NAME/ to your actual path

Step 3.4: Commit and Push Workflow

# Add the workflow file
git add .github/workflows/deploy.yml

# Commit
git commit -m "Add GitHub Actions auto-deployment workflow"

# Push (use your branch name)
git push origin main  # or master, release, etc.

Step 3.5: Watch First Deployment

  1. Go to your GitHub repository
  2. Click Actions tab (top menu)
  3. You should see a workflow running: “Build and Deploy to VPS”
  4. Click on it to see live progress

Workflow steps:

  1. ✅ Checkout code (~5 seconds)
  2. ✅ Setup Node.js (~10 seconds)
  3. ✅ Install dependencies (~30-60 seconds)
  4. ✅ Build (~10-30 seconds)
  5. ✅ Setup SSH (~2 seconds)
  6. ✅ Deploy to VPS (~5-15 seconds depending on file size)
  7. ✅ Restart container (~2 seconds)
  8. ✅ Cleanup (~1 second)

Total time: ~1-2 minutes

Step 3.6: Verify Auto-Deployment

Once the workflow completes successfully:

  1. Check your website: https://www.yourdomain.com
  2. Should show the latest version

Part 4: Test the Full Workflow

Make a small change to test automatic deployment:

# In your local repo
# Make any small change (edit a title, add a comment, etc.)

# Stage changes
git add .

# Commit
git commit -m "Test auto-deployment workflow"

# Push to trigger deployment
git push origin YOUR_BRANCH_NAME

What happens:

  1. Push triggers GitHub Actions
  2. GitHub builds your site
  3. Deploys to VPS automatically
  4. Site updates in ~1-2 minutes
  5. No manual steps needed!

Watch it in GitHub Actions tab - you should see a new workflow run automatically.


Troubleshooting

Common Issues and Solutions

Issue 1: DNS Not Resolving

Symptoms:

  • Can’t access website
  • dig shows old IP or no result

Solutions:

# Check DNS resolution
dig yourdomain.com +short
dig www.yourdomain.com +short

# Should show your VPS IP

# If not:
# 1. Wait longer (up to old TTL)
# 2. Check DNS records in registrar
# 3. Flush local DNS cache:
#    Linux: sudo systemd-resolve --flush-caches
#    macOS: sudo dscacheutil -flushcache

Issue 2: Certificate Not Generating

Symptoms:

  • Browser shows certificate error
  • “Unable to obtain certificate” in logs

Solutions:

# 1. Verify DNS points to VPS
dig yourdomain.com +short

# 2. Check Traefik logs
cd /opt/docker/traefik
docker compose logs | grep -i acme

# 3. Ensure port 80 is accessible (needed for HTTP challenge)
curl -I http://yourdomain.com

# 4. Check Let's Encrypt rate limits
# https://letsencrypt.org/docs/rate-limits/

# 5. Restart Traefik if needed
docker compose restart

Issue 3: GitHub Actions - Dependencies Lock File Not Found

Error message:

Dependencies lock file is not found in /home/runner/work/...
Supported file patterns: package-lock.json, npm-shrinkwrap.json, yarn.lock

Solution 1: Commit lock file (recommended)

# Create lock file if it doesn't exist
npm install

# Add to git
git add package-lock.json
git commit -m "Add package-lock.json for CI/CD"
git push

Solution 2: Use npm install instead of npm ci

In .github/workflows/deploy.yml, change:

- name: Install dependencies
  run: npm ci  # Change this...

To:

- name: Install dependencies
  run: npm install  # ...to this

Issue 4: GitHub Actions - VPS_HOST Secret Empty

Error message:

usage: ssh-keyscan [-46cDHv] [-f file] [-O option] [-p port] [-T timeout]
Error: Process completed with exit code 1

Solution:

The secret is missing or empty. Add it:

  1. Go to GitHub: Settings → Secrets and variables → Actions
  2. Check if VPS_HOST exists
  3. If missing: New repository secret
    • Name: VPS_HOST
    • Value: Your VPS IP (e.g., 123.45.67.89)
  4. If exists but empty: Edit and update value

Same for VPS_USER - should be root.

Issue 5: GitHub Actions - dist/ Folder Not Found

Error message:

rsync: change_dir "/home/runner/.../dist" failed: No such file or directory

Possible causes:

Cause 1: Build output is different directory

Check your build output:

# Locally
npm run build
ls -la

# Check what directory was created
# Could be: build/, dist/, out/, .output/, etc.

In deploy.yml, update the rsync path:

- name: Deploy to VPS
  run: |
    rsync -avz --delete \
      -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
      build/ \  # Change 'dist/' to your actual output directory
      ${{ secrets.VPS_USER }}@...

Cause 2: Build failed silently

Add debug steps after build:

- name: Build
  run: npm run build

- name: Check build output
  run: |
    echo "Build completed"
    ls -la
    [ -d "dist" ] && echo "dist exists" || echo "dist NOT found"

Cause 3: Working directory issue (subdirectory projects)

If your code is in a subdirectory, ensure you’re using defaults.run.working-directory correctly. See the advanced workflow example above.

Issue 6: Container Won’t Start

Check container logs:

cd /opt/docker/websites/YOUR_SITE_NAME
docker compose logs

# Common issues:
# - Port conflict
# - Volume mount path wrong
# - nginx.conf syntax error

Test nginx config:

# Test config syntax
docker run --rm -v $(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:alpine nginx -t

Issue 7: Website Shows Old Content

After deployment, site doesn’t update:

Solution 1: Clear browser cache

  • Hard refresh: Ctrl+Shift+R (Linux/Windows) or Cmd+Shift+R (Mac)

Solution 2: Verify files were deployed

# On VPS
ls -la /opt/docker/websites/YOUR_SITE_NAME/html/
# Check file timestamps - should be recent

Solution 3: Force container recreation

cd /opt/docker/websites/YOUR_SITE_NAME
docker compose down
docker compose up -d --force-recreate

Issue 8: React Router Not Working (404 on refresh)

Symptoms:

  • Homepage works
  • Navigation works
  • Refreshing on a route gives 404

Solution:

Ensure you have the SPA nginx config (Step 2.3) with:

location / {
    try_files $uri $uri/ /index.html;
}

This serves index.html for all routes, letting React Router handle routing.

Issue 9: GitHub Actions - Permission Denied (SSH)

Error message:

Permission denied (publickey)

Solutions:

  1. Verify SSH key in VPS authorized_keys:
# On VPS
cat ~/.ssh/authorized_keys | grep github-actions
# Should see your key
  1. Regenerate and re-add key:
# On VPS - create new key
ssh-keygen -t ed25519 -C "github-actions-site" -f ~/.ssh/github-actions-site -N ""
cat ~/.ssh/github-actions-site.pub >> ~/.ssh/authorized_keys

# Display private key
cat ~/.ssh/github-actions-site

# Copy and update VPS_SSH_KEY secret in GitHub
  1. Test SSH connection locally:
# Save private key to file temporarily
nano test_key
# Paste private key
chmod 600 test_key

# Test connection
ssh -i test_key root@YOUR_VPS_IP "echo 'SSH works!'"

# Remove test file
rm test_key