DEV Community

Shongkor Talukdar
Shongkor Talukdar

Posted on

Deploy a Node Application on an AWS EC2 Instance using CI/CD Pipeline.

Project Overview

This is a minimal Node.js/Express backend server. The application itself is straightforward — a single Express 5 app (index.js) that listens on port 3000 and returns "Hello World in AWS EC2!" at the root route. The real substance of the project lies in its deployment infrastructure.

CI/CD Pipeline

The pipeline is split across two GitHub Actions workflows that chain together to form a complete automated delivery process from code commit to live deployment on AWS EC2.

There are two parts of these settings. In your root directory, create a file named Dockerfile

`
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
COPY . .
RUN npm install
EXPOSE 3000
RUN ["chmod", "+x", "./entrypoint.sh"]
ENTRYPOINT [ "sh","./entrypoint.sh" ]

`
Enter fullscreen mode Exit fullscreen mode

For best practice, run your project using the entrypoint.sh:


 #!/bin/bash
 node index.js

Enter fullscreen mode Exit fullscreen mode

then in order to set DOCKER_HUB_TOKEN & DOCKER_USER_NAME ,

  • First register on https://app.docker.com
  • Create a repository named the same as Github repository.

  • Go to settings and generate a new access token and copy that token.

  • Go to your created GitHub repository settings section. In the secret and variable section, you will find Action and create a repository secret key named DOCKER_HUB_TOKEN --> paste your copied dockerhub tocken. In the same way, create another variable named DOCKER_USER_NAME --> copy the username of your Dockerhub account and paste here.

Stage 1 — Build and Publish to Docker Hub (docker_registry.yml)
This workflow triggers on every push to the main branch.

(docker_registry.yml)
`
name: Publish Docker Image to Docker Hub

on:
  push:
    branches: ['main']

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v3

      - name: Login to docker hub
        run: echo "${{secrets.DOCKER_HUB_TOKEN}}" | docker login -u "${{secrets.DOCKER_USER_NAME}}" --password-stdin

      - name: Build Docker Image
        run: docker build . --file Dockerfile --tag "${{secrets.DOCKER_USER_NAME}}"/study-abroad-agency-server:latest

      - name: Push Docker Image
        run: docker push "${{secrets.DOCKER_USER_NAME}}"/study-abroad-agency-server:latest

`
Enter fullscreen mode Exit fullscreen mode

It runs on a GitHub-hosted Ubuntu runner and performs three steps: it checks out the code, logs into Docker Hub using stored secrets (DOCKER_HUB_TOKEN and DOCKER_USER_NAME), builds a Docker image from the Dockerfile, and pushes it to Docker Hub tagged as latest.
The Dockerfile itself is lean — it uses the node:18-alpine base image, copies the application files, installs dependencies via npm install, exposes port 3000, and delegates startup to entrypoint.sh, which simply runs node index.js.

Now, before you get into the next stage. In your GitHub project repository, go to the Settings ** section, find the **Action sections click on *Runners *, and create a new self-hosted runner.
Finally, you need to set up your newly created AWS EC2 instance. Get access to your instance and run all of those commands that you found from Github self-hosted runner section.

Stage 2 — Deploy to AWS EC2 (awsec2deploy.yml)
This workflow is triggered not by a git event, but by the completion of the first workflow (workflow_run trigger).

(awsec2deploy.yml)

`
name: Publish Docker Images to AWS EC2

on:
  workflow_run:
    workflows: ['Publish Docker Image']
    types: [completed]

jobs:
  build:
    runs-on: self-hosted

    steps:
      - name: Stop the old container
        run: docker stop study-abroad-agency-server || true

      - name: Delete the old container
        run: docker rm study-abroad-agency-server || true

      - name: Delete the old image
        run: docker rmi ${{secrets.DOCKER_USER_NAME}}/study-abroad-agency-server:latest || true

      - name: Pull the image from dockerhub
        run: docker pull ${{secrets.DOCKER_USER_NAME}}/study-abroad-agency-server:latest

      - name: Run the image
        run: docker compose up -d

`
Enter fullscreen mode Exit fullscreen mode

This is the key architectural decision — it creates a sequential dependency between the two pipelines, ensuring deployment only proceeds after a successful image publish.
This workflow runs on a self-hosted runner, meaning GitHub Actions is executing directly on the EC2 instance itself. This is how it achieves direct deployment without needing SSH or additional tooling like CodeDeploy. The steps follow a clean replace-and-redeploy pattern: stop and remove the old container, delete the old image, pull the fresh latest image from Docker Hub, and finally bring the container back up using docker compose up -d.
The docker-compose.yml is minimal — it references the published image, names the container, and uses network_mode: host so the container shares the EC2 instance's network stack directly, making the app accessible on port 3000 without port mapping.

End-to-End Flow

In summary, a developer pushing to main sets off the following chain:
_Git push → GitHub Actions builds Docker image → Pushes to Docker Hub → EC2 self-hosted runner pulls new image → Restarts container via Docker Compose
This is a practical and cost-effective CI/CD setup for a single-server deployment. _

The main trade-off is that network_mode: host and a self-hosted runner introduce some operational coupling to the specific EC2 instance, which would need to be revisited if the architecture scaled to multiple instances or required zero-downtime deployments.

Github Code link : [https://github.com/Shongkor/study-abroad-agency-server]

Top comments (0)