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
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
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
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
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
You can also include linting:
- name: Run lint
run: npm run lint
And if the project has a build step:
- name: Build application
run: npm run build
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
A deployment command might look like:
cd /var/www/my-api
git pull origin main
npm ci
pm2 restart my-api
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
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
But production has:
Production → Missing environment variable
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
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
can produce different dependency resolution behavior depending on the project state.
For CI environments, I generally prefer:
npm ci
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
and inspect logs when necessary:
pm2 logs
Health checks matter
A successful deployment command doesn't necessarily mean a successful deployment.
For example:
git pull ✓
npm ci ✓
pm2 restart ✓
The application could still be returning:
HTTP 500
That's why a health check is useful.
For example:
curl -f https://api.example.com/health
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
Before making the deployment more advanced, I want to make sure the team knows:
- What version is currently running?
- What changed?
- How do we return to the previous version?
- 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
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
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)