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 domainwww.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
- Open browser:
https://www.yourdomain.com - Should see:
- ✅ Valid SSL certificate (green padlock)
- ✅ Your website loading
- ✅ No certificate warnings
- Test redirect (if configured):
https://yourdomain.com→https://www.yourdomain.com - 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_NAMEto 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
- Go to your GitHub repository
- Click Actions tab (top menu)
- You should see a workflow running: “Build and Deploy to VPS”
- Click on it to see live progress
Workflow steps:
- ✅ Checkout code (~5 seconds)
- ✅ Setup Node.js (~10 seconds)
- ✅ Install dependencies (~30-60 seconds)
- ✅ Build (~10-30 seconds)
- ✅ Setup SSH (~2 seconds)
- ✅ Deploy to VPS (~5-15 seconds depending on file size)
- ✅ Restart container (~2 seconds)
- ✅ Cleanup (~1 second)
Total time: ~1-2 minutes
Step 3.6: Verify Auto-Deployment
Once the workflow completes successfully:
- Check your website:
https://www.yourdomain.com - 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:
- Push triggers GitHub Actions
- GitHub builds your site
- Deploys to VPS automatically
- Site updates in ~1-2 minutes
- 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
digshows 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:
- Go to GitHub:
Settings → Secrets and variables → Actions - Check if
VPS_HOSTexists - If missing: New repository secret
- Name:
VPS_HOST - Value: Your VPS IP (e.g.,
123.45.67.89)
- Name:
- 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) orCmd+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:
- Verify SSH key in VPS authorized_keys:
# On VPS
cat ~/.ssh/authorized_keys | grep github-actions
# Should see your key
- 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
- 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