Technology Encyclopedia Home >Configure GitHub Actions to Auto-Deploy to a VPS — Push to Main, Deploy to Production Automatically

Configure GitHub Actions to Auto-Deploy to a VPS — Push to Main, Deploy to Production Automatically

Manual deployment involves a ritual: SSH into the server, pull the latest code, install dependencies, restart the process. Every time. After the 20th time, you start thinking there has to be a better way.

GitHub Actions makes this fully automatic. You push to main, GitHub runs your tests, and if they pass, your application is deployed to the server. No terminal, no manual steps, no forgetting to restart PM2.

This guide covers the complete setup: generating an SSH key for deployment, storing it in GitHub, writing the workflow file, and handling the deployment logic on the server side.

The deployment target in this guide is a Tencent Cloud Lighthouse instance running Ubuntu 22.04. A key property that makes GitHub Actions deployments reliable: Lighthouse instances have a static public IP that never changes — your deployment workflow's SERVER_IP secret stays valid indefinitely. The server's SSH access is always available, which is a prerequisite for reliable automated deployments. The console-level firewall also lets you optionally restrict SSH access to GitHub's IP ranges for additional security.


Table of Contents

  1. How Automated Deployment Works
  2. What You Need
  3. Part 1: Generate a Deployment SSH Key
  4. Part 2: Add Secrets to GitHub
  5. Part 3: Configure the Server
  6. Part 4: Write the GitHub Actions Workflow
  7. Part 5: More Complex Deployment Patterns
  8. Part 6: Rollback Strategy
  9. The Thing That Tripped Me Up
  10. Troubleshooting
  11. Summary

  • Key Takeaways
  • Use the appropriate Lighthouse application image to skip manual installation steps where available
  • Lighthouse snapshots provide one-click full-server backup before major changes
  • OrcaTerm browser terminal lets you manage the server from any device
  • CBS cloud disk expansion handles growing storage needs without server migration
  • Console-level firewall + UFW = two independent protection layers

