Manual deployments are error-prone and time-consuming. Every time you SSH into your server, run git pull, restart services, and hope nothing breaksβyou're wasting time and risking mistakes.
GitHub Actions automates this entirely. Push to main, and your code deploys automatically. This guide shows you how to set up CI/CD pipelines for deploying any application to your VPS.
Why GitHub Actions for VPS Deployment?
- Free: 2,000 minutes/month for private repos, unlimited for public
- Native integration: No third-party services needed
- Flexible: Run tests, build, and deploy in one workflow
- Secure: Secrets management built-in
Prerequisites
- A VPS with SSH access (create one on DanubeData)
- A GitHub repository with your code
- Basic knowledge of SSH and your application's deployment process
Step 1: Set Up SSH Key Authentication
Generate a Deploy Key
# On your local machine, generate a key specifically for deployments
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy
# This creates:
# ~/.ssh/github_actions_deploy (private key - add to GitHub Secrets)
# ~/.ssh/github_actions_deploy.pub (public key - add to server)
Add Public Key to VPS
# SSH into your VPS
ssh root@your-server
# Create deploy user if not exists
adduser deploy
usermod -aG sudo deploy
# Add public key to deploy user
mkdir -p /home/deploy/.ssh
nano /home/deploy/.ssh/authorized_keys
# Paste the content of github_actions_deploy.pub
# Set permissions
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
# Test connection from local machine
ssh -i ~/.ssh/github_actions_deploy deploy@your-server
Add Private Key to GitHub Secrets
- Go to your GitHub repository
- Settings β Secrets and variables β Actions
- Click "New repository secret"
- Add these secrets:
VPS_HOST: your-server-ip
VPS_USER: deploy
VPS_SSH_KEY: (paste entire private key content)
VPS_PORT: 22 (optional, if using non-standard port)
Step 2: Basic Deployment Workflow
Create .github/workflows/deploy.yml in your repository:
Simple SSH Deploy
name: Deploy to VPS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT || 22 }}
script: |
cd /var/www/myapp
git pull origin main
echo "Deployment complete!"
Framework-Specific Workflows
Node.js / Next.js Deployment
name: Deploy Node.js App
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/myapp
# Pull latest code
git pull origin main
# Install dependencies
npm ci --production
# Build application
npm run build
# Restart PM2 process
pm2 restart myapp || pm2 start npm --name myapp -- start
echo "Node.js deployment complete!"
Laravel Deployment
name: Deploy Laravel App
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, mysql
coverage: none
- name: Install Composer dependencies
run: composer install --no-progress --prefer-dist --optimize-autoloader
- name: Copy .env
run: cp .env.example .env
- name: Generate key
run: php artisan key:generate
- name: Run tests
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
run: php artisan test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/myapp
# Enable maintenance mode
php artisan down --retry=60
# Pull latest code
git pull origin main
# Install/update dependencies
composer install --no-dev --optimize-autoloader
# Run migrations
php artisan migrate --force
# Clear and cache config
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Restart queue workers
php artisan queue:restart
# Restart PHP-FPM
sudo systemctl reload php8.3-fpm
# Disable maintenance mode
php artisan up
echo "Laravel deployment complete!"
Django Deployment
name: Deploy Django App
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-django
- name: Run tests
env:
DATABASE_URL: postgres://test:test@localhost:5432/test_db
DJANGO_SETTINGS_MODULE: myapp.settings.test
run: pytest
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/myapp
# Activate virtual environment
source venv/bin/activate
# Pull latest code
git pull origin main
# Install dependencies
pip install -r requirements.txt
# Run migrations
python manage.py migrate --noinput
# Collect static files
python manage.py collectstatic --noinput
# Restart Gunicorn
sudo systemctl restart myapp
echo "Django deployment complete!"
Docker Deployment
name: Deploy with Docker
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
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/myapp
# Log in to GitHub Container Registry
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
# Pull latest image
docker compose pull
# Restart with new image
docker compose up -d
# Clean up old images
docker image prune -f
echo "Docker deployment complete!"
Advanced Patterns
Zero-Downtime Deployment
name: Zero-Downtime Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Zero-downtime deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
# Configuration
APP_DIR="/var/www/myapp"
RELEASES_DIR="$APP_DIR/releases"
CURRENT_LINK="$APP_DIR/current"
SHARED_DIR="$APP_DIR/shared"
RELEASE_NAME=$(date +%Y%m%d%H%M%S)
NEW_RELEASE="$RELEASES_DIR/$RELEASE_NAME"
# Create new release directory
mkdir -p $NEW_RELEASE
# Clone code to new release
git clone --depth 1 git@github.com:youruser/yourrepo.git $NEW_RELEASE
# Link shared files (uploads, .env, etc.)
ln -sf $SHARED_DIR/.env $NEW_RELEASE/.env
ln -sf $SHARED_DIR/storage $NEW_RELEASE/storage
# Install dependencies
cd $NEW_RELEASE
composer install --no-dev --optimize-autoloader
# Run migrations
php artisan migrate --force
# Cache config
php artisan config:cache
php artisan route:cache
# Atomic switch: update symlink
ln -sfn $NEW_RELEASE $CURRENT_LINK
# Reload PHP-FPM (graceful)
sudo systemctl reload php8.3-fpm
# Keep only last 5 releases
cd $RELEASES_DIR && ls -t | tail -n +6 | xargs -r rm -rf
echo "Zero-downtime deployment complete!"
Environment-Specific Deployments
name: Deploy to Environment
on:
push:
branches:
- main
- staging
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Set environment variables
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "DEPLOY_ENV=production" >> $GITHUB_ENV
echo "VPS_HOST=${{ secrets.PROD_VPS_HOST }}" >> $GITHUB_ENV
echo "APP_DIR=/var/www/myapp" >> $GITHUB_ENV
else
echo "DEPLOY_ENV=staging" >> $GITHUB_ENV
echo "VPS_HOST=${{ secrets.STAGING_VPS_HOST }}" >> $GITHUB_ENV
echo "APP_DIR=/var/www/myapp-staging" >> $GITHUB_ENV
fi
- name: Deploy to ${{ env.DEPLOY_ENV }}
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ env.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd ${{ env.APP_DIR }}
git pull origin ${{ github.ref_name }}
# ... rest of deployment
Deployment with Slack Notifications
name: Deploy with Notifications
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Notify Slack - Starting
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": "π Deployment starting for ${{ github.repository }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- name: Deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/myapp
git pull origin main
# deployment commands...
- name: Notify Slack - Success
if: success()
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": "β
Deployment successful for ${{ github.repository }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- name: Notify Slack - Failure
if: failure()
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": "β Deployment FAILED for ${{ github.repository }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Security Best Practices
1. Use Dedicated Deploy User
# On VPS: Create deploy user with limited permissions
adduser deploy
# Only give sudo access for specific commands
echo "deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl reload php8.3-fpm" | sudo tee /etc/sudoers.d/deploy
2. Limit SSH Key Permissions
# On VPS: Restrict the deploy key to specific commands
# In /home/deploy/.ssh/authorized_keys:
command="/home/deploy/deploy-script.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... github-actions-deploy
3. Use GitHub Environments
# In workflow, add environment protection
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://yourdomain.com
# Then in GitHub Settings β Environments β production:
# - Add required reviewers
# - Add branch protection
# - Add deployment secrets
4. Rotate SSH Keys Regularly
# Generate new key
ssh-keygen -t ed25519 -C "github-actions-deploy-$(date +%Y%m)" -f new_deploy_key
# Update on server
# Update in GitHub Secrets
# Delete old key
Troubleshooting
Permission Denied
# Check SSH key format (should start with -----BEGIN OPENSSH PRIVATE KEY-----)
# Ensure no extra whitespace in GitHub secret
# Verify public key is in authorized_keys on server
# Check file permissions: chmod 600 ~/.ssh/authorized_keys
Host Key Verification Failed
# Add known_hosts step before deploy
- name: Add known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
Deployment Hangs
# Add timeout to SSH action
- uses: appleboy/ssh-action@v1.0.3
with:
timeout: 60s # Fail if takes longer than 60 seconds
command_timeout: 30m # Individual command timeout
Complete Example Repository Structure
myapp/
βββ .github/
β βββ workflows/
β βββ deploy.yml # Main deployment workflow
β βββ test.yml # Run tests on PR
β βββ staging.yml # Deploy to staging
βββ src/
βββ tests/
βββ Dockerfile
βββ docker-compose.yml
βββ README.md
Get Started
Ready to automate your deployments?
- Create a VPS on DanubeData if you haven't already
- Set up SSH key authentication
- Add secrets to GitHub
- Create your workflow file
- Push to main and watch the magic happen
DanubeData VPS for CI/CD:
- Fast NVMe storage for quick deployments
- 20TB bandwidth for pulling images and dependencies
- SSH access with key authentication
- β¬4.49/mo starting price
π Create Your Deployment VPS
Need help setting up CI/CD? Contact our team for assistance.