Docker + GitHub Actions: Building a CI/CD Pipeline for SaaS Apps

Docker + GitHub Actions: Building a CI/CD Pipeline for SaaS Apps

The Problem

Shipping SaaS code manually is a liability. Without automation, every deployment is a gamble — wrong environment variables, missed build steps, or a developer pushing directly to production on a Friday afternoon. The core difficulty is orchestrating three moving parts that don’t naturally talk to each other: your application code, a containerized runtime (Docker), and a cloud host.

Getting this wrong means inconsistent environments between dev and prod, broken deployments with no rollback plan, and zero visibility into what actually shipped. Getting it right means every push to main automatically builds a tested Docker image, pushes it to a registry, and deploys — with no manual SSH sessions, no “works on my machine,” and a full audit trail baked into GitHub.

This tutorial gives you a production-ready CI/CD pipeline you can drop into an existing Node.js SaaS app in under an hour.

Tech Stack & Prerequisites

  • Node.js v20+ — the example app runtime
  • Docker Desktop (latest) — for local image builds and testing
  • A GitHub account — Actions runs free for public repos; 2,000 min/month for private
  • Docker Hub account — free tier is sufficient (or swap for GHCR/ECR)
  • A VPS or cloud server — Ubuntu 22.04 LTS recommended (DigitalOcean, AWS EC2, Hetzner, etc.)
  • SSH access to your server — with a non-root deploy user
  • docker and docker-compose installed on the server

Step-by-Step Implementation

Step 1: Setup — Dockerize Your Node.js App

First, create a Dockerfile in your project root. This is the blueprint for your container.

dockerfile
# Dockerfile

# --- Stage 1: Builder ---
# Use a specific version tag, never "latest" in production
FROM node:20-alpine AS builder

WORKDIR /app

# Copy dependency manifests first to leverage Docker layer caching
# A cache miss here only happens when package.json changes
COPY package*.json ./
RUN npm ci --only=production

# --- Stage 2: Runner ---
FROM node:20-alpine AS runner

# Security: run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy only the built node_modules from builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY . .

# Switch to non-root user before exposing
USER appuser

EXPOSE 3000

# Use exec form to handle signals correctly (enables graceful shutdown)
CMD ["node", "server.js"]
```

Then create a **`.dockerignore`** file to keep the image lean:
```
node_modules
.git
.env
.env.*
*.log
coverage
.nyc_output
README.md

Verify it builds locally before touching CI:

bash
docker build -t saas-app:local .
docker run --rm -p 3000:3000 saas-app:local

Step 2: Configuration — Secrets & Environment Variables

Never hardcode secrets. GitHub Actions has a built-in secrets store. Go to your repo → Settings → Secrets and variables → Actions → New repository secret.

Add these secrets:

Secret Name Value
DOCKERHUB_USERNAME Your Docker Hub username
DOCKERHUB_TOKEN Docker Hub access token (not your password)
DEPLOY_HOST Your server’s IP address
DEPLOY_USER SSH deploy username (e.g., deployer)
DEPLOY_SSH_KEY Your private SSH key (full contents of ~/.ssh/id_ed25519)

Generate a dedicated deploy SSH key (don’t reuse your personal one):

bash
# Run this locally
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""

# Print the private key — paste this into DEPLOY_SSH_KEY secret
cat ~/.ssh/deploy_key

# Add the public key to your server's authorized_keys
ssh-copy-id -i ~/.ssh/deploy_key.pub deployer@YOUR_SERVER_IP

For runtime secrets your app needs (e.g., DATABASE_URL, STRIPE_SECRET_KEY), store them in a .env file directly on the server — not in the repo, not in the Docker image:

bash
# On your server
mkdir -p /opt/saas-app
nano /opt/saas-app/.env

# Contents:
DATABASE_URL=postgres://user:pass@localhost:5432/saasdb
STRIPE_SECRET_KEY=sk_live_...
NODE_ENV=production
PORT=3000

Step 3: Core Logic — The GitHub Actions Workflow

Create the directory and workflow file:

bash
mkdir -p .github/workflows
touch .github/workflows/deploy.yml

Paste the full pipeline into .github/workflows/deploy.yml:

yaml
# .github/workflows/deploy.yml

name: Build, Push & Deploy

# Trigger only on pushes to main
# Pull requests will not trigger a deployment
on:
  push:
    branches:
      - main

env:
  IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/saas-app

