A hands-on guide to implementing Continuous Integration and Continuous Deployment using GitHub Actions with a Node.js application. This guide walks through three progressive scenarios: basic CI, AWS EC2 deployment, and Docker Hub integration.
- Understanding CI/CD
- Prerequisites and Setup
- Scenario 1: Basic CI Pipeline
- Scenario 2: Real-World AWS EC2 Deployment
- Scenario 3: Docker Hub Integration
- GitHub Actions Core Concepts
- Comprehensive Troubleshooting
- Best Practices and Next Steps
You can find the completed project for this guide at the following repository:
https://github.com/daretechie/node-ci-demo
Imagine running a busy restaurant kitchen where multiple chefs work together. Without proper coordination, orders get mixed up, dishes take too long, and quality suffers. CI/CD acts like a well-orchestrated kitchen system:
Continuous Integration (CI) is like having all chefs constantly combining their prep work into the main cooking station. Every ingredient (code change) gets tested and verified before it joins the main dish (main branch). This ensures that when Chef A adds seasoning and Chef B adds vegetables, they work together harmoniously.
Continuous Deployment (CD) is like having an automated system that immediately serves completed dishes to customers. Once a dish passes all quality checks in the kitchen, it automatically goes to the dining room without manual intervention.
- Deploy multiple times per day: Instead of weekly releases, push features as soon as they're ready
- Catch bugs before customers do: Automated tests run every time code changes, preventing broken features from reaching production
- Reduce deployment anxiety: Small, frequent changes are easier to troubleshoot than large, infrequent releases
- Focus on coding, not deployment: Automation handles the repetitive deployment tasks
┌─────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ Code │ │ GitHub │ │ GitHub │ │ AWS EC2 │
│ Changes │───▶│ Repository │───▶│ Actions │───▶│ Deployment │
│ (Push) │ │ │ │ (CI/CD) │ │ │
└─────────────┘ └──────────────┘ └───────────────┘ └──────────────┘
CI/CD Pipeline Overview
Before diving into the hands-on scenarios, ensure your development environment includes these essential tools:
Required Software and Accounts:
- Git installed locally with basic command knowledge (clone, commit, push, pull)
- GitHub account with repository creation permissions
- Node.js and npm installed (verify with
node --versionandnpm --version) - Code editor like VS Code, Atom, or Sublime Text
- Terminal or command prompt access
- Stable internet connection for GitHub and cloud services
For Advanced Scenarios:
- AWS account with EC2 instance access
- Docker Desktop installed locally
- Docker Hub account for container registry
- Basic understanding of SSH and cloud computing concepts
This foundational scenario demonstrates how to set up automated testing that runs every time code changes are pushed to GitHub. Think of this as setting up quality control checkpoints in our restaurant kitchen.
Start by creating a new project directory and moving into it:
mkdir node-ci-demo && cd node-ci-demoInitialize a Node.js project, which creates the package.json file that describes our application:
npm init -yInstall Express.js, a popular web framework that will help us create a simple web server:
npm install expressCreate the main application file called index.js with this content:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
// Define a route that responds with a greeting
app.get("/", (req, res) => {
res.send("Hello CI/CD World!");
});
// Start the server and listen on the specified port
app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`);
});This creates a simple web server that responds with "Hello CI/CD World!" when someone visits the root URL. The process.env.PORT allows deployment platforms to specify which port to use.
Update the package.json scripts section to include start and test commands:
{
"scripts": {
"start": "node index.js",
"test": "echo \"Running tests...\" && exit 0"
}
}The test script currently just prints a message and exits successfully. In real applications, this would run actual test suites.
Initialize Git in your project directory:
git initConnect your local repository to a GitHub repository (replace with your actual GitHub username and repository name):
git remote add origin https://github.com/<your-username>/node-ci-demo.gitAdd all files to Git, create your first commit, and push to GitHub:
git add .
git commit -m "Initial commit with Node app"
git push -u origin mainAt this point, your code is stored on GitHub and ready for automation. The -u origin main sets up tracking so future pushes only need git push.
Create the directory structure that GitHub Actions expects:
mkdir -p .github/workflowsThe .github/workflows directory is special - GitHub automatically looks here for workflow files and runs them when triggered.
Create a workflow file called .github/workflows/node.yml:
# This name appears in the GitHub Actions tab
name: Node.js CI
# Define when this workflow should run
on:
push:
branches: ["main"] # Run when code is pushed to main branch
pull_request:
branches: ["main"] # Run when pull requests target main branch
jobs:
build:
# Use Ubuntu as our virtual machine environment
runs-on: ubuntu-latest
# Test against multiple Node.js versions to ensure compatibility
strategy:
matrix:
node-version: [14.x, 16.x]
steps:
# Download the repository code to the virtual machine
- name: Checkout code
uses: actions/checkout@v2
# Install and configure Node.js
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
# Install dependencies exactly as specified in package-lock.json
- name: Install dependencies
run: npm ci
# Build the application if a build script exists
- name: Run build
run: npm run build --if-present
# Execute the test suite
- name: Run tests
run: npm testThis workflow creates a "build" job that runs on Ubuntu and tests against Node.js versions 14 and 16. Each step has a descriptive name that appears in the GitHub Actions interface.
Commit and push the workflow file:
git add .github/workflows/node.yml
git commit -m "Add GitHub Actions workflow"
git push origin mainNavigate to your GitHub repository in a web browser and click on the "Actions" tab. You should see your workflow running or completed. Each step will show either a green checkmark (success) or red X (failure).
The workflow runs automatically because pushing to the main branch triggers the on: push event. GitHub provides detailed logs for each step, making it easy to debug issues.
This scenario extends the basic CI pipeline to include deployment to an AWS EC2 instance. This simulates how many companies deploy applications to cloud infrastructure.
Set up the following components before proceeding:
AWS EC2 Instance:
- Launch an Ubuntu EC2 instance through the AWS Console
- Configure security group to allow inbound traffic on port 3000 (or your application port)
- Note the public IP address or DNS name
- Ensure you have the SSH private key file
Docker Installation on EC2: Connect to your EC2 instance and install Docker:
sudo apt update
sudo apt install docker.io -y
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker ubuntu # Add ubuntu user to docker groupGitHub Secrets Configuration: Navigate to your GitHub repository, go to Settings → Secrets and variables → Actions, then add these secrets:
AWS_ACCESS_KEY_ID: Your AWS access keyAWS_SECRET_ACCESS_KEY: Your AWS secret keyEC2_HOST: Your EC2 instance public IP or DNSEC2_USER: Usually "ubuntu" for Ubuntu instancesEC2_SSH_KEY: Contents of your private key file
Create a Dockerfile in your project root directory:
# Use Node.js 16 on Alpine Linux for smaller image size
FROM node:16-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy package files first to leverage Docker's layer caching
COPY package*.json ./
RUN npm install
# Copy the rest of the application code
COPY . .
# Expose port 3000 for web traffic
EXPOSE 3000
# Command to start the application
CMD ["npm", "start"]This Dockerfile creates a container image with our Node.js application. The layered approach (copying package files first) means Docker can reuse layers when only application code changes, speeding up builds.
Test the Docker build locally to ensure it works:
docker build -t node-ci-demo .
docker run -p 3000:3000 node-ci-demoVisit http://localhost:3000 to verify the containerized application works correctly.
Replace your existing .github/workflows/node.yml with this enhanced version:
name: Node.js CI/CD
on:
push:
branches: ["main"]
jobs:
# First job: run tests to ensure code quality
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 16
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
# Second job: deploy only if tests pass
deploy:
needs: build # This ensures deploy only runs after build succeeds
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
# Configure SSH to connect to EC2 instance
- name: Setup SSH
uses: webfactory/ssh-agent@v0.5.4
with:
ssh-private-key: ${{ secrets.EC2_SSH_KEY }}
# Connect to EC2 and deploy the application
- name: Deploy to EC2
run: |
ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'EOF'
# Stop and remove existing container if it exists
docker stop node-ci-demo || true
docker rm node-ci-demo || true
docker rmi node-ci-demo || true
# Navigate to app directory (create if doesn't exist)
cd ~/app || mkdir ~/app && cd ~/app
# Get latest code from GitHub
git clone https://github.com/${{ github.repository }} . || git pull
# Build and run new container
docker build -t node-ci-demo .
docker run -d -p 3000:3000 --name node-ci-demo node-ci-demo
EOFThe needs: build declaration ensures the deploy job only runs if the build job completes successfully. The SSH commands handle updating the application on the EC2 instance.
After pushing this updated workflow, GitHub Actions will:
- Run the build job (install dependencies and run tests)
- If tests pass, run the deploy job (connect to EC2 and update the running application)
Visit your EC2 instance's public IP in a web browser:
http://<EC2_PUBLIC_IP>:3000
You should see your application running with the latest changes. Each time you push code to the main branch, this process repeats automatically.
This final scenario demonstrates a production-ready approach using Docker Hub as a container registry. This separates the concerns of building containers and deploying them, following industry best practices.
Create accounts and configure secrets:
Docker Hub Setup:
- Create a Docker Hub account at hub.docker.com
- Create a new repository (e.g., "node-ci-demo")
- Generate an access token in Account Settings → Security
Additional GitHub Secrets: Add these to your repository secrets:
DOCKER_USERNAME: Your Docker Hub usernameDOCKER_PASSWORD: Your Docker Hub password or access token
Replace your workflow file with this production-ready version:
name: Node.js CI/CD with Docker Hub
on:
push:
branches: ["main"]
jobs:
# Test and build the application
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 16
- name: Install dependencies
run: npm ci
# Run tests before building container
- name: Run tests
run: npm test
# Log into Docker Hub using stored credentials
- name: Log in to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
# Build container image and push to Docker Hub
- name: Build and Push Docker Image
run: |
IMAGE_NAME=${{ secrets.DOCKER_USERNAME }}/node-ci-demo:latest
docker build -t $IMAGE_NAME .
docker push $IMAGE_NAME
# Deploy the built image to EC2
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Setup SSH
uses: webfactory/ssh-agent@v0.5.4
with:
ssh-private-key: ${{ secrets.EC2_SSH_KEY }}
# Deploy by pulling the image from Docker Hub
- name: Deploy on EC2
run: |
ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'EOF'
# Clean up existing container
docker stop node-ci-demo || true
docker rm node-ci-demo || true
docker rmi ${{ secrets.DOCKER_USERNAME }}/node-ci-demo:latest || true
# Pull latest image from Docker Hub and run it
docker pull ${{ secrets.DOCKER_USERNAME }}/node-ci-demo:latest
docker run -d -p 3000:3000 --name node-ci-demo ${{ secrets.DOCKER_USERNAME }}/node-ci-demo:latest
EOFThis approach provides several advantages:
- The container is built once and can be deployed to multiple environments
- Docker Hub serves as a versioned artifact repository
- Deployment becomes faster since it only needs to pull and run the pre-built image
- Other team members can pull and run the exact same container locally
After pushing this workflow, observe the complete pipeline:
- Code Push: You push changes to GitHub
- CI Phase: GitHub Actions runs tests and builds a Docker image
- Registry Phase: The image gets pushed to Docker Hub
- CD Phase: EC2 instance pulls the new image and restarts the application
This mirrors how major companies handle deployments - build once, deploy anywhere. Your application is now accessible at your EC2 public IP, running the latest code in a consistent container environment.
Understanding these fundamental concepts will help you customize and extend your workflows:
A workflow is like a recipe that defines the entire automation process. It consists of one or more jobs that can run sequentially or in parallel. Each job contains multiple steps that execute actions or commands.
Events are triggers that start workflows. Common events include:
push: When code is pushed to specific branchespull_request: When pull requests are created or updatedschedule: Time-based triggers using cron syntaxworkflow_dispatch: Manual workflow triggers
Jobs are collections of steps that run on the same virtual machine. Jobs can depend on other jobs using the needs keyword, creating deployment pipelines where testing must pass before deployment begins.
Steps are individual tasks within a job. They can run shell commands or use pre-built actions from the GitHub Marketplace. Actions are reusable units of code that perform specific tasks like checking out code or setting up development environments.
Runners are virtual machines that execute your workflows. GitHub provides hosted runners with Ubuntu, Windows, and macOS environments, or you can use self-hosted runners for more control.
GitHub Actions Workflow Structure:
├── Workflow (node.yml)
│ ├── Event Triggers (push, pull_request)
│ └── Jobs
│ ├── Build Job
│ │ ├── Checkout Code
│ │ ├── Setup Node.js
│ │ ├── Install Dependencies
│ │ └── Run Tests
│ └── Deploy Job
│ ├── Setup SSH
│ └── Deploy to EC2
_GitHub Actions Architecture*
Learning to diagnose and fix common issues is crucial for maintaining reliable CI/CD pipelines. Here are the most frequent problems and their solutions:
Problem: Workflow doesn't trigger after pushing code This often happens when the workflow file has syntax errors or is in the wrong location.
Solutions:
- Verify the file is located at
.github/workflows/filename.ymlin your repository root - Use an online YAML validator to check for syntax errors
- Ensure branch names in the workflow match your actual branch names
- Check the Actions tab for any error messages about workflow parsing
Problem: npm ci command fails during dependency installation
This typically occurs when the package-lock.json file is missing or out of sync.
Solutions:
- Ensure
package-lock.jsonexists in your repository and is committed - Run
npm installlocally to generate or update the lock file - If package-lock.json continues causing issues, temporarily switch to
npm installin the workflow - Delete node_modules and package-lock.json locally, then run
npm installand commit the new lock file
Problem: Docker build fails with permission or authentication errors Container builds can fail due to various Docker-specific issues.
Solutions:
- For Docker Hub authentication errors, verify your username and password/token are correct in GitHub Secrets
- Regenerate Docker Hub access tokens if authentication continues failing
- Ensure the Docker Hub repository exists and your account has push permissions
- Check that Docker is properly installed and running on your EC2 instance
Problem: EC2 deployment fails with SSH connection errors SSH connectivity issues are common when connecting to cloud instances.
Solutions:
- Verify the EC2 instance is running and accessible from the internet
- Check that your security group allows inbound SSH (port 22) connections
- Ensure the SSH private key in GitHub Secrets matches the key pair used for the EC2 instance
- Confirm the EC2_HOST variable contains the correct public IP or DNS name
- Test SSH connection manually from your local machine first
Problem: Application container starts but isn't accessible The container might be running but not properly exposed or configured.
Solutions:
- Verify the security group allows inbound traffic on your application port (typically 3000)
- Check that the Docker run command includes the correct port mapping (
-p 3000:3000) - Ensure your application listens on all interfaces (
0.0.0.0) not just localhost - Use
docker logs container-nameto check for application startup errors
Problem: Old application version still running after deployment Caching issues can cause old containers or images to persist.
Solutions:
- Add explicit cleanup commands to stop and remove old containers before deploying new ones
- Include
docker system prune -foccasionally to clean up unused images and containers - Use specific image tags instead of
latestto ensure proper versioning - Verify that the
docker pullcommand actually retrieves the newest image
When troubleshooting workflow issues, follow this systematic approach:
- Check the Actions Tab: GitHub provides detailed logs for each workflow run, including expanded output for each step
- Enable Debug Logging: Add
ACTIONS_STEP_DEBUG: trueto your workflow environment variables for more verbose output - Add Debug Steps: Insert temporary steps that print environment information or file contents
- Test Locally: Verify that Docker builds and application functionality work on your local machine before debugging the CI/CD pipeline
- Incremental Changes: Make one change at a time to isolate which modification causes issues
To maintain code quality and catch common errors early, we can integrate a linter like ESLint.
-
Install ESLint: First, add ESLint to your project as a development dependency. The
--initcommand will start a configuration wizard to help you set up the rules for your project.npm install eslint --save-dev npx eslint --init
-
Add ESLint to Workflow: To automate code quality checks, add a step to your GitHub Actions workflow that runs ESLint. This will cause the build to fail if any linting errors are found.
- name: Lint code run: npx eslint .
This step will run ESLint on all your project files.
Unit tests are crucial for verifying that individual parts of your application work as expected. We'll use Jest, a popular JavaScript testing framework, along with supertest for testing our Express server endpoints.
-
Install Jest and Supertest: Add Jest and Supertest to your project's development dependencies.
npm install jest supertest --save-dev
-
Create a Test File: Create a test file (e.g.,
app.test.js). Here's a simple test that checks if our main endpoint returns the correct message and a 200 status code. Note that you'll need to export yourappfromindex.jsfor this to work (module.exports = app;).const request = require("supertest"); const app = require("./index"); // Assumes app is exported from index.js describe("GET /", () => { it('should return "Hello CI/CD World!" with a 200 status code', async () => { const res = await request(app).get("/"); expect(res.statusCode).toBe(200); expect(res.text).toBe("Hello CI/CD World!"); }); });
-
Update Workflow to Run Tests: Your
package.jsonshould have atestscript that runs Jest. The GitHub Actions workflow will use this script to execute the tests.- name: Run tests run: npm test
Now, every time you push code, your tests will run automatically.
Always store sensitive information like API keys, passwords, and SSH keys in GitHub Secrets rather than hardcoding them in workflow files. Use principle of least privilege when creating access keys - grant only the minimum permissions necessary for the deployment process.
Pin action versions to specific releases (like @v3) rather than using @main to ensure consistent, predictable behavior. Regularly review and rotate access keys and tokens to minimize security risks.
Use caching strategically to speed up workflows. GitHub Actions can cache dependencies between runs, significantly reducing build times. Enable npm caching by adding cache: 'npm' to the setup-node action.
Structure workflows to fail fast - run quick tests before expensive operations like building Docker images. Use parallel jobs for independent operations like running different test suites.
Write descriptive names for jobs and steps to make workflow logs easier to understand. Add comments to complex workflow sections explaining the purpose and any non-obvious logic.
Consider creating reusable workflows or custom actions for repeated patterns across multiple repositories. This reduces duplication and ensures consistent deployment processes across projects.
Multi-Environment Deployments: Extend the pipeline to deploy to staging environments before production, allowing for additional testing and validation.
Blue-Green Deployments: Implement zero-downtime deployment strategies by running multiple versions of your application simultaneously.
Infrastructure as Code: Use tools like Terraform or AWS CloudFormation in your CI/CD pipeline to manage infrastructure changes alongside application deployments.
Monitoring and Alerting: Integrate monitoring tools and notification systems to alert teams when deployments fail or when applications experience issues.
Security Scanning: Add security scanning steps that check for vulnerabilities in dependencies or container images before deployment.
The GitHub Actions ecosystem includes extensive documentation, community-contributed actions, and learning resources. The GitHub Marketplace contains thousands of pre-built actions for common tasks like deployment to various cloud platforms, running security scans, and integrating with external services.
Engage with the GitHub Community Forums to ask questions, share experiences, and learn from other developers implementing CI/CD pipelines. Many open source projects publish their workflow files, providing real-world examples of advanced CI/CD patterns.
Consider exploring GitHub Learning Lab for interactive, hands-on courses that cover advanced GitHub Actions topics. The official GitHub Actions documentation provides comprehensive reference material for all features and capabilities.
Remember that CI/CD is a journey, not a destination. Start with simple automation like running tests on every push, then gradually add more sophisticated deployment strategies as your team's confidence and requirements grow. Each improvement makes your development process more reliable and efficient, ultimately leading to better software and happier users.















