DEV Community

Building Serverless API Gateways with Node.js Express and GitHub Actions CI/CD

Building Serverless API Gateways with Node.js Express and GitHub Actions CI/CD

Microservices architectures often require a central API Gateway to manage authentication, rate limiting, logging, and routing to downstream services. Running a traditional, always-on API Gateway on EC2 or Docker containers can lead to idle compute costs and maintenance overhead.

By leveraging Serverless architecture (AWS Lambda & API Gateway) with Node.js Express and GitHub Actions, you can build a highly resilient, auto-scaling API Gateway that costs virtually zero when idle and automates deployments seamlessly.

In this tutorial, we will construct a production-ready Serverless API Gateway from scratch using Node.js Express, wrap it for serverless deployment with serverless-http, and establish a zero-downtime CI/CD pipeline via GitHub Actions.


πŸ—οΈ Architecture Overview

The API Gateway acts as the single point of entry for client requests:

[ Client Request ] 
       β”‚
       β–Ό
[ AWS API Gateway / Lambda (Express.js Gateway) ]
       β”œβ”€β”€ 1. Security Headers (Helmet) & CORS
       β”œβ”€β”€ 2. Rate Limiting (express-rate-limit)
       β”œβ”€β”€ 3. JWT & API Key Authentication
       └── 4. Dynamic Proxy Dispatcher -> [ Downstream Services ]
Enter fullscreen mode Exit fullscreen mode

Key Components:

  • Express.js: Core routing engine and middleware handling.
  • Serverless Framework (serverless-http): Adapts Express request/response objects into AWS Lambda event handlers.
  • GitHub Actions: Automated linting, testing, and deployment to AWS on code pushes.

πŸ“ Project Structure

serverless-api-gateway/
β”œβ”€β”€ .github/
β”‚   └── workflows/
β”‚       └── deploy.yml
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ middleware/
β”‚   β”‚   β”œβ”€β”€ auth.js
β”‚   β”‚   └── rateLimiter.js
β”‚   β”œβ”€β”€ routes/
β”‚   β”‚   └── gateway.js
β”‚   └── app.js
β”œβ”€β”€ tests/
β”‚   └── gateway.test.js
β”œβ”€β”€ handler.js
β”œβ”€β”€ serverless.yml
β”œβ”€β”€ package.json
└── README.md
Enter fullscreen mode Exit fullscreen mode

πŸ’» 1. Setting Up package.json

First, initialize the Node.js project and install the required dependencies:

{
  "name": "serverless-api-gateway",
  "version": "1.0.0",
  "description": "Production-ready Serverless API Gateway with Express.js and GitHub Actions",
  "main": "handler.js",
  "scripts": {
    "start": "serverless offline",
    "test": "jest --detectOpenHandles"
  },
  "dependencies": {
    "axios": "^1.6.8",
    "cors": "^2.8.5",
    "dotenv": "^16.4.5",
    "express": "^4.19.2",
    "express-rate-limit": "^7.2.0",
    "helmet": "^7.1.0",
    "jsonwebtoken": "^9.0.2",
    "serverless-http": "^3.2.0"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "serverless": "^3.38.0",
    "serverless-offline": "^13.3.3",
    "supertest": "^6.3.4"
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ”’ 2. Authentication & Rate Limiting Middleware

Authentication Middleware (src/middleware/auth.js)

const jwt = require('jsonwebtoken');

const API_KEY_SECRET = process.env.API_KEY_SECRET || 'super-secret-api-key';
const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-jwt-key';

function authenticateRequest(req, res, next) {
  const apiKey = req.headers['x-api-key'];
  const authHeader = req.headers['authorization'];

  // 1. API Key Validation
  if (apiKey && apiKey === API_KEY_SECRET) {
    req.user = { role: 'service-account' };
    return next();
  }

  // 2. JWT Bearer Token Validation
  if (authHeader && authHeader.startsWith('Bearer ')) {
    const token = authHeader.split(' ')[1];
    try {
      const decoded = jwt.verify(token, JWT_SECRET);
      req.user = decoded;
      return next();
    } catch (err) {
      return res.status(401).json({ error: 'Invalid or expired authentication token' });
    }
  }

  return res.status(401).json({ error: 'Unauthorized: Missing API Key or Bearer Token' });
}

module.exports = { authenticateRequest };
Enter fullscreen mode Exit fullscreen mode

Rate Limiter Middleware (src/middleware/rateLimiter.js)

const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    status: 429,
    error: 'Too many requests from this IP, please try again after 15 minutes.'
  }
});

module.exports = { apiLimiter };
Enter fullscreen mode Exit fullscreen mode

πŸš€ 3. Core Gateway Express App (src/app.js)

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const { apiLimiter } = require('./middleware/rateLimiter');
const gatewayRoutes = require('./routes/gateway');

const app = express();

// Security and utility middleware
app.use(helmet());
app.use(cors());
app.use(express.json());

// Global Rate Limiter
app.use('/api', apiLimiter);

// Health Check Endpoint
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

