Introduction
In my previous article, I walked through building a lightweight Node.js REST API and deploying it live on an AWS EC2 instance with Nginx and PM2. If you haven't read that yet, I'd recommend starting there — https://dev.to/anitaalicloud/how-to-build-and-deploy-a-nodejs-rest-api-on-aws-ec2-with-pm2-cg8 — as this article picks up right where that one left off.
Here, we take the same API further. We containerize it using a multi stage Dockerfile, create a private Amazon ECR repository to store our images, and wire up a GitHub Actions CI pipeline that automatically builds, scans, and pushes a versioned Docker image every time we push to main.
By the end, every push to your repository will trigger a pipeline that produces a clean, security scanned, semantically versioned Docker image sitting in ECR with zero manual steps.
What We Are Adding
| What | Why |
|---|---|
| Multi-stage Dockerfile | Containerize the API with a lean, secure production image |
| Amazon ECR | Private registry to store and version our Docker images |
| GitHub Actions CI | Automate the build, scan, and push on every push to main
|
| Trivy security scan | Gate the pipeline — no vulnerable image reaches ECR |
| Semantic versioning | Every image build gets a unique, traceable tag |
Prerequisites
- The Node.js API from Article 1- https://dev.to/anitaalicloud/how-to-build-and-deploy-a-nodejs-rest-api-on-aws-ec2-with-pm2-cg8 already on GitHub
- Docker installed locally
- An AWS account with an IAM user (not root)
- A public GitHub repository
Step 1: The Multi Stage Dockerfile
Why Multi Stage?
A standard Dockerfile builds everything in one layer, dev tools, build dependencies, and runtime all end up in the final image. A multi-stage build separates concerns: the first stage installs and builds, the second stage starts clean and copies only what is needed to run the app. The result is a smaller, more secure production image.
Create a Dockerfile in the root of your repo:
# ─── Stage 1: Build ───────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# ─── Stage 2: Production ──────────────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
# Run as a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/index.js .
COPY --from=builder /app/package.json .
USER appuser
EXPOSE 3000
CMD ["node", "index.js"]
A few important details:
-
npm cireads frompackage-lock.jsonto install exact dependency versions, making builds reproducible across environments -
--from=buildercopies only the files the app needs to run. Nothing else from the build stage makes it into the final image. Running asappuserinstead of root reduces the attack surface if the container is ever compromised
Testing the Build Locally
docker build --target production -t personal-api:local .
docker run -p 3000:3000 personal-api:local
In a second terminal:
curl http://localhost:3000/
curl http://localhost:3000/health
curl http://localhost:3000/me
Verify the running container:
docker ps
Push the Dockerfile to GitHub:
git add Dockerfile
git commit -m "docker: add multi-stage Dockerfile"
git push origin main
Step 2: Create the Amazon ECR Repository
ECR is AWS's private container registry. This is where our pipeline will push versioned images.
Via AWS Console
- Log into AWS as your IAM user (not root)
- Go to ECR → Create repository
- Make sure you are in the correct region —
eu-north-1(Stockholm) in my case - Name the repository
personal-api, visibility: Private - Click Create repository
- Copy the repository URI — you will need this shortly:
123456789012.dkr.ecr.eu-north-1.amazonaws.com/personal-api
Via AWS CLI
aws ecr create-repository \
--repository-name personal-api \
--region eu-north-1
Step 3: Set Up the IAM User
Never use root AWS credentials in a CI pipeline. Create a scoped IAM user with only the permissions the pipeline needs:
- Go to IAM → Users → Create user
- Name it
devops-user - Attach the policy:
AmazonEC2ContainerRegistryFullAccess - Go to Security credentials → Create access key
- Select CLI, then copy both the Access Key ID and Secret Access Key — you only see the secret key once
Step 4: Add GitHub Secrets
The pipeline uses your AWS credentials to authenticate with ECR. Store them as GitHub secrets so they are never exposed in your code.
Go to your repo → Settings → Secrets and variables → Actions → New repository secret and add:
| Secret | Value |
|---|---|
AWS_ACCESS_KEY_ID |
Your IAM access key ID |
AWS_SECRET_ACCESS_KEY |
Your IAM secret access key |
AWS_REGION |
eu-north-1 |
ECR_REPOSITORY_URI |
Your full ECR URI |
Step 5: The GitHub Actions CI Pipeline
Create the file .github/workflows/ci.yml in your repo:
name: CI — Build, Scan & Push to ECR
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
ECR_REPOSITORY_URI: ${{ secrets.ECR_REPOSITORY_URI }}
AWS_REGION: ${{ secrets.AWS_REGION }}
jobs:
build-scan-push:
name: Build, Scan & Push
runs-on: ubuntu-latest
steps:
# ── Phase 1: Build ─────────────────────────────────────────
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate semantic version
id: version
run: |
MAJOR=1
MINOR=0
PATCH=${{ github.run_number }}
echo "VERSION=v${MAJOR}.${MINOR}.${PATCH}" >> $GITHUB_OUTPUT
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@v2
- name: Build Docker image
run: |
docker build \
--target production \
-t ${{ env.ECR_REPOSITORY_URI }}:${{ steps.version.outputs.VERSION }} \
-t ${{ env.ECR_REPOSITORY_URI }}:latest \
.
# ── Phase 2: Scan ──────────────────────────────────────────
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.ECR_REPOSITORY_URI }}:${{ steps.version.outputs.VERSION }}
format: table
exit-code: 1
severity: CRITICAL
ignore-unfixed: true
# ── Phase 3: Push ──────────────────────────────────────────
- name: Push image to ECR
run: |
docker push ${{ env.ECR_REPOSITORY_URI }}:${{ steps.version.outputs.VERSION }}
docker push ${{ env.ECR_REPOSITORY_URI }}:latest
Understanding the Three Pipeline Phases
Phase 1 — Build
The pipeline checks out the code, generates a semantic version tag, authenticates with AWS using the secrets you added, logs into ECR, and builds the Docker image.
The version tag follows the format v{MAJOR}.{MINOR}.{PATCH}. Major and minor are set manually. PATCH is set to github.run_number — a counter GitHub increments automatically on every pipeline run. So your images end up tagged like:
v1.0.1 ← first push
v1.0.2 ← second push
v1.0.3 ← third push
Each build also gets a latest tag pointing to the most recent successful push. This means you always have a traceable history of every image ever built, and rolling back is as simple as pulling a previous version tag from ECR.
Phase 2 — Scan
Before any image reaches ECR, Trivy scans it for known security vulnerabilities. Setting exit-code: 1 means the pipeline fails immediately if any CRITICAL vulnerabilities are found — the image is discarded and never pushed to the registry.
ignore-unfixed: true skips vulnerabilities that have no available fix yet. This keeps the pipeline practical rather than blocking builds over issues that cannot be resolved at this point in time.
Phase 3 — Push
This step only runs if the Trivy scan passes. It pushes both tags — the semantic version and latest — to ECR. After a successful run, you can verify the pushed image in AWS Console → ECR → personal-api → Images.
Triggering the Pipeline
Push the workflow file to GitHub:
git add .github/workflows/ci.yml
git commit -m "ci: add GitHub Actions CI pipeline"
git push origin main
Go to your repo → Actions tab and watch the pipeline run through all three phases.
Final Project Structure
nodejs-api-ci/
├── .github/
│ └── workflows/
│ └── ci.yml # CI pipeline
├── Dockerfile # Multi-stage build
├── index.js # Express API
├── package.json
├── package-lock.json
└── README.md
Key Takeaways
- Multi-stage Dockerfiles produce smaller, cleaner images. The production stage only contains what is needed to run — nothing from the build environment leaks through.
- Never use root AWS credentials in CI. A scoped IAM user with only ECR permissions limits the blast radius if credentials are ever compromised.
- Never hardcode credentials. GitHub secrets keep sensitive values out of your codebase entirely — the workflow file is public, your secrets are not.
- Scan before you push. Trivy as a pipeline gate means only clean images reach your registry.
- Semantic versioning makes every build traceable. If something breaks in production, you know exactly which image is running and can roll back to any previous version instantly.
Live Project
📦 GitHub Repo: https://github.com/AnitaAliCloud/nodejs-api-ci
🐳 ECR Repository: devopsprod.duckdns.org
This is Part 2 of my DevOps series. Read Part 1 here: https://dev.to/anitaalicloud/how-to-build-and-deploy-a-nodejs-rest-api-on-aws-ec2-with-pm2-cg8





Top comments (0)