How Automated Deployment Works {#how-it-works}

The flow:

Developer pushes code to GitHub (main branch)
        ↓
GitHub Actions runner starts
        ↓
Install dependencies → Run tests
        ↓
Tests pass? → SSH into production server
        ↓
Pull latest code → Install deps → Restart app
        ↓
Deployment complete

The GitHub Actions runner is GitHub's infrastructure — you don't need to manage it. It connects to your server via SSH using a key pair you provide as a secret.


What You Need {#prerequisites}

Requirement Details
GitHub repository Your application code
Cloud server Ubuntu 22.04 with SSH access
App running on server Node.js + PM2, or Docker, or any process manager
GitHub plan Free plan includes 2,000 minutes/month on public repos; private repos have limits

Part 1: Generate a Deployment SSH Key {#part-1}

Create a dedicated SSH key pair for deployments — don't reuse your personal key.

On your local machine:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_deploy_key -N ""

This creates:

  • ~/.ssh/github_deploy_key — private key (goes to GitHub)
  • ~/.ssh/github_deploy_key.pub — public key (goes to the server)

View the public key:

cat ~/.ssh/github_deploy_key.pub

View the private key (you'll copy this to GitHub):

cat ~/.ssh/github_deploy_key

Part 2: Add Secrets to GitHub {#part-2}

2.1 — Add Secrets to Your Repository

Go to your GitHub repository: Settings → Secrets and variables → Actions → New repository secret

Add the following secrets:

Secret Name Value
SSH_PRIVATE_KEY Contents of ~/.ssh/github_deploy_key (the private key)
SERVER_IP Your Lighthouse instance IP address
SERVER_USER ubuntu (or your deploy user)
APP_DIR /opt/myapp (path to your app on the server)

2.2 — (Optional) Add Environment Variables

For environment-specific config that shouldn't be in your code:

Secret Name Value
DATABASE_URL postgresql://user:pass@localhost/myapp
NODE_ENV production
API_KEY Your API key

Part 3: Configure the Server {#part-3}

3.1 — Add the Deploy Key to the Server

SSH into your Lighthouse server and add the public key:

cat ~/.ssh/authorized_keys
# Add your deploy key:
echo "PASTE_YOUR_PUBLIC_KEY_HERE" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

3.2 — Set Up Your Application Directory

sudo mkdir -p /opt/myapp
sudo chown ubuntu:ubuntu /opt/myapp
cd /opt/myapp
git clone https://github.com/yourusername/yourrepo.git .
npm ci --production

3.3 — Start the App with PM2

npm install -g pm2
pm2 start npm --name "myapp" -- start
pm2 save
pm2 startup
# Run the command it outputs to enable PM2 on boot

Part 4: Write the GitHub Actions Workflow {#part-4}

Create .github/workflows/deploy.yml in your repository:

name: Deploy to Production

on:
  push:
    branches:
      - main

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Set up 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

  deploy:
    name: Deploy to VPS
    runs-on: ubuntu-latest
    needs: test  # Only run if tests pass
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Set up SSH
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
        run: |
          mkdir -p ~/.ssh
          echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
      
      - name: Deploy application
        env:
          SERVER_IP: ${{ secrets.SERVER_IP }}
          SERVER_USER: ${{ secrets.SERVER_USER }}
          APP_DIR: ${{ secrets.APP_DIR }}
        run: |
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
            $SERVER_USER@$SERVER_IP << 'DEPLOY'
              set -e
              cd $APP_DIR
              
              # Pull latest code
              git pull origin main
              
              # Install dependencies
              npm ci --production
              
              # Reload app without downtime
              pm2 reload myapp
              
              echo "Deployment complete at $(date)"
          DEPLOY
      
      - name: Verify deployment
        env:
          SERVER_IP: ${{ secrets.SERVER_IP }}
          SERVER_USER: ${{ secrets.SERVER_USER }}
        run: |
          ssh -i ~/.ssh/deploy_key $SERVER_USER@$SERVER_IP \
            "pm2 status myapp"

Commit and Test

git add .github/workflows/deploy.yml
git commit -m "Add GitHub Actions deployment workflow"
git push origin main

Go to your repository's Actions tab and watch the workflow run. The first run takes a minute or two as GitHub sets up the runner.


Part 5: More Complex Deployment Patterns {#part-5}

Docker Deployment

If your app runs in Docker:

      - name: Deploy Docker container
        run: |
          ssh -i ~/.ssh/deploy_key ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} << 'DEPLOY'
            # Pull latest image
            docker pull ${{ secrets.DOCKER_IMAGE }}:latest
            
            # Stop and remove old container
            docker stop myapp || true
            docker rm myapp || true
            
            # Start new container
            docker run -d \
              --name myapp \
              --restart=always \
              -p 3000:3000 \
              -e NODE_ENV=production \
              -e DATABASE_URL=$DATABASE_URL \
              ${{ secrets.DOCKER_IMAGE }}:latest
          DEPLOY

Build and Push Docker Image in GitHub Actions

      - name: Build and push Docker image
        run: |
          docker build -t ${{ secrets.REGISTRY }}/myapp:${{ github.sha }} .
          docker push ${{ secrets.REGISTRY }}/myapp:${{ github.sha }}
          docker tag ${{ secrets.REGISTRY }}/myapp:${{ github.sha }} ${{ secrets.REGISTRY }}/myapp:latest
          docker push ${{ secrets.REGISTRY }}/myapp:latest

Deploy Only Changed Files (rsync)

For static sites or apps where you want to sync only changed files:

      - name: Sync files to server
        run: |
          rsync -avz --delete \
            -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
            ./dist/ \
            ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}:/var/www/myapp/

Part 6: Rollback Strategy {#part-6}

Having a rollback plan is important for production deployments.

Git-Based Rollback

Tag releases before deploying:

      - name: Create release tag
        run: |
          git tag -a "release-$(date +%Y%m%d%H%M%S)" -m "Automated release"
          git push origin --tags

To rollback, SSH into the server and check out a previous tag:

cd /opt/myapp
git log --oneline -10       # See recent commits
git checkout <previous_commit>
pm2 reload myapp

PM2 Version Tracking

PM2 tracks deployment history:

# On the server, before deploys
pm2 deploy production setup   # First time only

# pm2 keeps the last N deployments
# Rollback:
pm2 deploy production revert 1   # Revert to previous deploy

The Thing That Tripped Me Up {#gotcha}

My deployments were failing with Host key verification failed even though I'd added ssh-keyscan to the workflow.

The issue was subtle: ssh-keyscan was running before the known_hosts file was fully written, and the file wasn't being read correctly due to permissions. Also, I'd been using StrictHostKeyChecking=no in some places but not others.

The consistent fix:

      - name: Set up SSH key and known hosts
        run: |
          mkdir -p ~/.ssh
          chmod 700 ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          # Explicitly scan and save host key
          ssh-keyscan -t ed25519 -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
          chmod 644 ~/.ssh/known_hosts

And in all subsequent SSH commands, use the explicitly created known_hosts:

ssh -i ~/.ssh/deploy_key \
    -o UserKnownHostsFile=~/.ssh/known_hosts \
    $SERVER_USER@$SERVER_IP "command"

This eliminated the intermittent host verification failures.


Troubleshooting {#troubleshooting}

Issue Likely Cause Fix
Permission denied (publickey) Wrong key or not authorized Check public key is in ~/.ssh/authorized_keys on server
Host key verification failed known_hosts issue Use consistent ssh-keyscan setup (see above)
Deploy runs but app not updated PM2 reload didn't pick up changes Use pm2 restart myapp instead of reload for config changes
Workflow doesn't trigger Wrong branch name Check branches: matches your actual default branch
Tests pass locally but fail in Actions Different Node.js version Pin version with node-version: '20' in setup-node
Environment variables not found Secret not added Check Secrets in repository Settings
Git pull fails Diverged history Check for uncommitted changes on server: git status

Summary {#verdict}

What you set up:

  • Dedicated SSH key pair for deployment (not your personal key)
  • GitHub Secrets storing server credentials securely
  • GitHub Actions workflow: run tests → SSH deploy → verify
  • PM2 zero-downtime reload on each successful deploy
  • Deployment only happens when tests pass on main
  • ssh-keyscan for reliable host verification

The result: push code, walk away. GitHub runs the tests, deploys to your server, and the application is live. You only SSH into the server to debug problems or make infrastructure changes.

Frequently Asked Questions {#faq}

What's the difference between CI and CD?
CI (Continuous Integration) automatically runs tests when code is pushed. CD (Continuous Deployment/Delivery) automatically deploys tested code to servers. Together they eliminate manual build-test-deploy cycles.

How do I store secrets (API keys, passwords) in GitHub Actions deployment?
Use the CI/CD platform's secrets management — never put secrets in your repository. Reference them as environment variables in your pipeline config. Lighthouse server credentials go in deployment secrets.

What happens if a deployment fails partway through?
Implement a rollback strategy: tag git commits before deployment, keep the previous release available, and have a procedure to revert. Lighthouse snapshots provide a full-server rollback point.

How do I deploy to multiple environments (staging, production) from the same pipeline?
Use branch-based or tag-based triggers. Pushes to develop deploy to staging; tagged releases or merges to main deploy to production. Configure different deployment targets per environment.

How long should a CI/CD pipeline take?
Aim for under 5 minutes for feedback on a push. If builds are slow, parallelize test suites, cache dependencies, and build Docker layers efficiently. Long pipelines reduce developer productivity.

👉 Get started with Tencent Cloud Lighthouse
👉 View current pricing and launch promotions
👉 Explore all active deals and offers