Pushing code, SSHing into a server, pulling changes, and manually restarting a process is a deploy workflow most developers outgrow fast — it's error prone, ha…
Pushing code, SSHing into a server, pulling changes, and manually restarting a process is a deploy workflow most developers outgrow fast — it's error-prone, hard to repeat consistently, and easy to forget a step under pressure. GitHub Actions lets you automate testing and deployment directly from your repository, so every push either gets validated automatically or ships itself. This guide builds a complete pipeline: run tests on every push, then deploy to a server automatically when changes land on main.
Prerequisites / What You'll Need
- A project hosted on GitHub
- A test suite (even a basic one — Jest, pytest, etc.)
- A server to deploy to, with SSH access
- Basic familiarity with YAML
- 30–40 minutes
Step 1: Understand the Building Blocks
A GitHub Actions pipeline is defined in a YAML file under .github/workflows/. The key concepts:
- Workflow — the whole automation, triggered by an event (push, pull request, schedule)
- Job — a set of steps that run on a fresh virtual machine
- Step — an individual command or reusable action (a packaged unit of automation others have published)
- Secrets — encrypted values (API keys, SSH credentials) stored in your repo settings, never in code
Step 2: Create the Workflow File
In your repository, create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
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 linter
run: npm run lint
- name: Run tests
run: npm test
A few things worth noting:
on.push.branches and on.pull_request.branches mean tests run both on direct pushes and on every pull request — catching problems before a merge, not just after.
cache: 'npm' speeds up subsequent runs significantly by caching node_modules between workflow runs instead of reinstalling from scratch every time.
- Using
npm ci (not npm install) here matters for the same reason it matters in a Dockerfile — exact, reproducible installs from the lockfile.
Commit and push this file, then check the Actions tab on GitHub — you should see the workflow run automatically.
Step 3: Add a Build Status Badge (Optional but Useful)
In your README.md, add:

This gives anyone viewing your repo an instant visual signal of whether the latest code is passing.
Step 4: Generate a Deploy SSH Key
For the deployment job, GitHub Actions needs a way to SSH into your server. Generate a dedicated keypair just for this purpose — never reuse your personal SSH key:
ssh-keygen -t ed25519 -f deploy_key -C "github-actions-deploy" -N ""
Add the public key to your server's authorized keys:
ssh-copy-id -i deploy_key.pub deploy@your-server-ip
Step 5: Store Secrets in GitHub
In your repo, go to Settings → Secrets and variables → Actions → New repository secret, and add:
DEPLOY_SSH_KEY — the contents of the private key (deploy_key)
DEPLOY_HOST — your server's IP or hostname
DEPLOY_USER — the SSH user (e.g., deploy)
Security note: Once you've added the private key as a secret, delete the local deploy_key file or store it somewhere secure outside the repo — it should never be committed, even accidentally.
Step 6: Add the Deployment Job
Extend .github/workflows/ci.yml with a second job that only runs after tests pass, and only on main:
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
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.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /var/www/myapp
git pull origin main
npm ci --production
pm2 restart myapp
Two details doing important work here:
needs: test — this job won't even start unless the test job succeeds. Broken code never reaches your server.
if: github.ref == 'refs/heads/main' && github.event_name == 'push' — this ensures deployment only fires on direct pushes to main, not on every pull request from a contributor (which could otherwise let someone trigger a deploy from a fork).
Step 7: Watch It Run
Push a commit to main and open the Actions tab. You'll see the test job run first, then deploy kick off automatically once it passes. Click into either job to see live logs — genuinely useful for catching exactly where something fails without SSHing in to check manually.
Step 8: Add a Notification (Optional)
Knowing about a failed deploy immediately is worth the two extra minutes to set up. Add a Slack notification step at the end of the deploy job:
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1.27.0
with:
payload: '{"text":"🚨 Deploy failed for ${{ github.repository }}"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() means this step only runs if a prior step in the job failed — silent on success, loud on failure.
Common Pitfalls / Troubleshooting
- Secrets not showing up in logs — by design. GitHub automatically masks secret values in workflow logs. If something's failing and you suspect a bad secret, don't try to
echo it to debug — instead verify it was set correctly in repo settings, or test the SSH connection manually with the same key.
- Deploy job running on pull requests from forks. Without the
if condition shown above, a malicious PR could potentially trigger your deploy job. Always scope deploy jobs tightly to push events on protected branches.
npm ci failing in CI but npm install working locally. This almost always means your package-lock.json is out of sync with package.json — regenerate it locally and commit the update.
- SSH connection timing out. Check that your server's firewall allows inbound SSH from GitHub's IP ranges (they're published and rotate, so allowing all of port 22 with key-only auth is usually simpler than IP allowlisting).
- Workflow not triggering at all. Double-check the file is actually in
.github/workflows/ (not .github/workflow/ — a common typo) and has a valid .yml extension.
- Tests pass locally but fail in CI. Usually an environment difference — a missing env variable, a different Node/Python version, or a test that depends on local file state. Pin exact versions in your workflow to match your local dev environment.
Wrapping Up
With this in place, "deploying" stops being a manual ritual and becomes something that just happens correctly every time code lands on main — with tests as a gate in front of it. That consistency is worth more than the time it takes to set up; it removes an entire category of human error from your release process.
From here, reasonable next steps include adding a staging environment that deploys from develop before anything reaches main, incorporating automated database migrations into the deploy step, and looking at GitHub Environments for manual approval gates on production deploys.
Further Reading