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
dockeranddocker-composeinstalled 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
# --- 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 /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:
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):
# 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:
# 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:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
Paste the full pipeline into .github/workflows/deploy.yml:
# .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:
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:
# 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:
# 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:
# 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:
# 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:
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:
# 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:
# 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 $SECRETin workflow steps; GitHub masks known secrets but custom values are not masked - Pin Action versions to a SHA, not a tag —
actions/checkout@v4can be hijacked;actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683cannot - Use a non-root user inside the container — the
USER appuserline in the Dockerfile is not optional for production - Restrict SSH key scope — add
command="docker ..."to the server’sauthorized_keysentry to prevent the deploy key from being used for general shell access - Enable GitHub’s
environment: productionprotection rule — require a manual approval before deploys run against production secrets - Scan your image for CVEs — add
docker scout cvesortrivy imageas a workflow step before pushing

Finly Insights Team is a group of software developers, cloud engineers, and technical writers with real hands-on experience in the tech industry. We specialize in cloud computing, cybersecurity, SaaS tools, AI automation, and API development. Every article we publish is thoroughly researched, written, and reviewed by people who have actually worked in these fields.




