Deploying software manually by SSHing into servers, pulling code, and restarting processes is highly error-prone. This guide outlines how to build a containerized, self-healing, automated CI/CD pipeline that builds, tests, and deploys Node.js applications onto AWS EC2 with zero-downtime Nginx updates.
Without structured deployment processes, software teams encounter:
To achieve a reliable, secure deployment, we combine four layers:
The delivery flow is fully automated:
1. Developer pushes a commit to the main branch.
2. GitHub Actions runner checks out code and runs test blocks.
3. If tests pass, it compiles a multi-stage Docker build and pushes the versioned image to Docker Hub.
4. The workflow runs an SSH deploy script, logging into the EC2 instance using a secure PEM private key.
5. On the EC2 terminal, Docker Compose pulls the updated image, recreates the container, and restarts Nginx to apply proxy routing.
The automated CI/CD deployment architecture and network topology are shown below:
[ Developer Machine ] --( Git Push Commit )--> [ GitHub Repo ]
|
( Triggers Actions Workflow )
|
[ GitHub Runner Node ]
/ \
( Build & Push Image ) ( SSH Deploy Cmd )
/ \
[ Docker Hub ] [ AWS EC2 VM Instance ]
| |
| v
| [ Docker Compose Hook ]
| |
`----( Pulls New Image )------'
|
[ Local Nginx Proxy ]
/ \
( Port 80/443 Traffic ) ( Port 3000 App )
| |
[ Client Browser ] <====( SSL TLS HTTPS Secure Web Traffic )' `-[ Node API Docker ]Below is an optimized, multi-stage production Dockerfile and the corresponding GitHub Actions deploy script:
# --- Stage 1: Build & Dependencies ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# --- Stage 2: Minimal Runtime ---
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/src ./src
EXPOSE 3000
USER node
CMD ["node", "src/index.js"]Below is the corresponding GitHub Actions pipeline configuration (.github/workflows/deploy.yml):
name: Production EC2 Deployment
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/node-app:latest
- name: Deploy to AWS EC2 via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_PRIVATE_KEY }}
script: |
docker pull ${{ secrets.DOCKER_USERNAME }}/node-app:latest
docker-compose down
docker-compose up -d
docker system prune -fUSER node inside our Dockerfile ensures the process runs under limited privileges, preventing container escape attacks.restart: always or restart: unless-stopped inside your Compose file to tell Docker to automatically spin the application back up.I implemented this CI/CD infrastructure in awsdeployment. The pipeline securely deploys Node.js docker images to an AWS EC2 instance, utilizing Nginx to resolve custom domain routes and automatically renew Let's Encrypt certificates.