Most CI/CD tutorials stop at "push code, run tests, celebrate." Production SaaS apps are not that forgiving. You need lint gates, parallel test suites, a versioned Docker image in a registry, and a deployment that promotes cleanly from staging to production — without leaking secrets or bypassing review gates. This guide builds exactly that, step by step, using GitHub Actions.
Why GitHub Actions for SaaS CI/CD
GitHub Actions is deeply integrated with your repository, free for public repos, and generous on private-repo minutes. More importantly, its marketplace ecosystem means you rarely write low-level shell glue for common tasks like logging into a Docker registry or SSHing into a VM. For SaaS teams, the ability to trigger workflows on pull requests, tags, and manual dispatches from a single YAML file is a productivity multiplier.
That said, a poorly structured Actions setup becomes a maintenance nightmare fast. The goal here is a pipeline that is readable, secure, and scalable as your team grows.
The Pipeline Architecture
The final pipeline has four sequential stages:
- Lint & static analysis — fail fast on code quality
- Automated tests — unit and integration, with service containers
- Docker build & push — versioned image to a container registry
- Deploy — pull and run the new image on a cloud VM, with environment promotion
Stages 1 and 2 run on every pull request. Stages 3 and 4 run only on merges to main (staging) or on a version tag (production). This is environment promotion baked into branch strategy.
Setting Up the Workflow File
Create .github/workflows/pipeline.yml in your repository root. Start with the trigger block:
name: SaaS CI/CD Pipeline
on:
pull_request:
branches: [main, staging]
push:
branches: [main]
tags:
- "v*.*.*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
Using GitHub Container Registry (ghcr.io) keeps everything inside the GitHub ecosystem and respects your repo's access controls automatically.
Stage 1 — Lint and Static Analysis
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run type-check
Keep this job lean. Its only job is to reject bad code before any compute-heavy steps run. If your linter takes longer than 90 seconds, you have a configuration problem, not a pipeline problem.
Stage 2 — Automated Tests With a Service Container
test:
runs-on: ubuntu-latest
needs: lint
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test_password
POSTGRES_DB: saas_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm test
env:
DATABASE_URL: postgres://postgres:test_password@localhost:5432/saas_test
The services block spins up a real Postgres container alongside the test runner. No mocking the database. This catches the class of bugs that only appear when your ORM meets a real query planner — which in SaaS apps is alarmingly common.
Stage 3 — Docker Build and Push
build:
runs-on: ubuntu-latest
needs: test
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Two details worth highlighting here. First, cache-from/cache-to: type=gha uses GitHub's built-in layer cache, which can cut Docker build times by 60–80% on unchanged layers. Second, the image is tagged by git SHA for traceability — you can always trace a running container back to the exact commit that produced it.
Stage 4 — Deploy With Environment Promotion
deploy-staging:
runs-on: ubuntu-latest
needs: build
environment: staging
if: github.ref == 'refs/heads/main'
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ needs.build.outputs.image_tag }}
docker stop saas-app || true
docker rm saas-app || true
docker run -d \
--name saas-app \
--restart unless-stopped \
-p 3000:3000 \
--env-file /etc/saas/.env \
${{ needs.build.outputs.image_tag }}
deploy-production:
runs-on: ubuntu-latest
needs: build
environment: production
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ needs.build.outputs.image_tag }}
docker stop saas-app || true
docker rm saas-app || true
docker run -d \
--name saas-app \
--restart unless-stopped \
-p 3000:3000 \
--env-file /etc/saas/.env \
${{ needs.build.outputs.image_tag }}
The environment: key is critical. In GitHub's repository settings, you configure staging and production environments with their own secrets and, optionally, required reviewers. Pushing to main triggers staging automatically. Deploying to production requires a git version tag (v1.4.2, for example) and can require a manual approval step from a designated reviewer — a lightweight change management gate that most SaaS teams need before they realise they need it.
Secrets Management Best Practices
- Store SSH keys, registry credentials, and API tokens as repository or environment secrets, never in workflow YAML.
- Use environment-scoped secrets so staging credentials are physically inaccessible to the production deploy job.
- Rotate
DEPLOY_SSH_KEYquarterly and use a dedicated deploy user with minimal OS permissions on the VM. - For app-level secrets (database URLs, Stripe keys), use an
.envfile on the server managed by a secrets manager (AWS Secrets Manager, HashiCorp Vault, or even a simpleansible-vaultencrypted file). Do not pass them through Actions environment variables into the container at deploy time.
Common Pitfalls to Avoid
- Skipping
needs:— without explicit job dependencies, GitHub runs all jobs in parallel, which means you could push a broken Docker image before tests finish. - Using
latestas the only tag — always tag by SHA or version so rollbacks are deterministic. - Storing secrets in
env:at the workflow level — they become visible to every job and every third-party action you use. - No health check after deploy — add a simple
curl --failordocker inspect --format='{{.State.Health.Status}}'step after the container starts to catch silent failures before your monitoring does.
Why This Matters for Your Project
A pipeline like this is the difference between a SaaS product that deploys with confidence and one where releases are dreaded events. By automating lint, tests, image builds, and environment promotion inside GitHub Actions, your team ships faster, catches regressions earlier, and maintains an auditable deployment history tied directly to your git history. Whether you are a two-person startup or a scaling engineering team, this infrastructure pays for itself within the first prevented production incident.