jobs:
  # -------------------------------------------------------
  # JOB 1: Run tests before building anything
  # -------------------------------------------------------
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        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 test suite
        run: npm test

  # -------------------------------------------------------
  # JOB 2: Build Docker image and push to Docker Hub
  # Only runs if tests pass
  # -------------------------------------------------------
  build-and-push:
    name: Build & Push Image
    runs-on: ubuntu-latest
    needs: test  # This job waits for 'test' to succeed
    outputs:
      # Pass the image tag to the deploy job
      image-tag: ${{ steps.meta.outputs.tags }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Extract metadata for Docker tags
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            # Tag with the short Git SHA for traceability (e.g., sha-a1b2c3d)
            type=sha,prefix=sha-,format=short
            # Also tag as 'latest' on main branch pushes
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          # Cache layers between runs using GitHub Actions cache
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # -------------------------------------------------------
  # JOB 3: SSH into server and pull the new image
  # Only runs if build-and-push succeeds
  # -------------------------------------------------------
  deploy:
    name: Deploy to Server
    runs-on: ubuntu-latest
    needs: build-and-push
    environment: production  # Enables deployment protection rules in GitHub

    steps:
      - 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: |
            # Pull the latest image
            docker pull ${{ env.IMAGE_NAME }}:latest

            # Stop and remove the old container if it exists
            docker stop saas-app || true
            docker rm saas-app || true

            # Start the new container
            # --env-file loads your .env from the server filesystem
            # --restart always ensures the container comes back after a server reboot
            docker run -d \
              --name saas-app \
              --restart always \
              -p 3000:3000 \
              --env-file /opt/saas-app/.env \
              ${{ env.IMAGE_NAME }}:latest

            # Clean up old unused images to save disk space
            docker image prune -f

            echo "✅ Deployment complete."

Step 4: Testing — Verify the Pipeline End-to-End

4a. Trigger the pipeline:

bash
git add .
git commit -m "ci: add Docker + GitHub Actions pipeline"
git push origin main

Go to your repo → Actions tab → watch the three jobs run in sequence.

4b. Verify the image was pushed:

bash
# From your local machine
docker pull YOUR_DOCKERHUB_USERNAME/saas-app:latest
docker inspect YOUR_DOCKERHUB_USERNAME/saas-app:latest | grep Created

4c. Verify the container is running on the server:

bash
# SSH into your server
ssh deployer@YOUR_SERVER_IP

# Check the container is up
docker ps --filter name=saas-app

# Tail live logs to confirm a clean startup
docker logs saas-app --tail 50 --follow

4d. Hit the app endpoint:

bash
# From your local machine (replace with your domain or IP)
curl -i http://YOUR_SERVER_IP:3000/health

# Expected response:
# HTTP/1.1 200 OK
# {"status":"ok"}

If you get a 200, your full pipeline — commit → test → build → push → deploy — is working.

Common Errors & Troubleshooting

Gotcha #1: permission denied on Docker commands during SSH deploy

Symptom: The deploy job fails with Got permission denied while trying to connect to the Docker daemon socket.

Fix: Your deploy user isn’t in the docker group on the server. Run:

bash
# On the server, as root or sudo user
sudo usermod -aG docker deployer

# The group change requires a new session — force it without logging out:
newgrp docker

# Verify
docker ps  # should work without sudo

You’ll also need to re-run the GitHub Actions job since the SSH session in the previous run had stale permissions.

Gotcha #2: Old container keeps running after deploy

Symptom: Your server pulls the new image but traffic still hits the old version. docker ps shows two containers.

Fix: The docker stop || true pattern in the workflow script silently fails if the container name has drifted. Confirm the exact container name on your server:

bash
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"

If the name doesn’t match saas-app, either update the workflow or force remove by ID:

bash
# Nuclear option — removes ALL stopped containers
docker container prune -f

# Then restart via workflow, or manually:
docker run -d --name saas-app ...

Gotcha #3: GitHub Actions can’t find .env secrets — app crashes on startup

Symptom: Container starts and immediately exits. docker logs saas-app shows Error: DATABASE_URL is not defined.

Fix: The --env-file flag points to a path on the server. If that file doesn’t exist or has wrong permissions, Docker silently starts the container without those vars. Check:

bash
# On the server
ls -la /opt/saas-app/.env         # File must exist
cat /opt/saas-app/.env            # Spot-check values aren't empty

# Confirm Docker can read it
docker run --rm --env-file /opt/saas-app/.env alpine env | grep DATABASE_URL

If the file is there but vars are empty, re-open it in nano — a common mistake is writing DATABASE_URL =postgres://... (with a space before =), which Docker does not parse correctly.

Security Checklist

  • Rotate your Docker Hub token, not your account password — tokens are scoped and revocable
  • Never log secrets — avoid echo $SECRET in workflow steps; GitHub masks known secrets but custom values are not masked
  • Pin Action versions to a SHA, not a tag — actions/checkout@v4 can be hijacked; actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 cannot
  • Use a non-root user inside the container — the USER appuser line in the Dockerfile is not optional for production
  • Restrict SSH key scope — add command="docker ..." to the server’s authorized_keys entry to prevent the deploy key from being used for general shell access
  • Enable GitHub’s environment: production protection rule — require a manual approval before deploys run against production secrets
  • Scan your image for CVEs — add docker scout cves or trivy image as a workflow step before pushing

Leave a Comment

Your email address will not be published. Required fields are marked *

banner
Scroll to Top