Skip to content

Node.js Express Example

Complete example: Deploy an Express.js API to Airbase

This example shows how to build and deploy a Node.js Express API server to Airbase, including proper Dockerfile configuration, health checks, and CSP compliance.


Overview

What we're building: - REST API server with Express.js - JSON response handling - Health check endpoint - Environment variable configuration - Multi-stage Docker build

Tech stack: - Node.js 22 - Express.js 4.x - ESM (ES Modules)

Deployment time: ~5 minutes


Project Structure

express-api/
├── src/
│   ├── index.js             # Main application entry point
│   ├── routes/
│   │   └── api.js           # API routes
│   └── middleware/
│       └── logger.js        # Request logging
├── package.json             # Node dependencies
├── package-lock.json        # Lockfile
├── Dockerfile               # Multi-stage build
├── .dockerignore            # Build exclusions
├── airbase.json             # Airbase configuration
└── .env.example             # Environment variable template

Step 1: Create Express Application

package.json:

{
  "name": "express-api",
  "version": "1.0.0",
  "type": "module",
  "description": "Express.js API for Airbase",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "express": "^4.18.3"
  },
  "engines": {
    "node": ">=22.0.0"
  }
}

Key configuration: - ✅ "type": "module" - Enables ES modules - ✅ "engines" - Specifies Node.js version - ✅ Minimal dependencies (just Express)


Step 2: Create Application Code

src/index.js:

import express from 'express';

// Read port from environment (Airbase requirement)
const PORT = process.env.PORT || 3000;
const NODE_ENV = process.env.NODE_ENV || 'development';

const app = express();

// Middleware
app.use(express.json());

// Request logging middleware
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
  });
  next();
});

// Health check endpoint
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    environment: NODE_ENV
  });
});

// Root endpoint
app.get('/', (req, res) => {
  res.json({
    message: 'Welcome to Express API on Airbase',
    version: '1.0.0',
    endpoints: {
      health: '/health',
      api: '/api',
      users: '/api/users'
    }
  });
});

// API routes
app.get('/api', (req, res) => {
  res.json({
    message: 'API endpoint',
    documentation: 'https://docs.airbase.sg'
  });
});

// Sample resource endpoint
app.get('/api/users', (req, res) => {
  // Sample data
  const users = [
    { id: 1, name: 'Alice', email: 'alice@example.gov.sg' },
    { id: 2, name: 'Bob', email: 'bob@example.gov.sg' },
    { id: 3, name: 'Charlie', email: 'charlie@example.gov.sg' }
  ];

  res.json({
    data: users,
    count: users.length
  });
});

// Get user by ID
app.get('/api/users/:id', (req, res) => {
  const userId = parseInt(req.params.id);

  // Sample data
  const users = [
    { id: 1, name: 'Alice', email: 'alice@example.gov.sg' },
    { id: 2, name: 'Bob', email: 'bob@example.gov.sg' },
    { id: 3, name: 'Charlie', email: 'charlie@example.gov.sg' }
  ];

  const user = users.find(u => u.id === userId);

  if (user) {
    res.json({ data: user });
  } else {
    res.status(404).json({ error: 'User not found' });
  }
});

// Create user endpoint (POST)
app.post('/api/users', (req, res) => {
  const { name, email } = req.body;

  if (!name || !email) {
    return res.status(400).json({
      error: 'Name and email are required'
    });
  }

  // In a real app, this would save to a database
  const newUser = {
    id: Date.now(),
    name,
    email
  };

  res.status(201).json({
    message: 'User created',
    data: newUser
  });
});

// 404 handler
app.use((req, res) => {
  res.status(404).json({
    error: 'Not found',
    path: req.path
  });
});

// Error handler
app.use((err, req, res, next) => {
  console.error('Error:', err);
  res.status(500).json({
    error: 'Internal server error',
    message: NODE_ENV === 'development' ? err.message : undefined
  });
});

// Start server
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running on port ${PORT}`);
  console.log(`Environment: ${NODE_ENV}`);
  console.log(`Health check: http://localhost:${PORT}/health`);
});

Key features: - ✅ Reads PORT from environment variable - ✅ Binds to 0.0.0.0 (required for containers) - ✅ Health check endpoint - ✅ Request logging - ✅ Error handling - ✅ RESTful API patterns - ✅ JSON responses


Step 3: Create Dockerfile

Dockerfile:

# Stage 1: Build stage (install dependencies)
FROM gdssingapore/airbase:node-22-builder AS builder

# Set working directory
WORKDIR /app

# Copy package files
COPY --chown=app:app package.json package-lock.json ./

# Install dependencies
RUN npm ci --only=production

# Stage 2: Production stage
FROM gdssingapore/airbase:node-22

