DEV Community

Cover image for Containerizing a Node.js API with Docker and Automating Deployments to Amazon ECR with GitHub Action
anitaalicloud
anitaalicloud

Posted on • Edited on

Containerizing a Node.js API with Docker and Automating Deployments to Amazon ECR with GitHub Action

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


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"]
Enter fullscreen mode Exit fullscreen mode

A few important details:

  • npm ci reads from package-lock.json to install exact dependency versions, making builds reproducible across environments
  • --from=builder copies only the files the app needs to run. Nothing else from the build stage makes it into the final image. Running as appuser instead 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
Enter fullscreen mode Exit fullscreen mode

In a second terminal:

curl http://localhost:3000/
curl http://localhost:3000/health
curl http://localhost:3000/me
Enter fullscreen mode Exit fullscreen mode

Verify the running container:

docker ps
Enter fullscreen mode Exit fullscreen mode

Push the Dockerfile to GitHub:

git add Dockerfile
git commit -m "docker: add multi-stage Dockerfile"
git push origin main
Enter fullscreen mode Exit fullscreen mode

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

  1. Log into AWS as your IAM user (not root)
  2. Go to ECR → Create repository
  3. Make sure you are in the correct region — eu-north-1 (Stockholm) in my case
  4. Name the repository personal-api, visibility: Private
  5. Click Create repository
  6. Copy the repository URI — you will need this shortly:
   123456789012.dkr.ecr.eu-north-1.amazonaws.com/personal-api
Enter fullscreen mode Exit fullscreen mode

Via AWS CLI

aws ecr create-repository \
  --repository-name personal-api \
  --region eu-north-1
Enter fullscreen mode Exit fullscreen mode



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:

  1. Go to IAM → Users → Create user
  2. Name it devops-user
  3. Attach the policy: AmazonEC2ContainerRegistryFullAccess
  4. Go to Security credentials → Create access key
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)