CI/CD Pipeline with GitHub Actions: Automate Deployments

I need to confess something. For about a year — maybe a little longer, honestly — I deployed code to production by SSH-ing into a server and running git pull. That was the whole process. Push to GitHub, open a terminal, type in the server’s IP, pull the latest code, restart the process, cross my fingers, close the terminal. Sometimes I’d forget which server was which. Sometimes I’d pull to the wrong branch. Once, around 2 a.m. on a Friday night in Bangalore, I ran git pull on the staging server thinking it was production, then ran it on production thinking it was staging. Both broke. Neither had the right environment variables.

Nobody taught me differently because everyone on my small team did it the same way. We probably lost dozens of hours to deployment mistakes that year. Not catastrophic failures — just the slow, grinding kind where you spend twenty minutes figuring out why the app won’t start, only to realize someone forgot to run npm install after adding a new dependency.

So when I finally set up a CI/CD pipeline, the feeling wasn’t excitement. It was embarrassment. Why hadn’t I done this sooner? A machine, doing the same steps in the same order every single time, never forgetting to run the tests, never deploying untested code because it’s 2 a.m. and you’re exhausted. Automation doesn’t get tired. It doesn’t have bad Fridays.

Here’s the thing — setting it up isn’t hard. It’s maybe an afternoon of work. And what follows is that afternoon, laid out step by step, so you can stop doing what I did and start doing what I should’ve done from day one.

What We’re Actually Building

Let me walk you through the full picture before we touch any YAML. We’re going to build a CI/CD pipeline for a Node.js application using GitHub Actions. When you push code to your main or develop branch, the pipeline will automatically lint your code, run your tests against a real PostgreSQL database, build a Docker image, push it to GitHub’s container registry, and deploy it — staging for the develop branch, production for main.

That’s continuous integration and continuous deployment in one workflow file. Every commit gets tested. Every passing test gets built. Every successful build gets deployed. No human in the loop for the mechanical parts.

You’ll need a GitHub repository (obviously), a Node.js project with tests, and a server you can SSH into for deployment. If you don’t have all of those yet, don’t worry — the pipeline we build will work whenever you’re ready. Just having it in your repo means it’ll activate the moment you push.

A Quick Mental Model

Before we jump into the workflow file, it helps to know how GitHub Actions thinks about work. I won’t give you a glossary — you’ll pick up the terminology as we go — but picture it like an assembly line in a factory.

Your workflow is the entire assembly line. It lives as a YAML file inside .github/workflows/ in your repository. Each workflow has jobs, which are stations on the line. A station might be “test the code” or “build the Docker image.” Within each job, you’ve got steps — individual tasks like “install dependencies” or “run ESLint.”

Jobs run in parallel by default, which is great for speed. But sometimes one job depends on another — you shouldn’t build a Docker image if the tests failed. So you can wire up dependencies between them. We’ll do exactly that.

Workflows trigger on events. A push to main. A pull request opened. A cron schedule. Even a manual button click. You choose. For our pipeline, we care about pushes and pull requests.

That’s really all you need to hold in your head. Let’s build.

The Full Workflow File

Create a file at .github/workflows/ci-cd.yml in your repository. I’m going to show you the entire thing first, then we’ll walk through each section. Normally I’d build it piece by piece, but honestly, seeing the full picture helps you understand how the parts connect.

name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint-and-test:
    name: Lint & Test
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npx eslint . --max-warnings 0

      - name: Run unit tests
        run: npm test -- --coverage
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  build:
    name: Build Docker Image
    needs: lint-and-test
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    permissions:
      contents: read
      packages: write

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

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

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}

      - name: Build and push
        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

  deploy-staging:
    name: Deploy to Staging
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'
    environment: staging

    steps:
      - name: Deploy to staging server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:develop
            docker compose -f docker-compose.staging.yml up -d
            docker system prune -f

  deploy-production:
    name: Deploy to Production
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
            docker compose -f docker-compose.prod.yml up -d --no-deps app
            docker system prune -f

      - name: Verify deployment
        run: |
          sleep 30
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myapp.com/health)
          if [ "$STATUS" != "200" ]; then
            echo "Health check failed with status $STATUS"
            exit 1
          fi

That’s roughly 120 lines of YAML. One file. And it replaces every manual deployment step you’ve ever done. Let’s break it down.

Walking Through the Pipeline, Piece by Piece

Triggers and Environment Variables

