How to Use GitHub Actions for Auto-Deploy
How to Use GitHub Actions for Auto-Deploy
Manual deployments create bottlenecks in development velocity and introduce human error into production releases. Every deployment requires remembering the exact sequence of commands, ensures tests pass, builds the correct version, and deploys to the right environment. When this process exists only in your memory or scattered documentation, deployments become risky events you avoid rather than routine operations you perform confidently.
This guide demonstrates how to implement automated deployments using GitHub Actions, from basic workflows that deploy on every push to production-grade pipelines with testing, Docker builds, and multi-environment deployments. You will learn the workflow syntax, security patterns for handling deployment credentials, and strategies for zero-downtime deployments that work with common hosting platforms.
The examples progress from simple to sophisticated, allowing you to implement basic automation immediately and add complexity as your deployment requirements grow. Each pattern includes complete workflow files you can adapt to your specific infrastructure.
GitHub Actions Fundamentals
GitHub Actions workflows are YAML files stored in your repository's .github/workflows directory. When specific events occur (pushes, pull requests, scheduled times), GitHub executes the workflow on hosted runners that provide a clean environment for each run.
A workflow consists of one or more jobs that run in parallel by default. Each job contains steps that execute sequentially. Steps can run commands directly or use pre-built actions from the GitHub Marketplace. This structure balances flexibility with reusability: write custom commands when needed, use actions for common tasks.
The most basic deployment workflow triggers on pushes to the main branch:
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Deploy to server
run: echo "Deployment commands here"
This workflow runs on GitHub-hosted Ubuntu runners whenever code is pushed to main. The checkout action clones your repository, and the run step executes commands. In practice, that run step is replaced with actual deployment commands specific to your infrastructure.
Understanding Workflow Triggers
The on section defines what events trigger the workflow. Deploying on every push to main is simple but might be too aggressive for production. More sophisticated triggers provide better control:
on:
push:
branches:
- main
paths-ignore:
- 'docs/**'
- '**.md'
workflow_dispatch:
schedule:
- cron: '0 2 * * 0' # Weekly deploy on Sunday at 2 AM
This workflow triggers on pushes to main except when only documentation changes, allows manual triggering from the GitHub UI via workflow_dispatch, and runs on a schedule. The paths-ignore prevents deployment workflows from running when you only update documentation, saving CI/CD minutes.
Manual trigger capability is essential for production deployments. Even with automated deployments, you need the ability to manually deploy a specific version or redeploy after infrastructure changes. The workflow_dispatch event enables this without requiring direct infrastructure access.
Deploying Static Sites
Static site deployment is the simplest automated deployment pattern because it involves only building assets and copying them to hosting. This example deploys a React application to GitHub Pages:
name: Deploy Static Site
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build
The cache: 'npm' parameter in setup-node caches dependencies between workflow runs, significantly speeding up builds. The first run takes full time to install dependencies, but subsequent runs restore from cache and install only changed packages.
The npm ci command installs exact versions from package-lock.json and is faster and more reliable than npm install for CI environments. Using npm install in CI can cause builds to pass locally but fail in CI due to version differences.
The workflow fails if tests fail, preventing broken code from being deployed. This is the minimum quality gate: automated tests must pass before deployment proceeds. More sophisticated pipelines add linting, type checking, and security scanning.
Deploying to Cloud Storage
For deploying static sites to cloud storage like AWS S3 or Google Cloud Storage, replace the deploy step with cloud-specific upload commands:
- name: Deploy to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
aws s3 sync ./build s3://my-website-bucket --delete
aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_ID }} --paths "/*"
The --delete flag removes files from S3 that no longer exist in the build directory, keeping the deployed site in sync with the repository. The CloudFront invalidation clears the CDN cache so users see the updated content immediately rather than stale cached versions.
Credentials are stored in GitHub Secrets (Settings > Secrets and variables > Actions) and referenced with ${{ secrets.SECRET_NAME }}. Secrets are encrypted and never appear in logs. This is the standard pattern for handling any sensitive data in workflows.
Building and Deploying Docker Containers
Containerized applications require building Docker images, pushing them to a registry, and triggering deployment on the target infrastructure. This pattern works with any container orchestration platform.
name: Build and Deploy Docker
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v4
with:
images: myusername/myapp
tags: |
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=myusername/myapp:buildcache
cache-to: type=registry,ref=myusername/myapp:buildcache,mode=max
Docker Buildx enables advanced features like layer caching to remote registries, which speeds up builds significantly. The cache-from and cache-to settings store build cache in the registry, so subsequent builds reuse layers even when running on different runners.
The metadata action generates appropriate tags based on the git branch and commit SHA. The configuration creates two tags: one with the branch name and commit SHA (like main-a3f5b9c), and "latest" only for the default branch. This tagging strategy allows deploying specific versions while keeping a latest tag for simple deployments.
Deploying to Kubernetes
After building and pushing the Docker image, trigger deployment to Kubernetes by updating the image tag:
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup kubectl
uses: azure/setup-kubectl@v3
with:
version: 'v1.28.0'
- name: Configure kubectl
run: |
echo "${{ secrets.KUBECONFIG }}" | base64 -d > kubeconfig
export KUBECONFIG=./kubeconfig
- name: Update deployment
run: |
export KUBECONFIG=./kubeconfig
kubectl set image deployment/myapp \
myapp=myusername/myapp:main-${{ github.sha }} \
-n production
kubectl rollout status deployment/myapp -n production
The needs: build creates a dependency: the deploy job only runs after the build job succeeds. This ensures you never deploy an image that failed to build.
The kubeconfig file stored in GitHub Secrets provides kubectl credentials to access the cluster. Storing the base64-encoded kubeconfig in secrets and decoding it at runtime prevents accidentally committing cluster credentials to the repository.
The kubectl rollout status command waits for the deployment to complete successfully before the workflow finishes. If pods fail to start, the workflow fails, providing immediate feedback that deployment did not succeed.
Multi-Environment Deployments
Production applications typically deploy to multiple environments: staging for testing, production for users. GitHub Actions supports this pattern through environments and environment-specific secrets.
name: Multi-Environment Deploy
on:
push:
branches:
- main
- staging
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
url: ${{ github.ref_name == 'main' && 'https://app.example.com' || 'https://staging.example.com' }}
steps:
- uses: actions/checkout@v3
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
API_URL: ${{ vars.API_URL }}
run: |
echo "Deploying to ${{ github.event.repository.environment }}"
echo "API URL: ${{ vars.API_URL }}"
# Deployment commands using environment-specific configuration
The environment key specifies which GitHub Environment to use, selecting production for main branch and staging for staging branch. Each environment has separate secrets and variables, so DEPLOY_KEY and API_URL have different values depending on which environment is selected.
GitHub Environments support protection rules: require manual approval before deploying to production, limit which branches can deploy to which environments, or restrict deployments to specific reviewers. This provides a lightweight approval workflow without complex tooling.
Environment-Specific Configuration
Create environments in your GitHub repository (Settings > Environments) and configure secrets and variables for each:
Production Environment:
- Secret: DEPLOY_KEY = prod-key-here
- Variable: API_URL = https://api.example.com
- Protection: Require approval from @senior-dev
Staging Environment:
- Secret: DEPLOY_KEY = staging-key-here
- Variable: API_URL = https://staging-api.example.com
- Protection: None
Secrets are for sensitive data like credentials, while variables are for non-sensitive configuration like URLs. Both are environment-specific, but secrets are encrypted and hidden in logs while variables are visible.
The approval requirement means pushing to main triggers the workflow, but it pauses before the deploy job and sends a notification to the approver. Deployment proceeds only after approval. This gate catches accidental pushes to main and provides a review point before production changes.
Deploying to Platform-as-a-Service
Many hosting platforms provide their own GitHub Actions for deployment. These actions handle authentication and deployment details, simplifying workflow configuration.
Vercel Deployment
name: Deploy to Vercel
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
The Vercel token is generated from your Vercel account settings, and organization and project IDs are found in your Vercel project settings. This workflow deploys to production on every push to main, with Vercel handling the build and deployment process.
Railway Deployment
name: Deploy to Railway
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Railway CLI
run: npm i -g @railway/cli
- name: Deploy
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: railway up
Railway CLI deployment is even simpler: install the CLI and run railway up. Railway detects your project type, builds, and deploys automatically. The RAILWAY_TOKEN authenticates the CLI to your Railway account.
DigitalOcean App Platform
name: Deploy to DigitalOcean
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to DigitalOcean App Platform
uses: digitalocean/[email protected]
with:
app_name: my-application
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
DigitalOcean's action triggers a deployment through their API. The platform pulls code from GitHub, builds, and deploys using the configuration in your App Platform project settings.
Database Migrations in Deployment Workflows
Database migrations require careful ordering: run migrations before deploying new code that depends on schema changes, but after ensuring the current code can handle the intermediate state.
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Run database migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm run migrate
deploy:
needs: migrate
runs-on: ubuntu-latest
steps:
- name: Deploy application
run: echo "Deploy commands here"
This pattern runs migrations before deployment. The needs: migrate dependency ensures deployment only proceeds if migrations succeed. If a migration fails, the workflow stops and the broken code is never deployed.
The challenge is writing backwards-compatible migrations. If new code depends on a new column but that column does not exist yet, deployment will fail. The solution is multi-step migrations: add the column in one deployment, update code to use it in the next deployment, then remove old code in a third deployment. This allows rolling back any step without breaking the application.
Migration Rollback Strategy
Not all database migrations can be rolled back automatically. Dropping a column loses data permanently. The safe pattern is:
- Add new schema elements (tables, columns) without removing old ones
- Deploy code that uses both old and new schema
- Verify the new schema works in production
- Deploy code that uses only the new schema
- Remove old schema elements in a later migration
This multi-step process allows rollback at any point because the old schema remains available. If the new code fails, roll back to the previous version which still works with the old schema.
Testing and Quality Gates
Automated deployment must include automated quality checks. The minimum viable quality gate is unit tests, but production workflows benefit from additional validation.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Type check
run: npm run type-check
- name: Run unit tests
run: npm test
- name: Run integration tests
run: npm run test:integration
- name: Check code coverage
run: npm run test:coverage
continue-on-error: true
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Build application
run: npm run build
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy
run: echo "Deploy commands here"
This workflow has three jobs with dependencies: test must pass before build runs, and build must pass before deploy runs. Any failure stops the pipeline, preventing broken code from reaching production.
The continue-on-error: true on code coverage allows the workflow to continue even if coverage is below the threshold. This makes sense when you want visibility into coverage without blocking deployment. Remove this flag if you want to enforce minimum coverage requirements.
Performance Testing
For APIs, include basic performance testing to catch regressions that make endpoints unacceptably slow:
- name: Performance test
run: |
npm run build
npm start &
sleep 5
npx autocannon -c 10 -d 10 http://localhost:3000/api/health
if [ $? -ne 0 ]; then
echo "Performance test failed"
exit 1
fi
This starts the application, waits for it to initialize, then runs autocannon to send requests and measure response times. If performance degrades significantly, the test fails and deployment is blocked. Adjust the connection count and duration based on your acceptable performance baseline.
Rollback Strategies
Automated deployment must include the ability to rollback quickly when problems are discovered in production. Different deployment targets require different rollback approaches.
Container-Based Rollback
For Kubernetes deployments, rollback is built into the platform:
kubectl rollout undo deployment/myapp -n production
This reverts to the previous deployment. Kubernetes maintains deployment history, so you can rollback multiple versions if needed. The rollback is fast because the old container image still exists in the registry.
Automate rollback based on health checks:
- name: Deploy and verify
run: |
kubectl set image deployment/myapp myapp=myusername/myapp:${{ github.sha }}
kubectl rollout status deployment/myapp -n production --timeout=5m || \
(kubectl rollout undo deployment/myapp -n production && exit 1)
This deploys the new version and waits for it to become ready. If the rollout does not complete within 5 minutes (indicating health check failures), it automatically rolls back to the previous version and fails the workflow.
Platform Rollback
Most PaaS platforms provide deployment history and one-click rollback through their dashboards. You can also trigger rollback through their APIs from GitHub Actions if you detect problems:
- name: Run smoke tests
run: npm run smoke-test
continue-on-error: true
id: smoke
- name: Rollback on failure
if: steps.smoke.outcome == 'failure'
run: |
# Platform-specific rollback command
vercel rollback --token=${{ secrets.VERCEL_TOKEN }}
This runs smoke tests after deployment and triggers rollback if they fail. Smoke tests verify basic functionality: homepage loads, API responds, database is accessible. They run quickly and catch obvious problems without exhaustive testing.
Monitoring Deployment Success
Deployment completion does not guarantee deployment success. Integrate monitoring and alerting into workflows to verify the deployed application works correctly.
- name: Wait for deployment
run: sleep 30
- name: Health check
run: |
response=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health)
if [ "$response" -ne 200 ]; then
echo "Health check failed with status $response"
exit 1
fi
- name: Notify deployment success
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK }}
payload: |
{
"text": "Deployment succeeded: ${{ github.sha }} deployed to production",
"username": "GitHub Actions"
}
The sleep gives the application time to start and become healthy. The health check verifies the endpoint responds correctly. The Slack notification provides visibility: deployments succeed or fail silently without notifications, and team members need to know when production changed.
Deployment Metrics
Send deployment events to monitoring systems for correlation with metrics and errors:
- name: Record deployment in Datadog
run: |
curl -X POST "https://api.datadoghq.com/api/v1/events" \
-H "Content-Type: application/json" \
-H "DD-API-KEY: ${{ secrets.DATADOG_API_KEY }}" \
-d '{
"title": "Deployment to production",
"text": "Deployed commit ${{ github.sha }} to production",
"tags": ["environment:production", "service:myapp"]
}'
This creates an event marker in Datadog that appears on graphs and correlates with metrics. When investigating a production issue, you can see if it started immediately after a deployment, which focuses troubleshooting on changes in that deployment.
FAQ
Should I deploy to production automatically or require manual approval?
Start with manual approval for production deployments using GitHub Environments. This provides a safety check while you build confidence in your automated testing. Once you have comprehensive tests and monitoring that catch issues before production impact, consider automatic deployment to production. The decision depends on risk tolerance and test coverage, not universal best practices.
How do I handle secrets and environment variables in GitHub Actions?
Use GitHub Secrets (repository or organization level) for sensitive data like API keys and credentials. Use GitHub Variables for non-sensitive configuration like URLs. Access secrets with ${{ secrets.NAME }} and variables with ${{ vars.NAME }}. Secrets are encrypted and redacted from logs automatically. Never hardcode secrets in workflow files or commit them to the repository.
What is the difference between GitHub-hosted and self-hosted runners?
GitHub-hosted runners are VMs provided by GitHub that start fresh for each workflow run. They include common tools and are convenient but have limited resources and cannot access private networks. Self-hosted runners are machines you manage that can access internal resources and have custom tools installed, but require maintenance and security updates. Use GitHub-hosted runners unless you need access to internal resources or have specific performance requirements.
How do I deploy only when tests pass?
Structure your workflow with job dependencies using the needs key. Put tests in one job and deployment in another job that needs the test job. If tests fail, the deployment job never runs. This is the standard pattern for preventing broken code from reaching production through automated deployment.
Can I use GitHub Actions to deploy to multiple cloud providers?
Yes, GitHub Actions can deploy to any cloud provider or hosting platform via their APIs or CLI tools. Use provider-specific actions from the GitHub Marketplace or write custom steps using CLI tools. The workflow syntax is the same regardless of deployment target; only the specific deployment commands differ between providers.
How do I limit which branches can trigger deployments?
Use the branches filter in the workflow trigger and GitHub Environment protection rules. The branches filter controls which pushes trigger the workflow, while Environment protection rules control which branches can deploy to specific environments. This provides defense in depth: the workflow only runs for specific branches, and environments add another authorization layer.
What happens if deployment fails halfway through?
Workflow jobs are atomic: if any step fails, subsequent steps in that job do not run. However, partial deployments can occur if the failure happens during deployment commands. Implement health checks and rollback logic as shown in the rollback section. Design deployments to be idempotent when possible: running the deployment twice should produce the same result as running it once.
How much do GitHub Actions cost for automated deployment?
GitHub Actions provides 2,000 minutes per month on the free tier for private repositories, and unlimited minutes for public repositories. A typical deployment workflow uses 3-5 minutes. Deploying 10 times per day uses about 1,000 minutes per month. For high-frequency deployments or large builds, you may need to upgrade to a paid plan ($4 per month for 3,000 minutes) or use self-hosted runners which have no time limits.
Can I schedule deployments for specific times?
Yes, use the schedule trigger with cron syntax. For example, cron: '0 2 * * 1' runs every Monday at 2 AM. Scheduled deployments make sense for maintenance windows or batch deployments of multiple applications. Combine with workflow_dispatch so you can also trigger manually when needed outside the schedule.
How do I test GitHub Actions workflows without deploying to production?
Use branch-based workflow triggers and environment protection to test in staging first. Push to a staging branch triggers deployment to staging environment, and only pushes to main trigger production deployment. Alternatively, use the act tool to run GitHub Actions workflows locally for testing before committing them. This provides a fast feedback loop for workflow development.
Conclusion
GitHub Actions provides a flexible platform for automated deployment that scales from simple static site deployment to complex multi-environment container orchestration. The key to effective automated deployment is starting simple with basic workflows and adding sophistication as needs evolve, not implementing every possible feature upfront.
Begin with a workflow that deploys on push to main after tests pass. Add multi-environment support when you have a staging environment. Implement approval gates when your deployment risk increases. Integrate monitoring and automated rollback when you have the telemetry to make those decisions. Each addition should solve an actual problem you encountered, not a theoretical problem you might face.
The goal of automated deployment is confidence to ship frequently without fear. When deployment is automated, tested, and monitored, it becomes a non-event that happens multiple times per day rather than a risky operation that requires planning and coordination. This frequency enables faster feedback cycles and reduces the batch size of changes, making each deployment lower risk than infrequent large deployments.