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_IPsecret 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.
- Key Takeaways
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.
| 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 |
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
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) |
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 |
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
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
npm install -g pm2
pm2 start npm --name "myapp" -- start
pm2 save
pm2 startup
# Run the command it outputs to enable PM2 on boot
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"
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.
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
- 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
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/
Having a rollback plan is important for production deployments.
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 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
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.
| 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 |
✅ What you set up:
mainssh-keyscan for reliable host verificationThe 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.
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.
👉 Get started with Tencent Cloud Lighthouse
👉 View current pricing and launch promotions
👉 Explore all active deals and offers