// Gateway Proxy Routes
app.use('/api/v1', gatewayRoutes);

// Fallback 404 handler
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found on API Gateway' });
});

module.exports = app;
Enter fullscreen mode Exit fullscreen mode

πŸ”€ 4. Routing & Proxying Engine (src/routes/gateway.js)

const express = require('express');
const axios = require('axios');
const { authenticateRequest } = require('../middleware/auth');

const router = express.Router();

// Downstream service endpoints registry
const SERVICES = {
  USERS: process.env.USERS_SERVICE_URL || 'https://jsonplaceholder.typicode.com/users',
  POSTS: process.env.POSTS_SERVICE_URL || 'https://jsonplaceholder.typicode.com/posts'
};

// Protected User Microservice Proxy
router.get('/users', authenticateRequest, async (req, res) => {
  try {
    const response = await axios.get(SERVICES.USERS, { timeout: 5000 });
    res.status(200).json({
      gateway: 'Serverless-Node-Gateway',
      service: 'users-microservice',
      data: response.data
    });
  } catch (error) {
    res.status(502).json({ error: 'Bad Gateway: Microservice response failed', details: error.message });
  }
});

// Protected Posts Microservice Proxy
router.get('/posts', authenticateRequest, async (req, res) => {
  try {
    const response = await axios.get(SERVICES.POSTS, { timeout: 5000 });
    res.status(200).json({
      gateway: 'Serverless-Node-Gateway',
      service: 'posts-microservice',
      data: response.data.slice(0, 5) // Return sample posts
    });
  } catch (error) {
    res.status(502).json({ error: 'Bad Gateway: Microservice response failed', details: error.message });
  }
});

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

⚑ 5. Serverless Handler (handler.js) & Config (serverless.yml)

Lambda Entrypoint (handler.js)

const serverless = require('serverless-http');
const app = require('./src/app');

module.exports.handler = serverless(app);
Enter fullscreen mode Exit fullscreen mode

Serverless Framework Configuration (serverless.yml)

service: serverless-api-gateway

provider:
  name: aws
  runtime: nodejs18.x
  region: us-east-1
  memorySize: 256
  timeout: 10
  environment:
    API_KEY_SECRET: ${env:API_KEY_SECRET, 'super-secret-api-key'}
    JWT_SECRET: ${env:JWT_SECRET, 'super-secret-jwt-key'}

functions:
  api:
    handler: handler.handler
    events:
      - httpApi:
          path: '*'
          method: '*'

plugins:
  - serverless-offline
Enter fullscreen mode Exit fullscreen mode

βš™οΈ 6. GitHub Actions CI/CD Pipeline (.github/workflows/deploy.yml)

Automate testing and deployment whenever changes are pushed to main:

name: Serverless API Gateway CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test-and-lint:
    name: Run Tests & Audit
    runs-on: ubuntu-latest

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

      - name: Setup Node.js 18.x
        uses: actions/setup-node@v4
        with:
          node-version: 18.x
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Run Unit Tests
        run: npm test

  deploy:
    name: Deploy to AWS Lambda
    needs: test-and-lint
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest

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

      - name: Setup Node.js 18.x
        uses: actions/setup-node@v4
        with:
          node-version: 18.x

      - name: Install Dependencies
        run: npm ci

      - name: Deploy Serverless Service
        uses: serverless/github-action@v3.2
        with:
          args: deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          API_KEY_SECRET: ${{ secrets.API_KEY_SECRET }}
          JWT_SECRET: ${{ secrets.JWT_SECRET }}
Enter fullscreen mode Exit fullscreen mode

πŸ§ͺ 7. Automated Unit Testing with Jest & Supertest (tests/gateway.test.js)

const request = require('supertest');
const app = require('../src/app');

describe('Serverless API Gateway Endpoints', () => {
  it('GET /health - should return status 200 OK', async () => {
    const res = await request(app).get('/health');
    expect(res.statusCode).toEqual(200);
    expect(res.body).toHaveProperty('status', 'healthy');
  });

  it('GET /api/v1/users - should reject unauthorized requests with 401', async () => {
    const res = await request(app).get('/api/v1/users');
    expect(res.statusCode).toEqual(401);
  });

  it('GET /api/v1/users - should succeed with valid X-API-KEY header', async () => {
    const res = await request(app)
      .get('/api/v1/users')
      .set('x-api-key', 'super-secret-api-key');
    expect(res.statusCode).toEqual(200);
    expect(res.body).toHaveProperty('service', 'users-microservice');
  });
});
Enter fullscreen mode Exit fullscreen mode

🎯 Conclusion

By wrapping Express.js with serverless-http and managing deployments via GitHub Actions, you achieve:

  1. Zero Cold-Start Overhead for Light Proxies: Lightweight Node.js routing.
  2. Built-in Protection: Helmet security headers, rate-limiting, and centralized JWT validation.
  3. Automated CI/CD: Automatic test execution and deployment to AWS Lambda on every git push.

This setup offers enterprise-grade API management with serverless cost efficiency.

Top comments (0)