Why Automate Deployments with GitHub Actions?
Manual deployments are error-prone, time-consuming, and stressful. You SSH into a server, pull the latest code, run a build, restart services, and hope nothing breaks. One missed step or typo in a command can bring down your entire application. GitHub Actions eliminates this human error by codifying your deployment process into repeatable, version-controlled workflows that run automatically when you push code.
GitHub Actions is a CI/CD platform built directly into GitHub. It runs your workflows on GitHub-hosted or self-hosted runners, supports any language or framework, and integrates with thousands of community actions. Best of all, it is free for public repositories and includes 2,000 minutes per month for private repos on the free tier.
GitHub Actions Fundamentals
Before we dive into deployment workflows, let us understand the core concepts of GitHub Actions.
| Concept | Description | Example |
|---|---|---|
| Workflow | A YAML file in .github/workflows/ | deploy.yml |
| Trigger | Event that starts the workflow | push, pull_request, workflow_dispatch |
| Job | A set of steps that run on one runner | build, test, deploy |
| Step | Individual task within a job | run: npm test |
| Action | Reusable unit of code | actions/checkout@v4 |
| Runner | Machine that executes workflows | ubuntu-latest, self-hosted |
| Secret | Encrypted variable for sensitive data | secrets.SSH_PRIVATE_KEY |
Your First Deployment Workflow
Let us start with a simple workflow that builds your application and deploys it to a server via SSH whenever you push to the main branch.
name: Deploy to Production
on:
push:
branches: [main]
jobs:
build:
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
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
cd /var/www/myapp
git pull origin main
npm ci --production
pm2 reload ecosystem.config.js
Settings > Secrets and variables > Actions). Secrets are encrypted, masked in logs, and never exposed in forked repositories.
Setting Up GitHub Secrets
Before your deployment workflow can connect to your server, you need to configure the necessary secrets in your GitHub repository.
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Your identification has been saved in ~/.ssh/deploy_key
Your public key has been saved in ~/.ssh/deploy_key.pub
~/.ssh/authorized_keys on your deployment server.| Secret Name | Value |
|---|---|
SERVER_HOST | Your server IP or hostname |
SERVER_USER | SSH username (e.g., deploy) |
SERVER_PORT | SSH port (default: 22) |
SSH_PRIVATE_KEY | Contents of ~/.ssh/deploy_key |
Rsync Deployment: The Faster Alternative
SSH deployment with git pull is simple but has drawbacks: it requires Git installed on the server, keeps the .git directory on production, and re-runs the build on the server. A better approach is to build locally (in CI) and rsync only the production artifacts to the server.
name: Deploy with Rsync
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npm run build
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SERVER_PORT }} \
${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
- name: Deploy with rsync
run: |
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SERVER_PORT }}" \
--exclude='.env' \
--exclude='storage' \
--exclude='node_modules' \
./dist/ \
${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:/var/www/myapp/
- name: Restart application
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
cd /var/www/myapp
pm2 reload all
Multi-Environment Deployment: Staging and Production
Real-world projects need at least two environments: staging for testing and production for live traffic. GitHub Actions supports this with environment-specific secrets, branch-based triggers, and manual approval gates.
Branch-Based Deployment Strategy
name: Multi-Environment Deploy
on:
push:
branches: [develop, main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci && npm test && npm run build
- uses: actions/upload-artifact@v4
with:
name: build-artifact
path: dist/
deploy-staging:
needs: build
if: github.ref == 'refs/heads/develop'
environment: staging
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { name: build-artifact, path: dist/ }
- name: Deploy to staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
...
deploy-production:
needs: build
if: github.ref == 'refs/heads/main'
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { name: build-artifact, path: dist/ }
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
...
Manual Approval Gates
For production deployments, you often want a human to review and approve before the deployment proceeds. GitHub Environments support required reviewers, which pause the workflow until an authorized team member approves.
Manual Trigger with Inputs
Sometimes you want to trigger a deployment manually with specific options. The workflow_dispatch trigger supports input parameters:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options:
- staging
- production
skip_tests:
description: 'Skip test suite'
required: false
type: boolean
default: false
Caching Dependencies for Faster Builds
Installing dependencies on every workflow run wastes time and bandwidth. GitHub Actions provides built-in caching to speed up repeated builds. The setup-node action's cache option handles npm automatically, but you can cache anything with the generic cache action.
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
# Cache Go modules
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
PHP Laravel Deployment Workflow
Here is a complete workflow for deploying a Laravel application with Composer dependencies, database migrations, and cache optimization:
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.3', extensions: mbstring,pdo_mysql }
- run: composer install --no-interaction --prefer-dist
- run: php artisan test
deploy:
needs: test
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/laravel-app
php artisan down --render="errors::503"
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
php artisan up
Deployment Notifications
Knowing when a deployment succeeds or fails is critical. Here is how to add Slack notifications to your workflow:
if: success()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{"text": "Deployed ${{ github.sha }} to production by ${{ github.actor }}"}
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{"text": "FAILED: Deployment to production failed! Check logs."}
Self-Hosted Runners
GitHub-hosted runners are convenient but have limitations: 2,000 minutes per month on free plans, no persistent storage, and no access to private networks. Self-hosted runners solve all of these. You can run them on your own servers, giving you unlimited minutes, faster builds (local cache), and direct network access to your deployment targets.
$ mkdir actions-runner && cd actions-runner
$ curl -o actions-runner.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.320.0/...
$ tar xzf actions-runner.tar.gz
$ ./config.sh --url https://github.com/YOUR/REPO --token YOUR_TOKEN
$ sudo ./svc.sh install
$ sudo ./svc.sh start
# Use in workflow
jobs:
deploy:
runs-on: self-hosted
| Feature | GitHub-Hosted | Self-Hosted |
|---|---|---|
| Cost | 2,000 min free | Unlimited |
| Setup | Zero config | Requires setup |
| Speed | Cold start each run | Persistent cache |
| Network | Public internet only | Private network |
| Maintenance | Managed by GitHub | Your responsibility |
Rollback Workflow
Every deployment pipeline needs a rollback plan. Here is a manually triggered workflow that reverts to the previous deployment:
on:
workflow_dispatch:
inputs:
commit_sha:
description: 'Commit SHA to rollback to'
required: true
jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- name: Rollback on server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/myapp
git fetch origin
git checkout ${{ github.event.inputs.commit_sha }}
npm ci --production
pm2 reload all
Deployment Status Badges
Add a deployment status badge to your README to show the current state of your workflow at a glance:

# Branch-specific badge

Best Practices Checklist
Before shipping your GitHub Actions deployment pipeline, make sure you have covered these essential points:
- All secrets stored in GitHub Secrets, never in workflow files
- Tests run before deployment and block on failure
- Separate workflows or jobs for staging and production
- Production deployments require manual approval
- Dependencies are cached for faster builds
- Deployment notifications sent to Slack or Discord
- Rollback workflow exists and has been tested
- Build artifacts uploaded for traceability
- Workflow uses specific action versions (not @latest)
- SSH keys are dedicated to deployment, not personal keys
Wrapping Up
GitHub Actions transforms server deployment from a manual, error-prone process into a reliable, automated pipeline. Start with a simple workflow that runs tests and deploys via SSH. As your confidence grows, add environment separation, caching, notifications, and approval gates. The key is to start simple and iterate. Your first workflow does not need to be perfect — it just needs to be better than manually SSHing into your server and running commands by hand.
Remember that CI/CD is not just about automation — it is about confidence. When every code change goes through the same tested pipeline, you can deploy on a Friday afternoon without breaking a sweat. And that is the real superpower of GitHub Actions.