At the top of the file, the on block tells GitHub when to run the workflow. We’ve set it to trigger on pushes to main and develop, plus pull requests targeting main. Why pull requests too? Because you want to catch broken code before it merges. If someone opens a PR that fails the linting step, they’ll know immediately — no merge, no deploy, no late-night SSH sessions.

Below that, the env block sets three variables available to every job. NODE_VERSION pins your Node.js version so you’re not surprised by a runner upgrade. REGISTRY points to GitHub’s container registry (ghcr.io), and IMAGE_NAME uses your repository name as the Docker image name. Clean and predictable.

Job 1: Lint and Test

Here’s where most of the magic lives. Look at the services block — we’re spinning up a real PostgreSQL 16 container alongside our test runner. Not a mock. Not an in-memory substitute. An actual Postgres instance with a user, password, and database. Your tests hit a real database, which means they’ll catch real database bugs. I can’t overstate how important that is. I’ve seen tests pass against SQLite mocks and fail spectacularly in production Postgres because of type differences and constraint behavior.

Notice the health check options on the Postgres service. Without those, your tests might start before the database is ready to accept connections, and you’ll get mysterious connection refused errors. Ask me how I know. The pg_isready command pings Postgres every ten seconds, up to five times, before the runner considers the service healthy.

After checking out the code and setting up Node.js (with npm caching enabled — we’ll talk about why that matters later), we run npm ci instead of npm install. That’s deliberate. npm ci does a clean install from your package-lock.json, which is faster and more predictable. In a CI environment, you never want to accidentally upgrade a dependency because someone forgot to commit the lockfile.

ESLint runs with --max-warnings 0. Strict, yes. But it means warnings can’t pile up silently. If your linter finds something, the pipeline stops. You fix it or you don’t merge. That discipline pays off over months.

Then the tests run with coverage reporting. The coverage report gets uploaded as an artifact, which means you can download it from the Actions tab in GitHub any time — useful for tracking whether your test coverage is improving or slipping.

Job 2: Build the Docker Image

Only after linting and testing pass does the build job start. See the needs: lint-and-test line? That’s the dependency chain. And the if: github.event_name == 'push' condition means we only build images on actual pushes, not on pull requests. Why waste compute building a Docker image for a PR that might never merge?

Docker Buildx gives us multi-platform builds and better caching. We log into GitHub’s container registry using the automatically-provided GITHUB_TOKEN — no extra secrets needed for this part. The metadata action generates smart tags: a SHA-based tag for exact commit tracing, a branch-name tag for convenience, and semver tags if you use Git tags for releases.

Pay attention to the caching lines at the bottom. cache-from: type=gha and cache-to: type=gha,mode=max use GitHub Actions’ built-in cache to store Docker layer data. On a typical Node.js project, this can cut build times from five minutes to under one minute on subsequent runs. Your node_modules layer, your build output layer — all cached. Only the layers that actually changed get rebuilt.

Jobs 3 and 4: Deploy to Staging and Production

Both deployment jobs use the appleboy/ssh-action to connect to your server and run Docker commands. Staging deploys from the develop branch image. Production deploys from main. Simple branching strategy, straightforward deployment.

Notice the environment field on each job. GitHub Environments let you configure protection rules — like requiring a manual approval before production deploys. You could have two senior developers who must click “approve” before anything touches production. That’s a governance layer you get for free.

On production, there’s a health check step. After deploying, the pipeline waits thirty seconds (to give the container time to start), then hits your app’s /health endpoint. If it doesn’t return a 200 status, the pipeline fails. You’ll see it red in GitHub. You’ll get a notification. And if you’ve set up Slack notifications (which we’ll add shortly), your whole team will know.

One subtle detail: the production deploy uses --no-deps app in the docker compose command. That restarts only the app service, not your database or Redis or whatever else runs alongside it. You don’t want a code deployment to bounce your entire infrastructure.

Keeping Secrets Out of Your Workflow

I shouldn’t have to say this, but I’ve seen it enough times that I will: never, ever put credentials in your workflow file. Not even temporarily. Not even in a “private” repository. Workflow files get committed, they show up in pull requests, and they live forever in your Git history.

GitHub provides encrypted secrets at two levels. Repository secrets are available to every workflow in the repo. Environment secrets are scoped to a specific environment (staging, production, etc.) and can require approvals to access.

For our pipeline, you’ll need to set up these secrets:

