DEV Community

Cover image for Setting Up CI/CD for a Node.js Backend with GitHub Actions and AWS
Sahinur
Sahinur

Posted on

Setting Up CI/CD for a Node.js Backend with GitHub Actions and AWS

Deploying a backend manually works when a project is small.

SSH into the server, pull the latest code, install dependencies, restart the application, and check whether everything is working.

But as the application and team grow, this process becomes repetitive—and manual steps are easy to get wrong.

That's where CI/CD becomes useful.

In this post, I'll walk through a practical CI/CD approach for a Node.js + Express backend deployed on AWS EC2 using GitHub Actions.

Before: How we deployed

A simple manual deployment can look like this:

Developer
   ↓
Git Push
   ↓
GitHub
   ↓
SSH into EC2
   ↓
git pull
   ↓
npm install
   ↓
PM2 restart
Enter fullscreen mode Exit fullscreen mode

It works.

But every deployment depends on someone remembering the correct sequence of commands.

A small mistake can cause problems:

  • Forgetting to pull the latest code
  • Installing dependencies incorrectly
  • Restarting the wrong process
  • Deploying from the wrong branch
  • Forgetting an environment variable
  • Restarting the application before checking the build

The goal of CI/CD is to make this process more predictable.

The pipeline I want

For a Node.js backend, a simple pipeline can look like:

Developer
    ↓
GitHub
    ↓
GitHub Actions
    ↓
Install Dependencies
    ↓
Run Tests
    ↓
Build / Validate
    ↓
Deploy to AWS EC2
    ↓
Restart Application
    ↓
Health Check
Enter fullscreen mode Exit fullscreen mode

The important idea is that a deployment should not simply mean:

"Push code → restart server."

It should mean:

"Push code → verify code → deploy → verify deployment."

Step 1: Create the workflow

GitHub Actions workflows live inside:

.github/
└── workflows/
    └── deploy.yml
Enter fullscreen mode Exit fullscreen mode

A basic workflow can start like this:

name: Deploy Node.js API

on:
  push:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        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 tests
        run: npm test
Enter fullscreen mode Exit fullscreen mode

Now every push to the main branch can automatically trigger the workflow.

Step 2: Validate before deploying

One of the most important principles I follow with CI/CD is:

Don't deploy code that hasn't passed basic checks.

For example:

- name: Run tests
  run: npm test
Enter fullscreen mode Exit fullscreen mode

You can also include linting:

- name: Run lint
  run: npm run lint
Enter fullscreen mode Exit fullscreen mode

And if the project has a build step:

- name: Build application
  run: npm run build
Enter fullscreen mode Exit fullscreen mode

The exact commands depend on the project.

The important thing is that the deployment job should depend on these checks succeeding.

Step 3: Deploy to EC2

There are several ways to deploy an application to EC2.

A straightforward approach is to connect to the server and execute deployment commands.

Conceptually:

GitHub Actions
      ↓
SSH
      ↓
EC2
      ↓
Pull latest code
      ↓
Install dependencies
      ↓
Restart PM2
Enter fullscreen mode Exit fullscreen mode

A deployment command might look like:

cd /var/www/my-api

git pull origin main

npm ci

pm2 restart my-api
Enter fullscreen mode Exit fullscreen mode

The exact directory, process name, and deployment strategy depend on the application.

Step 4: Keep secrets out of Git

This is one of the most important parts of CI/CD.

I don't want credentials such as:

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
DATABASE_URL
JWT_SECRET
Enter fullscreen mode Exit fullscreen mode

inside the repository.

Instead, sensitive values should be managed through appropriate secret-management mechanisms.

For GitHub Actions, repository or environment secrets can be used for CI/CD credentials.

For the application itself, production configuration should ideally be managed separately from source code.

The basic rule is:

Source code can be committed. Secrets should not be.

What broke the first time

CI/CD looks simple when drawn as a diagram.

Real deployments are rarely that simple.

Some common problems I watch for are:

1. Environment differences

The application works locally:

Local → Works
Enter fullscreen mode Exit fullscreen mode

But production has:

