CI/CD with GitHub Actions: From Zero to Production

Photo by Unsplash

Photo by Unsplash
CI/CD with GitHub Actions has become the de-facto standard for shipping software reliably and fast. Whether you are a solo developer or part of a growing engineering team, automating your test, build, and deployment stages eliminates the painful manual steps between a commit and a live release. In this guide we will walk through building a production-grade pipeline from scratch — covering workflows, Docker image publishing, SSH deployment, and security best practices.
Continuous Integration (CI) is the practice of automatically running tests every time code is pushed. Continuous Deployment (CD) takes that further by automatically shipping passing builds to a staging or production environment. GitHub Actions implements both inside your repository using YAML workflow files stored under .github/workflows/.
Every GitHub Actions workflow starts with a trigger (on:), defines one or more jobs, and each job contains a sequence of steps. Steps can be shell commands or reusable marketplace actions published by the community. Jobs run on virtual machines called runners — GitHub provides hosted Ubuntu, Windows, and macOS runners for free within the monthly quota.
Workflows can be triggered by dozens of events: push, pull_request, schedule (cron), workflow_dispatch (manual), or repository_dispatch (external webhooks). Using branch filters such as 'branches: [main]' ensures your production deployment job only fires when code lands on the main branch, not feature branches.
Store all credentials in GitHub Encrypted Secrets (Settings → Secrets and variables → Actions) and never hard-code tokens or passwords in workflow files. Secrets are masked in logs automatically.
A robust CI pipeline builds and tags a Docker image on every successful test run, then pushes it to a container registry. Using docker/build-push-action together with Docker Buildx enables advanced features like multi-platform builds and inline layer caching, dramatically reducing build times on subsequent runs.
The workflow below chains three jobs with the needs: keyword to enforce sequential execution. Tests must pass before the image is built, and the image must be published before the deployment SSH command runs. This ensures broken code can never reach production.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
push: true
tags: myuser/myapp:latest,myuser/myapp:${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull myuser/myapp:latest
docker stop myapp || true
docker rm myapp || true
docker run -d --name myapp -p 3000:3000 myuser/myapp:latestCache the npm ci install step using actions/cache or the built-in cache: option on actions/setup-node. For Docker, pass cache-from: type=registry to docker/build-push-action so that unchanged layers are pulled from the registry instead of rebuilt. On a warm cache, build time drops from several minutes to under 30 seconds.
A deployment step that stops the running container before starting the new one creates a brief outage window. Two simple patterns avoid this: using Docker's start-first rolling update policy, or running the new container temporarily on a different port before switching a load balancer rule.
The appleboy/ssh-action marketplace action lets you run shell commands on a remote server over SSH without installing any agent. After pulling and starting the new container, add a health check loop that polls the /health endpoint with a timeout before declaring the deployment successful.
Avoid echoing environment variables or printing debug output that might contain secrets. Use the 'if: failure()' condition to upload debug artifacts only when a job fails, keeping logs clean in normal runs. Rotate any secret that was accidentally logged immediately.
A pipeline that handles production credentials is a high-value target. Lock down permissions at the workflow and job level, pin third-party actions to a specific commit SHA rather than a mutable tag, and enable required status checks so the main branch can only receive code through a reviewed pull request.
Using 'actions/checkout@v4' references a mutable tag. A malicious actor who gains write access to that repository could change what v4 points to. Pinning to the full commit SHA — 'actions/checkout@11bd719' — ensures you always get exactly the code you reviewed, eliminating supply-chain risk.
GitHub's OIDC provider lets your workflow request a short-lived JWT that cloud providers (AWS, GCP, Azure) trust directly. You configure a trust policy on the cloud side and then no static secret is stored in your repository at all — the workflow exchanges the OIDC token for temporary credentials at runtime.
GitHub Actions matrix strategy lets you run the same job across multiple versions of a runtime, operating system, or custom variable in parallel. For a Node.js project you might test on Node 18, 20, and 22 simultaneously, cutting total CI time by two-thirds compared to sequential runs.
Define a matrix with 'node-version: [18, 20, 22]' and reference it as '${{ matrix.node-version }}' inside the setup-node step. GitHub fans out the job into three parallel runners automatically. If any one version fails the overall check is marked failing, giving you precise signal on compatibility.
Use 'continue-on-error: true' on individual matrix legs when you want to gather data from experimental Node versions without blocking merges. Add a final summary step that reads the matrix outcomes and posts a Markdown table to the PR as a comment using the GitHub API.
To get the most from GitHub Actions it helps to understand key terms: workflow trigger, job, marketplace action, repository secret, and runner.