# Required secrets for this pipeline:
# STAGING_HOST       - IP or hostname of staging server
# PROD_HOST          - IP or hostname of production server
# DEPLOY_USER        - SSH username for deployment
# SSH_PRIVATE_KEY    - Private key for SSH authentication
# GITHUB_TOKEN       - Automatically provided by GitHub

Go to your repository’s Settings > Secrets and variables > Actions to add them. For the SSH key, generate a dedicated deploy key — don’t reuse your personal one. If the key gets compromised, you revoke only the deploy key, and your personal access stays untouched.

I’d also recommend setting up GitHub Environments for staging and production separately. Navigate to Settings > Environments, create both, and add environment-specific secrets there. On production, enable “Required reviewers” and add yourself (and maybe a colleague). Now nobody — not even you in a moment of haste — can push to production without someone approving it first.

Making Your Pipeline Fast (Because Slow Pipelines Get Ignored)

Here’s a truth nobody tells you: a slow CI/CD pipeline is almost as bad as no pipeline at all. If your pipeline takes fifteen minutes, developers will stop waiting for it. They’ll merge before it finishes. They’ll start working on the next thing and forget about the failing build notification. I’ve watched this happen on three different teams.

Caching is your best weapon. We’ve already covered two kinds — npm caching via actions/setup-node and Docker layer caching via GitHub Actions cache. But there are more things worth caching, especially if you run end-to-end tests.

# Cache Cypress binary for E2E tests
- name: Cache Cypress
  uses: actions/cache@v4
  with:
    path: ~/.cache/Cypress
    key: cypress-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

# Cache Playwright browsers
- name: Cache Playwright
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

Cypress and Playwright both download large browser binaries. Without caching, that download happens every single run — sometimes adding two or three minutes. With caching, the binaries persist between runs and only re-download when your package-lock.json changes (which usually means a version upgrade).

A few more speed tips from experience. Run your linting and unit tests in the same job rather than splitting them into separate jobs. Job startup has overhead — spinning up a runner, checking out code, installing dependencies — and doing all of that twice wastes time. Splitting jobs makes sense when they need different environments (like our test job needing Postgres but our build job needing Docker), but not for steps that share the same setup.

Also: npm ci is faster than npm install in CI, but you can go further. If your project has a lot of native dependencies, consider caching the entire node_modules directory. The tradeoff is a slightly stale cache sometimes, but the speed improvement — especially for projects with packages like sharp or bcrypt that compile native code — can be dramatic.

Notifications: Knowing What Happened Without Checking GitHub

A pipeline that fails silently is a pipeline that fails. You want notifications — in Slack, in Discord, in email, wherever your team actually looks. Here’s a Slack notification step you can add to any job:

      - name: Notify Slack
        if: always()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: repo,message,commit,author,workflow
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

The if: always() condition is crucial. By default, steps only run if the previous step succeeded. But you want the notification to fire whether the job passed or failed — especially when it failed. That’s when you need to know.

Drop this step at the end of your deploy jobs. When someone pushes to main and production deploys successfully, the team sees a green notification. When it fails, they see red. No one has to go check.

And while you’re at it, add a status badge to your README so anyone visiting the repo can see if the pipeline’s healthy:

![CI/CD](https://github.com/YOUR_USER/YOUR_REPO/actions/workflows/ci-cd.yml/badge.svg)

Small thing, but it signals professionalism. It says “we test our code” without saying a word.

Branch Protection: Making the Pipeline Mandatory

You’ve built the pipeline. Tests run. Deployments happen. But there’s a hole: nothing stops someone from pushing directly to main and bypassing everything. If a developer force-pushes to main without running the pipeline, all your automation is worthless.

Fix this with branch protection rules. Go to Settings > Branches > Branch protection rules and add a rule for your main branch. Enable these:

  • Require status checks to pass before merging — select the lint-and-test job. Nobody merges unless the tests pass.
  • Require pull request reviews before merging — at least one approval from a teammate. Two eyes are better than one.
  • Do not allow bypassing the above settings — yes, even for admins. Especially for admins. I’ve seen CTOs merge hotfixes at midnight that break things worse than the original bug.

With branch protection in place, the only path to production is: write code, open a PR, pass the automated checks, get a human review, merge, and let the pipeline deploy. Every step has a checkpoint. Every checkpoint has a purpose.

What Happens When Things Go Wrong

They will. Pipelines break. Tests flake. Docker builds time out. Servers become unreachable during deployment. I want to arm you with a few troubleshooting patterns because the first time your pipeline goes red after you’ve just set it up, you might panic.

Tests pass locally but fail in CI. Almost always an environment difference. Check your Node.js version, your database setup, and your environment variables. The most common cause I’ve seen? A test that depends on a local file or environment variable that only exists on the developer’s machine. Your CI runner starts fresh every time — treat it like a brand-new computer that knows nothing about your project except what’s in the repo.

Docker build fails with “no space left on device.” GitHub runners have limited disk space (about 14 GB on Ubuntu runners). If your Docker image is large, you might run out. Add a cleanup step before the build: docker system prune -af to reclaim space from old images and containers. Or better, slim down your Docker image. Multi-stage builds are your friend — don’t ship your node_modules dev dependencies in your production image.

Deployment succeeds but the app doesn’t work. Your health check should catch this, but if it doesn’t, check your environment variables on the server. Missing or wrong env vars are behind roughly half of all “it deployed but it’s broken” incidents in my experience. Use a .env.example file in your repo to document what’s needed, and verify those variables exist on your server before deploying.

Pipeline takes forever. Audit your caching. Are your npm dependencies cached? Docker layers? Browser binaries? A cache miss on any of these can add minutes. Also check if you’re running unnecessary jobs on pull requests — you probably don’t need to build Docker images or deploy when someone’s just opened a PR for review.

Growing Your Pipeline Over Time

What I’ve shown you is a foundation, not a ceiling. Once you have the basic lint-test-build-deploy cycle working, there’s a whole world of improvements you can layer on. A few I’d suggest exploring when you’re ready.

Database migrations in the pipeline. If you’re using something like Prisma or Knex, add a migration step between the build and deploy jobs. Run npx prisma migrate deploy (or your equivalent) before restarting the app. That way your database schema is always in sync with your code.

End-to-end tests with Playwright. Add a job after your unit tests that spins up your app and runs Playwright against it. GitHub Actions supports running services alongside your tests, so you can have your app, your database, and your browser automation all running in the same job. It’s heavier, but it catches entire categories of bugs that unit tests miss.

Canary deployments. Instead of deploying to every server at once, deploy to one first. Run your health checks. If everything’s green, roll out to the rest. You can implement this with a simple sequential job structure — deploy-canary, then verify-canary, then deploy-all.

Scheduled security scans. Add a workflow that runs nightly (using the schedule trigger) and runs npm audit or a tool like Snyk against your dependencies. You don’t want to find out about a critical vulnerability from Twitter — you want your pipeline to tell you Monday morning with a Slack notification.

Matrix builds for multiple Node.js versions. If your library or API needs to support multiple Node versions, GitHub Actions has a matrix strategy that runs your tests against each version in parallel. It’s one line of config for a lot of confidence.

The Mindset Shift

When I was doing git pull deployments, I thought of deployment as a task. Something a person does at the end of a sprint. Something that requires focus and attention and a quiet office. And I was good at it — I had the commands memorized, I knew the server IPs by heart, I could deploy in under five minutes.

But being good at a manual process doesn’t make the manual process good. I was a single point of failure. If I went on vacation, deployments stalled. If I was sick, they waited. If I made a mistake at 2 a.m., nobody caught it until morning. And the knowledge of how to deploy lived in my head, not in a file anyone could read.

A CI/CD pipeline isn’t just automation. It’s documentation. It’s a contract between your team and your code. It says: “here are the exact steps to go from commit to production, and a machine will execute them perfectly, every single time.” Anyone can read the YAML and understand what happens. Anyone can modify it. Nobody has to be the deployment hero.

That’s probably the biggest thing I want you to take away from all of this. Not the YAML syntax (you’ll look that up anyway). Not the specific actions we used (they’ll get updated). But the idea that your deployment process belongs in version control, tested and automated, just like your application code.

My pipeline at work has grown quite a bit since that first version. We run Playwright E2E tests, database migrations, Slack notifications, coverage thresholds, even a Lighthouse audit for performance regressions. Some of those additions happened because of bugs — we added the Lighthouse check after a CSS change tanked our mobile performance score and nobody noticed for a week. Each addition makes the pipeline smarter, and makes the team more confident with every push.

So let me leave you with a question, one I genuinely want you to sit with: what would you automate first? Maybe it’s testing. Maybe it’s deployment. Maybe it’s something I haven’t mentioned — linting your commit messages, generating changelogs, posting release notes. Look at your current workflow and find the one thing you do manually that a machine could do better. Then build the YAML. Push it. Watch it run.

That first green checkmark? It’ll change how you think about shipping code.

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.