# Set environment variables
ENV NODE_ENV=production

# Set working directory
WORKDIR /app

# Copy dependencies from builder
COPY --chown=app:app --from=builder /app/node_modules ./node_modules

# Copy application code
COPY --chown=app:app package.json ./
COPY --chown=app:app src ./src

# Switch to non-root user
USER app

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:' + (process.env.PORT || 3000) + '/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"

# Start application
CMD ["node", "src/index.js"]

Key features: - ✅ Multi-stage build (smaller final image) - ✅ Uses node-22-builder for installing dependencies - ✅ Uses node-22 for runtime (smaller, no build tools) - ✅ Production-only dependencies (npm ci --only=production) - ✅ Non-root user (app) - ✅ Proper file ownership (--chown=app:app) - ✅ Health check configured - ✅ No unnecessary files in final image

Why multi-stage build? - Smaller image size (~200MB vs ~1GB) - Faster deployments - No build tools in production image - Better security


Step 4: Create .dockerignore

.dockerignore:

node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
*.md
README.md
.DS_Store
.vscode
.idea

Why: Excludes unnecessary files from Docker build context.


Step 5: Create Airbase Configuration

airbase.json:

{
  "framework": "container",
  "handle": "your-team/express-api",
  "port": 3000,
  "instanceType": "nano"
}

Configuration notes: - port: 3000 (matches Express server port) - instanceType: nano is sufficient for basic APIs (0.25 vCPU, 512MB RAM) - handle: Replace with your actual project handle


Step 6: Create Environment Variables (Optional)

.env.example:

PORT=3000
NODE_ENV=production

For staging:

Create .env.staging:

PORT=3000
NODE_ENV=staging

For production:

Create .env:

PORT=3000
NODE_ENV=production


Deploy to Airbase

Build and Deploy to Staging

# Navigate to project directory
cd express-api

# Install dependencies locally (for testing)
npm install

# Test locally
npm start
# Visit http://localhost:3000

# Build container
airbase container build

# Deploy to staging
airbase container deploy --yes staging

Expected output:

Building container...
✓ Build complete
✓ Image pushed to registry

Deploying to staging environment...
✓ Deployment successful
✓ Application is live at: https://staging--express-api.app.tc1.airbase.sg

Deployment time: 2-3 minutes

Test in Staging

# Test health check
curl https://staging--express-api.app.tc1.airbase.sg/health

# Test root endpoint
curl https://staging--express-api.app.tc1.airbase.sg/

# Test API endpoint
curl https://staging--express-api.app.tc1.airbase.sg/api/users

# Test specific user
curl https://staging--express-api.app.tc1.airbase.sg/api/users/1

# Test POST endpoint
curl -X POST https://staging--express-api.app.tc1.airbase.sg/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"David","email":"david@example.gov.sg"}'

What to test: - [ ] Health check responds with 200 OK - [ ] API endpoints return JSON - [ ] Error handling works (404, 400) - [ ] POST requests work - [ ] Response times acceptable

Deploy to Production

# If staging tests pass, deploy to production
airbase container deploy --yes

Production URL: https://express-api.app.tc1.airbase.sg


CSP Compliance

Express + CSP

Good news: Express APIs are naturally CSP-compliant when serving JSON.

Why APIs are CSP-friendly: 1. No HTML rendering (just JSON responses) 2. No inline scripts 3. No browser execution context 4. CSP headers don't affect API responses

If serving HTML:

If your Express app serves HTML pages, ensure CSP compliance:

// Set CSP header
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "script-src 'self'"
  );
  next();
});

// Serve HTML with external scripts only
app.get('/page', (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>My Page</title>
      </head>
      <body>
        <div id="app"></div>
        <!-- ✅ External script (CSP-compliant) -->
        <script src="/app.js"></script>
      </body>
    </html>
  `);
});

// Serve the external script
app.use(express.static('public'));

Avoid:

// ❌ Inline script (CSP violation)
res.send(`
  <script>console.log('inline script')</script>
`);


Advanced: Database Integration

Adding PostgreSQL

Install pg package:

npm install pg

Update package.json:

{
  "dependencies": {
    "express": "^4.18.3",
    "pg": "^8.11.3"
  }
}

Create database connection:

src/db.js:

import pg from 'pg';
const { Pool } = pg;

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT || 5432,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

export default pool;

Update index.js to use database:

import pool from './db.js';

// Get users from database
app.get('/api/users', async (req, res) => {
  try {
    const result = await pool.query('SELECT * FROM users');
    res.json({
      data: result.rows,
      count: result.rows.length
    });
  } catch (error) {
    console.error('Database error:', error);
    res.status(500).json({ error: 'Database error' });
  }
});

Update .env:

DB_HOST=your-database-host
DB_PORT=5432
DB_NAME=your_database
DB_USER=your_user
DB_PASSWORD=your_password


Troubleshooting

Issue: App not accessible

Symptom: Deployment succeeds but URL doesn't respond.

Causes: 1. Not binding to 0.0.0.0 2. Wrong port in airbase.json 3. App crashed on startup

Solution:

Ensure server binds to 0.0.0.0:

app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running on port ${PORT}`);
});