Production → Missing environment variable
Enter fullscreen mode Exit fullscreen mode

The code may be correct while the deployment configuration is not.

2. Node.js version mismatch

For example:

Local:
Node.js 20

CI:
Node.js 18

Production:
Node.js 20
Enter fullscreen mode Exit fullscreen mode

This can create confusing build or dependency issues.

That's why I prefer explicitly defining the Node.js version in CI.

3. Dependency installation

Using:

npm install
Enter fullscreen mode Exit fullscreen mode

can produce different dependency resolution behavior depending on the project state.

For CI environments, I generally prefer:

npm ci
Enter fullscreen mode Exit fullscreen mode

when a valid package-lock.json is available.

4. Restarting the wrong process

With PM2, the deployment isn't finished just because the new code reached the server.

The correct application process needs to be restarted or reloaded.

After deployment, I want to verify:

pm2 status
Enter fullscreen mode Exit fullscreen mode

and inspect logs when necessary:

pm2 logs
Enter fullscreen mode Exit fullscreen mode

Health checks matter

A successful deployment command doesn't necessarily mean a successful deployment.

For example:

git pull       ✓
npm ci         ✓
pm2 restart    ✓
Enter fullscreen mode Exit fullscreen mode

The application could still be returning:

HTTP 500
Enter fullscreen mode Exit fullscreen mode

That's why a health check is useful.

For example:

curl -f https://api.example.com/health
Enter fullscreen mode Exit fullscreen mode

If the health endpoint fails, the deployment should be treated as unsuccessful.

This gives the pipeline a much better definition of "success."

Rollback strategy

One of the biggest things I want from a deployment system is a safe way to recover.

A simple rollback strategy can be based on Git commits.

For example:

Version A
   ↓
Deploy
   ↓
Version B
   ↓
Problem detected
   ↓
Rollback
   ↓
Version A
Enter fullscreen mode Exit fullscreen mode

Before making the deployment more advanced, I want to make sure the team knows:

  1. What version is currently running?
  2. What changed?
  3. How do we return to the previous version?
  4. How do we verify the rollback?

For larger systems, more advanced strategies such as blue/green or canary deployments can provide safer releases.

What a mature pipeline looks like

Eventually, the workflow can become:

              ┌──────────────┐
              │   Developer  │
              └──────┬───────┘
                     │
                     ▼
                ┌─────────┐
                │ GitHub  │
                └────┬────┘
                     │
                     ▼
             ┌───────────────┐
             │ GitHub Actions│
             └───────┬───────┘
                     │
          ┌──────────┴──────────┐
          ▼                     ▼
     Install/Test            Lint/Build
          │                     │
          └──────────┬──────────┘
                     ▼
                  Deploy
                     │
                     ▼
                  AWS EC2
                     │
                     ▼
               Health Check
                     │
              ┌──────┴──────┐
              ▼             ▼
           Success        Failure
              │             │
              ▼             ▼
          Complete       Rollback
Enter fullscreen mode Exit fullscreen mode

The goal isn't to create the most complicated pipeline possible.

It's to remove unnecessary manual work while making deployments safer.

What I learned

The biggest lesson for me is that CI/CD isn't just automation.

It's about creating a repeatable engineering process.

A good pipeline should answer:

  • Has the code been tested?
  • Can it be deployed safely?
  • What version was deployed?
  • Did the deployment actually work?
  • Can we recover if something goes wrong?

Even a relatively simple Node.js backend can benefit from this mindset.

Final takeaway

Manual deployment isn't necessarily bad.

It's just difficult to scale.

As projects grow, automation becomes valuable because it reduces repetitive work and makes the deployment process predictable.

For a Node.js + Express backend on AWS EC2, a simple GitHub Actions pipeline can provide a solid starting point:

Code
 ↓
Test
 ↓
Build
 ↓
Deploy
 ↓
Health Check
Enter fullscreen mode Exit fullscreen mode

From there, the pipeline can evolve with the application.

The goal of CI/CD isn't to deploy faster at any cost.

It's to deploy more consistently, more safely, and with greater confidence.

Top comments (0)