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" ]
`
For best practice, run your project using the entrypoint.sh:
#!/bin/bash
node index.js
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
`
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
`
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)