Check airbase.json port matches:

{
  "port": 3000
}

Issue: Module not found error

Symptom: Container fails to start with "Cannot find module" error.

Cause: Dependencies not installed in Docker image.

Solution:

Check Dockerfile copies node_modules:

COPY --chown=app:app --from=builder /app/node_modules ./node_modules

Ensure npm ci runs in builder stage:

RUN npm ci --only=production

Issue: Permission denied errors

Symptom: App can't write logs or access files.

Cause: Files not owned by app user.

Solution:

Use --chown=app:app when copying files:

COPY --chown=app:app src ./src

Ensure USER app before CMD:

USER app
CMD ["node", "src/index.js"]

Issue: Slow startup or high memory usage

Symptom: App takes long to start or uses too much memory.

Solution 1: Upgrade instance type

{
  "instanceType": "b.small"
}

Solution 2: Optimize dependencies

Remove unnecessary packages from package.json.

Solution 3: Add caching

Use caching for expensive operations:

import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 600 });

app.get('/api/users', async (req, res) => {
  const cachedUsers = cache.get('users');
  if (cachedUsers) {
    return res.json({ data: cachedUsers });
  }

  // Fetch from database
  const users = await fetchUsers();
  cache.set('users', users);
  res.json({ data: users });
});

Issue: CORS errors

Symptom: Browser can't access API from different origin.

Solution:

Install cors package:

npm install cors

Add CORS middleware:

import cors from 'cors';

app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  credentials: true
}));

Update .env:

ALLOWED_ORIGINS=https://myapp.gov.sg,https://staging--myapp.gov.sg


Production Best Practices

1. Logging

Use structured logging:

npm install pino pino-http
import pino from 'pino';
import pinoHttp from 'pino-http';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
});

app.use(pinoHttp({ logger }));

2. Error Handling

Catch all unhandled errors:

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Application specific logging, throwing an error, or other logic here
});

process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception:', error);
  process.exit(1);
});

3. Graceful Shutdown

Handle shutdown signals:

const server = app.listen(PORT, '0.0.0.0');

process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down gracefully');
  server.close(() => {
    console.log('Server closed');
    process.exit(0);
  });
});

4. Rate Limiting

Protect against abuse:

npm install express-rate-limit
import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP'
});

app.use('/api/', limiter);

5. Input Validation

Validate request data:

npm install express-validator
import { body, validationResult } from 'express-validator';

app.post('/api/users',
  body('email').isEmail(),
  body('name').isLength({ min: 2 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // Process valid request
  }
);

Production Checklist

Before deploying to production:

  • Test all API endpoints in staging
  • Verify health check responds
  • Check error handling (404, 500 responses)
  • Test with invalid input
  • Verify environment variables are set
  • Check logs for errors
  • Test performance under load (use wrk or ab)
  • Ensure proper authentication (if required)
  • Set up rate limiting
  • Configure CORS properly
  • Test database connections (if using)
  • Verify proper instance type

Performance Testing

Using curl

# Simple load test
for i in {1..100}; do
  curl -s https://staging--express-api.app.tc1.airbase.sg/health > /dev/null &
done
wait

Using Apache Bench (ab)

# Install Apache Bench
brew install httpd  # macOS
sudo apt install apache2-utils  # Ubuntu

# Run 1000 requests with 10 concurrent connections
ab -n 1000 -c 10 https://staging--express-api.app.tc1.airbase.sg/

Using wrk

# Install wrk
brew install wrk  # macOS

# Run 30 second test with 10 connections
wrk -t10 -c10 -d30s https://staging--express-api.app.tc1.airbase.sg/

Next Steps

Enhance your API: - Add authentication (JWT, OAuth) - Integrate with databases (PostgreSQL, MongoDB) - Add API documentation (Swagger/OpenAPI) - Implement caching (Redis) - Add monitoring and metrics - Set up automated testing

Resources: - Express.js Documentation - Node.js Best Practices - PostgreSQL Node.js Driver


Complete Example Repository

You can find the complete working example at:

express-api/
├── src/
│   └── index.js          # 150 lines
├── package.json          # 20 lines
├── Dockerfile            # 30 lines
├── .dockerignore         # 10 lines
├── airbase.json          # 6 lines
└── .env.example          # 5 lines

Total: ~220 lines of code for a complete production-ready API.


See Also