BlogTutorialsGitHub Actions CI/CD: Deploy to VPS Automatically (2025)

GitHub Actions CI/CD: Deploy to VPS Automatically (2025)

Adrian Silaghi
Adrian Silaghi
January 17, 2026
16 min read
3 views
#github actions #ci/cd #deployment #vps #automation #devops #continuous integration #continuous deployment #ssh
GitHub Actions CI/CD: Deploy to VPS Automatically (2025)

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

  1. Go to your GitHub repository
  2. Settings β†’ Secrets and variables β†’ Actions
  3. Click "New repository secret"
  4. 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?

  1. Create a VPS on DanubeData if you haven't already
  2. Set up SSH key authentication
  3. Add secrets to GitHub
  4. Create your workflow file
  5. 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.

Share this article

Ready to Get Started?

Deploy your infrastructure in minutes with DanubeData's managed services.